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/SERVERS.md ADDED
@@ -0,0 +1,101 @@
1
+ # Servers
2
+
3
+ Every supervised process is one entry in `$HHOSTED_HOME/servers.config.json`. Add it with **➕ Add
4
+ server** in the panel, or write it by hand — the panel writes the same file.
5
+
6
+ ```json
7
+ {
8
+ "id": "myapp",
9
+ "command": "node",
10
+ "args": ["server.js"],
11
+ "cwd": "./apps/myapp",
12
+ "port": 8080,
13
+ "autostart": true,
14
+ "env": { "NODE_ENV": "production" },
15
+ "health": { "mode": "http", "http": { "path": "/healthz" } }
16
+ }
17
+ ```
18
+
19
+ Relative paths resolve against the directory you ran `home-hosted` from, so a project can keep its
20
+ servers and its state together.
21
+
22
+ ## Field reference
23
+
24
+ | field | what it does |
25
+ | --- | --- |
26
+ | `command`, `args`, `cwd` | what to run, with `{placeholders}` resolved per entry |
27
+ | `env`, `dataEnvs`, `envFile` | environment; `dataEnvs` also marks data directories for backups, `envFile` keeps secrets out of the config |
28
+ | `port`, `bind` | enables the readiness wait, health checks and the conflict preflight; `local` keeps it on `127.0.0.1` |
29
+ | `onPortConflict` | `block` (default), `warn`, or `adopt` — see below |
30
+ | `health.mode` | `port` (TCP connect) or `http` (path, expected status, expected body) |
31
+ | `health.unhealthyThreshold`, `forceRestartAfterMs` | how many failed probes before the card warns, and when to restart anyway |
32
+ | `restart.*` | backoff: `maxRetries`, `baseDelayMs`, `factor`, `maxDelayMs`, `resetAfterMs` |
33
+ | `stop.*` | `signal`, `killGroup`, `graceMs`, and `killPortHolders` to sweep a leftover listener |
34
+ | `dependsOn` | ids that must be healthy first; stopped in reverse order |
35
+ | `resources.maxRssBytes` | restart when the process tree grows past a limit |
36
+ | `bootstrap` | one command to run once before the first start (migrations, warmups) |
37
+ | `backupPaths` | extra paths this entry owns, included in backups |
38
+
39
+ ### Placeholders
40
+
41
+ | placeholder | resolves to |
42
+ | --- | --- |
43
+ | `{id}` `{label}` | the entry's id, and its label (falling back to the id) |
44
+ | `{port}` | its configured port, empty when it has none |
45
+ | `{bind}` `{host}` `{displayHost}` | `local`/`lan`/an address; the address it binds (`127.0.0.1`, `0.0.0.0`); the address to *show* (`127.0.0.1`, the LAN IP) |
46
+ | `{lanIp}` | this machine's LAN address, or `127.0.0.1` |
47
+ | `{cwd}` | the entry's working directory (its `cwd`, else the project directory) |
48
+ | `{projectDir}` | where you ran `home-hosted` (or `--project`) — `$HHOSTED_PROJECT` |
49
+ | `{dataRoot}` | the state directory — `$HHOSTED_HOME`, default `~/.home-hosted` |
50
+ | `{home}` | the current user's home directory |
51
+
52
+ Unknown placeholders stay visible instead of silently becoming empty, and `${VAR}` reads from the
53
+ environment (including `envFile`). Every supervised process also gets `HHOSTED_SERVER_ID` and
54
+ `HHOSTED_CONTROL_PORT`, so a service can tell which entry it is and how to reach the panel.
55
+
56
+ `dataEnvs` is the one that pays for itself twice: the value is exported to the process *and* the path
57
+ is picked up by Backups, so a data directory is declared once.
58
+
59
+ ## A busy port
60
+
61
+ Preflight runs before every start, so two servers cannot silently fight over one port.
62
+
63
+ | `onPortConflict` | what happens when something already listens |
64
+ | --- | --- |
65
+ | `block` *(default)* | the entry goes to **conflict** with `port 4000 is already in use (pid 4242)`, and does not start |
66
+ | `warn` | it starts anyway — useful when the listener is a leftover you are replacing |
67
+ | `follow` | if the listener is a **detached restart of this same entry**, the panel adopts it as-is; anything else blocks, exactly like `block` |
68
+ | `reclaim` | same detection, but it stops that successor and starts a fully supervised process of its own |
69
+
70
+ ### When a program restarts itself
71
+
72
+ Some programs restart themselves by launching a new detached process and exiting — a plugin doing an
73
+ update, a `re-exec` on config change. The panel used to see the successor's port as a conflict and sit
74
+ there blocked while the service was actually up.
75
+
76
+ Every process gets `HHOSTED_SERVER_ID` in its environment and a successor inherits it, so the preflight
77
+ can tell a successor from a stranger (Linux `/proc`, macOS `ps -E`; Windows has no per-process
78
+ environment, so there you get the conflict banner and the free-port button). Two policies act on that:
79
+
80
+ | policy | what it does | trade-off |
81
+ | --- | --- | --- |
82
+ | `follow` | adopts the successor: pid, health probe, CPU/RSS, stop, and it starts its own process again when the successor exits | **keeps exactly what the program set up**, but its output is not captured — the pipe belongs to whoever spawned it, so that entry's log in the panel goes quiet |
83
+ | `reclaim` | stops the successor, then starts a fully supervised process of its own | **full features** — live logs, resources, stop semantics all behave like any other entry — at the cost of one restart |
84
+
85
+ Both are shown as **detached** in the panel while adopted, both can be stopped and restarted like any
86
+ other entry, and neither will ever start a second copy on top of a **stranger**: that still blocks,
87
+ exactly like `block`.
88
+
89
+ If the holder is ours under a different policy, the conflict message says so, which is how the setting
90
+ is discovered:
91
+
92
+ ```text
93
+ port 4374 is already in use (pid 912) — pid 912 is a detached restart of this entry:
94
+ set onPortConflict to "follow" to adopt it, or "reclaim" to replace it with a supervised process
95
+ ```
96
+
97
+ ## Editing fields
98
+
99
+ Changes from the panel are atomic and validated before they are written. Editing an entry in the file
100
+ by hand is picked up without a restart; a value the schema rejects is refused with the exact path, and
101
+ the panel keeps running on the config it already had.
package/UI_CREATION.md CHANGED
@@ -1,77 +1,106 @@
1
1
  # Building a UI for home-hosted
2
2
 
3
- The panel's UI is **a static site, and it is replaceable**. The stock one ships in the
4
- package; yours is a folder of files that the control plane serves instead — same API, same
5
- authentication, no server changes and no fork.
3
+ The panel's UI is **a static site, and it is replaceable**. The stock one ships in the package;
4
+ yours is a folder of files the control plane serves instead — same API, same authentication, no
5
+ server changes and no fork.
6
+
7
+ ## Ask an AI to build it
8
+
9
+ The whole contract is this file, so a coding agent can do the work. Point it at this repo and be
10
+ specific about what you want:
11
+
12
+ > Help me build a UI for `home-hosted`: a nostalgic game theme. Servers as a party menu, health as
13
+ > HP bars, logs in a text-box pane, keyboard navigation, and a save-state corner for backups.
14
+ > Follow `UI_CREATION.md`.
15
+
16
+ Then zip the build and install it (below). Useful constraints to include in the prompt: the API is
17
+ same-origin (relative `/api/...`), `401 { code: 'AUTH_REQUIRED' }` means "show the login screen",
18
+ live data should come from SSE, and assets must be self-hosted. Existing directions to borrow from
19
+ live in [`docs/mockups/`](./docs/mockups) — or ask for something else entirely; the server does not
20
+ care what your UI looks like.
21
+
22
+ ## Install it
6
23
 
7
24
  ```text
8
- $HHOSTED_HOME/.ui/ ← your build lives here
25
+ $HHOSTED_HOME/.ui/ ← where your build lives
9
26
  index.html ← required, at the root
10
27
  assets/…
11
28
  ui.json ← optional: { "name": "my-panel", "version": "2.1.0" }
12
29
  ```
13
30
 
14
- ## Install it
15
-
16
- **Settings → Interface** → pick a `.zip` and *Install UI* (refresh to see it). Or drop the
17
- files in `$HHOSTED_HOME/.ui` yourself. The zip may contain the files at its root, or inside
18
- one wrapper directory (`zip -r ui.zip dist` also works).
31
+ **Settings → Interface** → pick a `.zip` → *Install UI* (refresh to see it). Or drop the files into
32
+ `$HHOSTED_HOME/.ui` yourself. The zip may hold the files at its root or inside one wrapper directory
33
+ (`zip -r ui.zip dist` works too). `ui.json` is optional; it is what the settings page shows as
34
+ installed.
19
35
 
20
- There is no build step on the server side: whatever you upload is served as-is. So ship
21
- plain HTML/JS/CSS, or the output of your own Vite/Next/Astro build with relative asset paths.
36
+ Nothing is built on the server side: whatever you upload is served as-is, so ship plain
37
+ HTML/JS/CSS or the output of your own Vite/Next/Astro build with relative asset paths.
22
38
 
23
39
  **If it breaks:** `home-hosted ui-revert` puts the stock panel back (or *Revert to stock* in
24
- the settings page). The CLI also prints a reminder on startup while a custom UI is active,
25
- because a broken UI must never lock you out of your own server.
40
+ Settings). The CLI also prints a reminder at startup while a custom UI is active — a broken UI must
41
+ never lock you out of your own machine.
26
42
 
27
43
  ## Rules of the road
28
44
 
29
- 1. **Static only.** No server code, no environment variables, no filesystem. Anything you
30
- need comes from the API.
31
- 2. **Same origin, relative paths.** Call `/api/...` (never an absolute host): the panel may
32
- be reached over loopback, a LAN address, TLS or a proxy.
33
- 3. **`index.html` at the root** (or inside a single wrapper directory). Deep links work:
34
- unknown paths fall back to `index.html`, so client-side routing is fine.
35
- 4. **Keep the login flow.** The API answers `401` with `{"code":"AUTH_REQUIRED"}` when a
36
- session is missing — route to your login screen and `POST /api/auth/login`.
37
- 5. **Self-host your assets.** An offline home server should not need a CDN.
38
- 6. **Limits:** a zip of at most 20 000 entries / 512 MB uncompressed, no absolute paths, no
39
- `..`, no symlinks, no drive letters.
45
+ 1. **Static only.** No server code, no filesystem, no environment variables. Everything comes from
46
+ the API.
47
+ 2. **Same origin, relative paths.** Call `/api/...`, never an absolute host: the panel may be
48
+ reached over loopback, a LAN address, TLS or a proxy.
49
+ 3. **`index.html` at the root** (or in a single wrapper directory). Unknown paths fall back to it,
50
+ so client-side routing and deep links just work.
51
+ 4. **Keep the login flow.** Without a session the API answers `401` with `{"code":"AUTH_REQUIRED"}` —
52
+ show your login screen and `POST /api/auth/login`.
53
+ 5. **Self-host your assets.** An offline home server should not need a CDN, and neither should its
54
+ panel.
55
+ 6. **Limits:** ≤ 20 000 entries, ≤ 512 MB uncompressed, no absolute paths, no `..`, no symlinks, no
56
+ drive letters.
57
+ 7. **Offline and plain-http friendly.** Assume a LAN over `http://`: no `Secure`-only cookies, no
58
+ hard-coded port, no https-only APIs.
40
59
 
41
60
  ## The API
42
61
 
43
- Two ways to consume it, both generated from the same ArkType schemas the server validates
44
- with:
62
+ Both routes below are generated from the same ArkType schemas the server validates with, so they
63
+ cannot drift:
45
64
 
46
- | | |
47
- | --- | --- |
48
65
  | approach | how |
49
66
  | --- | --- |
50
- | **OpenAPI** | `GET /openapi/spec.json` (no session needed); browse it at `GET /openapi/ui` |
51
- | **Typed RPC** | building inside this repo: `import type { AppType } from '@server/app'` + `hc<AppType>()`, as `uis/stock/src/lib/rpc.ts` does |
52
- | **Generated types** | `npx openapi-typescript http://127.0.0.1:3999/openapi/spec.json -o src/api.d.ts` |
67
+ | **OpenAPI** | `GET /openapi/spec.json` (no session needed); browse it at `/openapi/ui` |
68
+ | **Typed RPC** | inside this repo: `import type { AppType } from '@server/app'` + `hc<AppType>()`, as `uis/stock/src/lib/rpc.ts` does |
69
+ | **Generated types** | `pnpm dlx openapi-typescript http://127.0.0.1:3999/openapi/spec.json -o src/api.d.ts` |
53
70
 
54
- The stock UI (`uis/stock/src/lib/api.ts`) is the reference client: `fetch` for everything, with
55
- ArkType validating the responses at runtime. Either style is fine.
71
+ `uis/stock/src/lib/api.ts` is the reference client: plain `fetch`, with ArkType validating the
72
+ responses at runtime. Either style is fine.
56
73
 
57
- ### The endpoints you will actually use
74
+ ### Endpoints you will actually use
58
75
 
59
76
  | endpoint | what it gives you |
60
77
  | --- | --- |
61
- | `GET /api/state` | everything: panel settings, servers with live status, host vitals. Live clients should use SSE instead |
62
- | `GET /api/events` | **the live feed.** `event: hello` carries the full state, then `state`, `server` and `log` frames |
78
+ | `GET /api/state` | everything: panel settings, servers with live status, host vitals |
79
+ | `GET /api/events` | **the live feed**: `hello` carries the full state, then `state`, `server`, `log` |
63
80
  | `GET /api/servers/:id/stream` | one server's `server` + `log` frames |
64
81
  | `POST /api/servers/:id/{start,stop,restart}`, `/api/servers/{start-all,stop-all}` | lifecycle |
65
- | `GET /api/servers`, `POST /api/servers`, `PATCH` / `DELETE /api/servers/:id` | the entries themselves |
66
- | `GET /api/logs`, `GET /api/logs/:id?tail=&search=&stream=`, `GET /api/logs/:id/download?file=` | persisted logs |
67
- | `GET` / `PATCH /api/settings`, `POST` / `DELETE /api/settings/tls` and `/api/settings/ui` | the panel's own configuration |
68
- | `POST /api/backups`, `GET /api/backups`, `POST /api/backups/restore`, `GET /api/backups/:name/download`, `DELETE /api/backups/:name` | archives |
69
- | `POST` / `DELETE /api/notifications/token`, `POST /api/notifications/test`, `POST /api/notifications/detect-chats` | Telegram |
70
- | `GET /healthz` | liveness, **no session** — 503 when an autostart server has crashed |
71
- | `GET /api/metrics` | Prometheus text |
72
-
73
- `GET /healthz` and `GET /openapi/*` are the only unauthenticated reads; the SPA shell itself
74
- is public too, so your app can load before a session exists.
82
+ | `POST /api/servers/:id/free-port` | ask whatever holds that server's port to stop (`403`-safe: supervised listeners are refused) |
83
+ | `GET` / `POST /api/servers`, `PATCH` / `DELETE /api/servers/:id` | the entries themselves |
84
+ | `GET /api/logs`, `/api/logs/:id?tail=&search=`, `/api/logs/:id/download?file=` | persisted logs |
85
+ | `GET` / `PATCH /api/settings` | the panel's own config (`control.label`, host thresholds, backups, …) |
86
+ | `POST` / `DELETE /api/settings/ui` | install or revert a UI — what the settings page calls |
87
+ | `POST` / `DELETE /api/settings/tls` | upload or clear a PEM pair |
88
+ | `GET` / `POST /api/backups`, `/api/backups/restore`, `/api/backups/:name/download` | archives |
89
+ | `POST` / `DELETE /api/notifications/token`, `/api/notifications/test`, `/detect-chats` | Telegram |
90
+ | `POST /api/auth/login`, `GET /api/auth/session`, `POST /api/auth/logout` | the session |
91
+ | `GET /healthz` | liveness — **no session**, and `503` when an autostart server has crashed |
92
+ | `GET /api/metrics` | Prometheus text (needs a session) |
93
+
94
+ `GET /healthz` and `GET /openapi/*` are the only unauthenticated reads; the SPA shell itself is
95
+ public, so your app can load before a session exists.
96
+
97
+ Anything calling the API from outside a browser — a script, a test, an agent, a native shell — can
98
+ skip the login dance with an API token: `home-hosted set-token --generate` prints one once, and
99
+ `Authorization: Bearer <token>` authenticates every `/api` request with the same authority as a
100
+ signed-in session. The browser app you ship should still use the cookie.
101
+
102
+ Two settings worth reflecting: `control.label` is the panel's own name (the stock shell shows it),
103
+ and `GET /api/settings` includes `ui` — which UI is being served, and its metadata.
75
104
 
76
105
  ### Failures
77
106
 
@@ -81,58 +110,57 @@ Every failing request answers with one envelope:
81
110
  { "message": "unknown server \"web\"", "code": "UNKNOWN_SERVER", "detail": { "…": "…" } }
82
111
  ```
83
112
 
84
- `code` is stable and machine-readable (`AUTH_REQUIRED` drives the login redirect); `detail`
85
- carries validation issues or context when there is any. Status codes are the usual ones
86
- (400 bad input, 401 no session, 403 bad token/origin, 404 unknown id, 409 conflict, 413 too
87
- large).
113
+ `code` is stable and machine-readable (`AUTH_REQUIRED` drives the login redirect); `detail` carries
114
+ validation issues when there are any. Status codes are the usual ones: 400 bad input, 401 no
115
+ session, 403 bad token or origin, 404 unknown id, 409 conflict, 413 too large.
88
116
 
89
117
  ### SSE frames
90
118
 
91
119
  | `event:` | `data:` |
92
120
  | --- | --- |
93
- | `hello` | `{ ts, state }` — the first frame, with the complete snapshot |
94
- | `state` | `{ ts, state }` — anything changed: a status, a resource sample, the host vitals |
121
+ | `hello` | `{ ts, state }` — the first frame, the complete snapshot |
122
+ | `state` | `{ ts, state }` — anything changed: a status, a resource sample, host vitals |
95
123
  | `server` | `{ ts, serverId, server }` — one entry, after an action or a probe |
96
124
  | `log` | `{ ts, serverId, lines }` — new output (dropped under backpressure, never state) |
97
125
  | `ping` | the current time, every 15 s |
98
126
 
99
- The exact frames are in `src/shared/contracts.ts` (`sseMessageSchema`) — the server validates
100
- against them before writing, so that schema is also your best type source.
101
-
102
- Send `?logs=0` to skip log frames, or `?serverId=<id>` for one server. The server pings every
103
- 15 s.
127
+ Send `?logs=0` to skip log frames, or `?serverId=<id>` to follow one server. The exact shapes are
128
+ `sseMessageSchema` in `src/shared/contracts.ts` — the server validates against it before writing, so
129
+ that schema is also your best type source.
104
130
 
105
- ## Adding a UI to this repo
131
+ ## Building one in this repo
106
132
 
107
- `uis/<name>/` is a Vite app: `index.html`, `src/`, a `tsconfig.json` (copy a sibling's) and a 2-line
108
- `vite.config.ts` calling `createUiConfig` from `uis/vite.shared.ts`. `public/ui.json` names it.
133
+ The repo's own UIs are Vite apps: `uis/<name>/` holds `index.html`, `src/`, a `tsconfig.json`
134
+ (copied from a sibling) and a two-line `vite.config.ts` calling `createUiConfig` from
135
+ `uis/vite.shared.ts`, with `public/ui.json` naming it. Yours does not have to be Vite — only the
136
+ output matters.
109
137
 
110
138
  ```sh
111
- node scripts/build-uis.mjs <name> --zip # builds it and writes uis/dist/home-hosted-ui-<name>.zip
112
- pnpm run build:uis # every UI, zipped; the release attaches them as assets
139
+ node scripts/build-uis.mjs <name> --zip # builds it, writes uis/dist/home-hosted-ui-<name>.zip
140
+ pnpm run build:uis # every UI, zipped; releases attach them as assets
113
141
  ```
114
142
 
115
- Only `stock` ships inside the npm package — the others are release assets you upload from
116
- Settings → Interface. `pnpm run quickcheck` type-checks every UI, and `pnpm test` (vitest) picks up
117
- any `test/*.test.ts` you add (use relative imports; the `@` alias points at `stock`).
143
+ Only `stock` ships inside the npm package; the rest are release assets you install from Settings.
144
+ `pnpm run quickcheck` type-checks every UI, and `pnpm test` picks up any `test/*.test.ts` you add
145
+ (use relative imports — the `@` alias points at `stock`).
118
146
 
119
147
  ## A worked example
120
148
 
121
149
  ```bash
122
150
  # 1. any static framework; the only requirement is a static output
123
151
  npm create vite@latest my-panel -- --template vue-ts
124
- cd my-panel && npm install
125
- npm run build # → dist/
152
+ cd my-panel && pnpm install
153
+ pnpm run build # → dist/
126
154
 
127
- # 2. make sure the API base is relative, then zip the build
155
+ # 2. keep the API base relative, then zip the build
128
156
  cd dist && zip -r ../my-panel.zip . && cd ..
129
157
 
130
- # 3. Settings → Interface → Install UI, and refresh
158
+ # 3. Settings → Interface → Install UI, then refresh
131
159
  ```
132
160
 
133
- Your client needs the session cookie, which the browser sends automatically once you log in
134
- on that origin. For local development, `pnpm dev` in this repo runs the panel on 3999 and a
135
- Vite dev server on 3998 with `/api` proxied, so you can point your own dev server at
161
+ Your client needs the session cookie, which the browser sends automatically once you log in on that
162
+ origin (a non-browser client uses an API token instead, above). For local development `pnpm dev` runs
163
+ the panel on 3999 and a Vite dev server on 3998 with `/api` proxied — point your own dev server at
136
164
  `http://127.0.0.1:3999` the same way.
137
165
 
138
166
  ## Checklist
@@ -140,9 +168,8 @@ Vite dev server on 3998 with `/api` proxied, so you can point your own dev serve
140
168
  - [ ] `index.html` at the root of the zip, assets referenced relatively
141
169
  - [ ] only `/api/...` calls, no absolute origins, no hard-coded port
142
170
  - [ ] `401 { code: 'AUTH_REQUIRED' }` handled with a login screen
143
- - [ ] live data from SSE (a panel that only polls feels broken)
171
+ - [ ] live data from SSE — a panel that only polls feels broken
144
172
  - [ ] deep links render (the server falls back to `index.html`)
145
- - [ ] assets self-hosted; no CDN dependencies
146
- - [ ] works offline over plain http on a LAN (no `Secure`-only cookies, no https assumptions)
147
- - [ ] `ui.json` with a name and version, so *Settings → Interface* can tell you what is
148
- installed
173
+ - [ ] assets self-hosted, no CDN
174
+ - [ ] works over plain http on a LAN
175
+ - [ ] `ui.json` with a name and version, so Settings can tell you what is installed