home-hosted 0.6.7 → 0.7.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
@@ -1,16 +1,20 @@
1
1
  # AGENTS.md
2
2
 
3
3
  `home-hosted` is a Node 24 / TypeScript harness for self-hosted servers: `up` starts a Hono/srvx
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`, `docs/SERVERS.md` (entries and port conflicts),
6
- `docs/NOTIFICATIONS.md`, `docs/DDNS.md`; UI authors: `docs/UI_CREATION.md`.
4
+ panel (default `127.0.0.1:3999`) that supervises the entries in each workspace's
5
+ `servers.config.json` and serves a UI. User docs: `README.md`, `docs/SERVERS.md` (entries and port
6
+ conflicts), `docs/NOTIFICATIONS.md`, `docs/DDNS.md`; UI authors: `docs/UI_CREATION.md`.
7
7
 
8
- State lives only in `$HHOSTED_HOME` (default `~/.home-hosted`): `servers.config.json`,
9
- `.control-secrets.json` (0600: password hash, API token hash, Telegram bot token, DDNS credentials),
10
- `.logs/`, `.tls/`, `.backups/`, `.ui/`, `.state/` (a persistent entry's nanny state, plus its 0600
11
- spawn spec until the nanny reads it, plus `ddns.json` — the last address each target confirmed), and
12
- `run.json` — the live daemon's pid/url/token, 0600. The package ships **no servers**: never commit a
13
- config, a seed entry, or a path that names one.
8
+ State lives only in `$HHOSTED_HOME/.hh` (default `~/.home-hosted/.hh`). Global files sit at its top
9
+ level: `settings.json` (listener, auth, TLS policy, host vitals, backups), `workspaces.json`,
10
+ `.control-secrets.json` (0600: password hash, API token hash), `.tls/`, `.backups/`, `.ui/`, `.logs/`
11
+ (the panel console), and `run.json` — the live daemon's pid/url/token, 0600. Every workspace owns
12
+ `.hh/<id>/`: `settings.json` (server defaults, log retention, notifications, DDNS), `servers.config.json`,
13
+ `.secrets.json` (0600: Telegram bot token and DDNS credentials sealed with AES-256-GCM under
14
+ `HHOSTED_DDNS_SECRET`), `.logs/`, and `.state/` (a persistent entry's nanny state plus its 0600 spawn
15
+ spec until the nanny reads it, plus `ddns.json`). A pre-`.hh` instance is relocated automatically by
16
+ `ensureLayout()` (`src/config/layout.ts`). The package ships **no servers**: never commit a config, a
17
+ seed entry, or a path that names one.
14
18
 
15
19
  ## Commands
16
20
 
@@ -83,10 +87,15 @@ a minor without one.
83
87
  - `src/api/**` — one file per URL group (`$.routes.ts` = several routes), mirroring the path.
84
88
  - `src/shared/contracts.ts` — every ArkType schema (config, API and SSE DTOs), shared with the UIs;
85
89
  the OpenAPI spec is generated from it, never hand-written.
86
- - `src/config/` — `schema.ts` (on-disk shape, including the `meta` stamp), `parse.ts` (the tolerant
87
- reader: unknown keys are reported and kept, everything else blocking), `store.ts` (validate/merge/
88
- atomic commit, reports `configError`/`configWarnings` instead of throwing on a bad file),
89
- `migrations.ts` (the schema constant and the ordered step registry), `secrets.ts`, `seed.ts`.
90
+ - `src/config/` — split by scope. `settings.ts` (`GlobalSettingsStore`: `.hh/settings.json` — listener,
91
+ auth, TLS policy, host vitals, backups) and `store.ts` (`WorkspaceStore`: a workspace's
92
+ `settings.json` + `servers.config.json`, validate/merge/atomic commit, reporting
93
+ `configError`/`configWarnings` instead of throwing on a bad file). `workspaces.ts` is the registry
94
+ (`workspaces.json`), `layout.ts` the one-time relocation of a pre-workspace `$HHOSTED_HOME`.
95
+ `schema.ts` holds the three on-disk shapes plus the `meta` stamp, `parse.ts` the tolerant readers
96
+ (`parseGlobalSettings`/`parseWorkspaceSettings`/`parseServersFile`: unknown keys are reported and
97
+ kept, everything else blocking), `patch.ts` the merge helpers, `migrations.ts` the schema constant
98
+ and ordered step registry, and `secrets.ts`/`seed.ts` the two secret scopes and the seeds.
90
99
  - `src/providers/` — stateless leaves: `process` (spawn, `terminate`, `terminatePid` for a process we
91
100
  adopted), `port` (probe, holder lookup, `terminatePids`), `proc` (the sampler, plus
92
101
  `processCarriesServerId` for the environment marker and `processTreePids` for tree ownership),
@@ -95,12 +104,13 @@ a minor without one.
95
104
  `log-tail` (an offset reader that survives rotation and truncation), `ddns/` (one `DdnsProvider` per
96
105
  registrar over one HTTP call each, plus `ip` and `zone` helpers), health-check, host, telegram,
97
106
  archive.
98
- - `src/services/` — stateful orchestration: supervisor, control-server, config-watch, state,
99
- auth + exposure, dependencies, history, log-buffer/log-files, notifications, host-monitor, ddns,
100
- backups, tls, ui, plus `init` (the scaffold behind `home-hosted init`: a manifest, a `.gitignore`,
101
- and the prompts stay in the CLI), `nanny` (the process a persistent entry runs under) and
102
- `log-relay` (the tailer that feeds its lines to the supervisor). It names no server — the scaffold
103
- must stay as neutral as the supervisor.
107
+ - `src/services/` — stateful orchestration: `panel` (one `WorkspaceRuntime` per workspace, workspace
108
+ CRUD, and the single aggregate state frame — every per-workspace service hangs off it), supervisor,
109
+ control-server, config-watch, state, auth + exposure, dependencies, history, log-buffer/log-files,
110
+ notifications, host-monitor, ddns, backups, tls, ui, plus `init` (the scaffold behind
111
+ `home-hosted init`: a manifest, a `.gitignore`, and the prompts stay in the CLI), `nanny` (the
112
+ process a persistent entry runs under) and `log-relay` (the tailer that feeds its lines to the
113
+ supervisor). It names no server — the scaffold must stay as neutral as the supervisor.
104
114
  - `src/middleware/auth.ts` — the `/api/*` guard, and `requestIdentity()`, the one place a request's
105
115
  credentials are read: the `hh_session` cookie or `Authorization: Bearer <api token>`. A token is
106
116
  a first-class credential (same authority as a signed-in browser) and is verified from the secrets
@@ -111,7 +121,7 @@ a minor without one.
111
121
  - `uis/<name>/` — each UI is a Vite app (Vue 3 + Tailwind v4) built through `uis/vite.shared.ts`;
112
122
  `stock` is the one shipped inside the package. Aliases: `@` → that UI's `src`, `@shared` →
113
123
  `src/shared`, `@server` → `src` (**types only** — never import runtime server code into a UI).
114
- Its `public/ui.json` is the UI's identity: `name` and `version` (what Settings shows),
124
+ Its `public/ui.json` is the UI's identity: `name` and `version` (what Global Settings → Interface shows),
115
125
  `repo`/`tag`/`asset` (which release carries it, for `ui-update`) and `unix` (when it was built).
116
126
  `UiService` carries those fields into the installed `$HHOSTED_HOME/.ui/ui.json` unchanged and adds
117
127
  `uploadedAt`/`files` of its own.
@@ -140,7 +150,7 @@ a minor without one.
140
150
  - A single on/off setting is a `ToggleSwitch`; `CheckField` is only for picking items out of a set
141
151
  (the restore plan). A checkbox in a `FieldGroup` grid reads as misaligned next to the inputs.
142
152
  - A create body carries only what differs from what the entry would inherit — the schema's defaults
143
- with the panel's `Settings → Server defaults` on top (`inheritBaseline` + `diffFields` in both
153
+ with `Workspace Settings → Server defaults` on top (`inheritBaseline` + `diffFields` in both
144
154
  UIs). A value written into `servers.config.json` stops following those defaults, so anything the
145
155
  person did not decide stays out; the editor applies the same rule to an edit.
146
156
  - ArkType at every runtime boundary: routes use `validate('json'|'query'|'param', schema)` then
@@ -149,7 +159,9 @@ a minor without one.
149
159
  envelope `{ message, code, detail }`. Never hand-roll `c.json({ error })`.
150
160
  - Document routes with `describeRoute` + `jsonBody(schema)`; `jsonBody` needs a real schema.
151
161
  - Patch schemas carry no defaults; nested groups (`restart`/`health`/`stop`/`auth`/`tls`/`telegram`/
152
- `http`) merge key-by-key, and an explicit `null` clears a key.
162
+ `http`) merge key-by-key, and an explicit `null` clears a key. `ddns` is the exception: its
163
+ `accounts`/`domains` lists are **replaced** (a merge cannot express removing a hostname), and only
164
+ `ipv4`/`ipv6` merge.
153
165
  - Two-sided bounds read inclusively (`'1 <= number.integer <= 512'`). `test/shared/contracts.test.ts`
154
166
  pins every boundary and the patch/schema parity — update it with any schema change.
155
167
  - A new field in a **response** DTO is optional (`'x?'`) and its clients read it defensively. An
@@ -165,6 +177,12 @@ a minor without one.
165
177
  - A destructive action that is one click away confirms in a **popover** (`KillPortButton.vue`),
166
178
  never by arming the same button for a second press: an impatient double click on an arming
167
179
  button fires it. Keep the safe choice first in the popover's tab order.
180
+ - **Docs ship with the change.** A user-visible change updates the docs that describe it
181
+ (`README.md`, `docs/*.md`) in the same commit — do not forget them; a stale doc is a bug like
182
+ stale code.
183
+ - **UIs move together.** `uis/stock` is not the only client: a change to it — or to a shared
184
+ contract it reads — lands in every other UI under `uis/`, and each altered UI bumps its
185
+ `ui.json` per the rule above.
168
186
 
169
187
  ## Compatibility
170
188
 
@@ -194,10 +212,14 @@ either is a last resort, and never an accidental one.
194
212
  - **Unknown keys are dropped from the resolved config, kept on disk, and listed in a startup
195
213
  warning.** Dropping one is the normal way a newer config looks here, so it must never fail the
196
214
  group it sits in (that used to reset `control` — port, bind, auth policy — to schema defaults).
215
+ A *write* to a group validates the same tolerant way (`parseTolerant` via the store), so a key this
216
+ release does not know never blocks a save — removing a key from a schema must not brick the page
217
+ that writes it.
197
218
  - **Migrations ship inside the package** (`src/config/migrations.ts`), are ordered, idempotent and
198
- described in one line each. `home-hosted migrate` prints the plan, keeps `servers.config.json.bak`,
199
- refuses to write a config it cannot read, and needs consent: `--yes`, `HHOSTED_MIGRATE=allow`, or a
200
- person at a terminal. A detached daemon never migrates on its own. Starting a fetch of migration
219
+ described in one line each. `home-hosted migrate` prints the plan, keeps a `.bak` beside each file it
220
+ rewrites, refuses to write a config it cannot read, and needs consent: `--yes`, `HHOSTED_MIGRATE=allow`,
221
+ or a person at a terminal. The layout relocation is not a schema change: it runs automatically from
222
+ the CLI pre-pass (`ensureLayout()`), so an upgrade never starts against a half-moved directory. Starting a fetch of migration
201
223
  code from GitHub was considered and rejected: the panel supervises processes, so remote code is an
202
224
  RCE surface, and a migration would age against a newer store API anyway.
203
225
 
@@ -205,11 +227,13 @@ either is a last resort, and never an accidental one.
205
227
 
206
228
  - **Server-agnostic.** No blessed ids, no `dataDir`-style globals: a server gets only its own
207
229
  `command`/`args`/`env`/`dataEnvs`/`envFile`/`bootstrap`. A test fails if a core file learns one.
208
- - **Paths.** `dataRoot` is state; `projectDir` is the base for relative entry paths. `{id}{port}`
230
+ - **Paths.** `dataRoot` is state, and the state itself lives under `dataRoot/.hh` (global files plus
231
+ one directory per workspace); `projectDir` is the base for relative entry paths. `{id}{port}`
209
232
  `{host}{bind}{cwd}{projectDir}{dataRoot}{home}` and `${ENV}` expand in config; there is no
210
233
  package-relative state.
211
- - **Secrets never enter the config.** The password hash, API token hash and bot token live in the
212
- 0600 secrets file, and the TLS pair in `.tls/`; the config holds policy.
234
+ - **Secrets never enter the config.** Global `.hh/.control-secrets.json` holds the password hash and
235
+ API token hash, each workspace's `.secrets.json` holds its Telegram token and DDNS credentials, and
236
+ the TLS pair sits in `.hh/.tls/`; every one is 0600 and the config holds only policy.
213
237
  - **A port holder that is this entry's own successor is not a stranger.** A program that restarts
214
238
  itself leaves a detached process behind; with `follow` the panel adopts it as-is (pid, liveness,
215
239
  health, resources, stop — but not its output); with `reclaim` it stops that successor and starts a
@@ -252,8 +276,14 @@ either is a last resort, and never an accidental one.
252
276
  - **The CLI drives a running panel through `/_hh`, never `/api`.** `down` and `start`/`stop` read
253
277
  run.json's token and speak over loopback through one guard (`assertLocalCall`), so they need no
254
278
  session, password or API token — and a new local command must not open an unauthenticated `/api` route.
279
+ - **A server id is only unique inside its workspace.** Anything keyed by id alone — SSE
280
+ (`EventHub` scopes on `serverKey(workspaceId, serverId)`), log buffers, series, route params — must key
281
+ on the pair, or two workspaces' same-named servers mix frames.
282
+ - **A workspace is the ownership boundary.** Servers, secrets, logs and nanny state belong to exactly
283
+ one workspace; only listener, auth, TLS, host vitals, backups, UI and `run.json` are panel-wide.
284
+ A per-workspace service is reached through `PanelService.requireWorkspace(id)`, never a module global.
255
285
  - **UIs are external clients.** Nothing in `src/**` may know a UI's markup or files;
256
- `$HHOSTED_HOME/.ui` overrides the packaged UI at runtime.
286
+ `$HHOSTED_HOME/.hh/.ui` overrides the packaged UI at runtime.
257
287
  - **A restore writes only where a config says** — archive allowlist, `origin` matching first,
258
288
  symlinks never recreated.
259
289
  - **Cross-platform.** Linux/macOS/Windows: `/proc` vs `ps` vs Win32_Process, process groups vs
@@ -263,7 +293,11 @@ either is a last resort, and never an accidental one.
263
293
  ## Gotchas
264
294
 
265
295
  - `run.json` is the daemon's identity and `down`'s credential; keep `runtimeSchema` in sync with the
266
- `Runtime` written in `src/index.ts`.
296
+ `Runtime` written in `src/index.ts`. It now lives at `.hh/run.json`.
297
+ - **`ensureLayout()` runs before any command resolves a state path** (the CLI pre-pass) and is
298
+ idempotent. It moves rather than copies, never overwrites an existing `.hh` file, and removes a
299
+ legacy directory only when every entry in it actually moved — a refused move must not become a
300
+ delete.
267
301
  - Moving the listener (host/port/TLS) kills the connection answering that request, so
268
302
  `PATCH /api/settings`, the TLS routes and `/_hh/shutdown` defer with `afterResponse()`.
269
303
  - `Supervisor.start()` sets `starting` synchronously before its first await, and `stop()` sets
@@ -273,15 +307,15 @@ either is a last resort, and never an accidental one.
273
307
  is true, one Stop on an already-stopped server made that entry unstartable until a daemon restart.
274
308
  - Timers and `void`-ed promises in the supervisor carry a `.catch`: a throw in the tick or in a retry
275
309
  is an unhandled rejection, and Node 24 ends the process on one.
276
- - **`Settings → Server defaults` merge into the nested groups key by key** (`mergeDefaults` in
277
- `src/config/schema.ts`, used by both the config parser and `validateServer`). A shallow
310
+ - **`Workspace Settings → Server defaults` merge into the nested groups key by key** (`mergeDefaults`
311
+ in `src/config/schema.ts`, used by the servers parser and `validateServer`). A shallow
278
312
  `{ ...defaults, ...entry }` lets an entry that decides `restart.maxRetries` silently drop the
279
- panel's `restart.baseDelayMs` — and the create/edit diff flow depends on inheriting exactly that.
313
+ workspace's `restart.baseDelayMs` — and the create/edit diff flow depends on inheriting exactly that.
280
314
  - The curated CLI dispatch reads only the **flag** forms (`--help`/`-h`, `--version`/`-v`) after a
281
315
  command name, never a bare `help`/`version`: that is an option's value (`init --name help`).
282
316
  - Port preflight re-probes after 300 ms — a just-closed listener can still complete a handshake.
283
- - **The config is re-read whenever the file changes on disk** (`src/services/config-watch.ts` →
284
- `ConfigStore.reloadFromDisk()`, wired in `src/index.ts`). The watch is on the *directory*, because
317
+ - **A workspace's config is re-read whenever its files change on disk** (`src/services/config-watch.ts`
318
+ → `WorkspaceStore.reloadFromDisk()`, wired per workspace in `src/services/panel.ts`). The watch is on the *directory*, because
285
319
  an editor's save is a temporary file renamed over the target — the inode changes, the name does not
286
320
  — and a two-second poll backs it up where `fs.watch` is undependable (network mounts). The store
287
321
  compares the bytes it last read and the bytes it wrote, so the panel's own saves never reload
@@ -344,11 +378,14 @@ either is a last resort, and never an accidental one.
344
378
  ## Where to extend
345
379
 
346
380
  - **Route**: `src/api/<x>.ts` (chained factory) → mount in `src/app.ts` → schemas in
347
- `src/shared/contracts.ts` → `describeRoute` + `jsonBody`.
348
- - **Server field**: `serverSchema` + its patch in contracts, merge keys in `config/store.ts` when
381
+ `src/shared/contracts.ts` → `describeRoute` + `jsonBody`. A workspace-scoped route takes
382
+ `?workspace=<id>` (`workspaceQuerySchema` + `requireWorkspace` in `src/helpers/workspace.ts`); an
383
+ omitted id means the default workspace, never another one.
384
+ - **Server field**: `serverSchema` + its patch in contracts, merge keys in `config/patch.ts` when
349
385
  nested, the form in `uis/stock/src/components/settings/`, and the contract tests.
350
386
  - **UI**: a new `uis/<name>/` with a `vite.config.ts` from the shared factory;
351
- `node scripts/build-uis.mjs <name> --zip`. The contract is `docs/UI_CREATION.md`.
387
+ `node scripts/build-uis.mjs <name> --zip`. The contract is `docs/UI_CREATION.md`. A change to one
388
+ UI (`stock`, `noc-console`) is carried to the others in the same commit.
352
389
  - **Capability**: a stateless `src/providers/*` returning plain data; a DDNS provider is one file plus
353
390
  a registry line (`src/providers/ddns/index.ts`), and the settings form, credentials and validation
354
391
  follow from its metadata — see `docs/DDNS.md`.
package/README.md CHANGED
@@ -7,7 +7,8 @@
7
7
  <sub>The harness for your servers.</sub>
8
8
 
9
9
  Point it at the things you run — a gateway, a media server, a bot, a database — and it starts
10
- them, watches them, restarts what dies, and shows you one page of what is going on. Or
10
+ them, watches them, restarts what dies, and shows you one page of what is going on. Split them into
11
+ [workspaces](#-workspaces) when one panel holds more than one setup. Or
11
12
  [BYOU](#-bring-your-own-ui-byou), for a specialized UI that fits you exactly.
12
13
 
13
14
  [![npm](https://img.shields.io/npm/v/home-hosted.svg)](https://www.npmjs.com/package/home-hosted)
@@ -16,7 +17,7 @@ them, watches them, restarts what dies, and shows you one page of what is going
16
17
  [![License](https://img.shields.io/npm/l/home-hosted.svg)](./LICENSE)
17
18
  [![Node](https://img.shields.io/node/v/home-hosted.svg)](https://nodejs.org)
18
19
 
19
- [🚀 Quick start](#-quick-start) · [🤖 Agents & API](#-agents-scripts-and-tools) · [✨ Features](#-features) · [🧩 Servers](./docs/SERVERS.md) · [🛠 CLI](#-cli) · [🔔 Notifications](./docs/NOTIFICATIONS.md) · [🎨 BYOU](#-bring-your-own-ui-byou)
20
+ [🚀 Quick start](#-quick-start) · [🗂 Workspaces](#-workspaces) · [🧩 Servers](./docs/SERVERS.md) · [🤖 Agents & API](#-agents-scripts-and-tools) · [✨ Features](#-features) · [🛠 CLI](#-cli) · [🔔 Notifications](./docs/NOTIFICATIONS.md) · [🎨 BYOU](#-bring-your-own-ui-byou)
20
21
 
21
22
  </div>
22
23
 
@@ -101,7 +102,7 @@ npx home-hosted down # stops the panel *and* everything it started
101
102
 
102
103
  > [!NOTE]
103
104
  > The first boot writes a default password (`hh`) so the panel is never unprotected. Change
104
- > it under **Settings → Authentication** — binding beyond `127.0.0.1` stays refused until you do.
105
+ > it under **Global settings → Authentication** — binding beyond `127.0.0.1` stays refused until you do.
105
106
 
106
107
  <details>
107
108
  <summary><b>📦 Install it instead of npx-ing it</b></summary>
@@ -111,8 +112,8 @@ npm install -g home-hosted # or: pnpm add -g home-hosted
111
112
  home-hosted up # `hh up` does the same
112
113
  ```
113
114
 
114
- Everything it owns — config, secrets, logs, TLS, backups — lives in `$HHOSTED_HOME`, default
115
- `~/.home-hosted`. Delete that and nothing of yours is left behind.
115
+ Everything it owns — workspaces, settings, secrets, logs, TLS, backups — lives under
116
+ `$HHOSTED_HOME/.hh`, default `~/.home-hosted/.hh`. Delete that and nothing of yours is left behind.
116
117
 
117
118
  <sub>An installed home-hosted answers to **`hh`** too — `hh up`, `hh status`, `hh down`. Only the
118
119
  installed form gets it; npx stays `npx home-hosted …`.</sub>
@@ -146,9 +147,13 @@ It writes a manifest whose scripts all pass `--home ./state`:
146
147
 
147
148
  ```gitignore
148
149
  node_modules/
149
- state/* # secrets, logs, TLS keys and archives stay local…
150
- !state/servers.config.json # …but the server definitions are committed
151
- data/ # per-server data directories, declared through dataEnvs
150
+ state/.hh/* # secrets, logs, TLS keys and archives stay local…
151
+ !state/.hh/settings.json # …but the workspace definitions are committed
152
+ !state/.hh/workspaces.json
153
+ state/.hh/*/*
154
+ !state/.hh/*/settings.json
155
+ !state/.hh/*/servers.config.json
156
+ data/ # per-server data directories, declared through dataEnvs
152
157
  ```
153
158
 
154
159
  One clone, `pnpm install --frozen-lockfile`, `pnpm run up` — the setup is up on any machine with Node.
@@ -196,25 +201,27 @@ home-hosted set-token --generate
196
201
  # hh_9uA2… (printed once; only its hash is kept, mode 0600)
197
202
 
198
203
  curl -H "Authorization: Bearer hh_9uA2…" http://127.0.0.1:3999/api/state
199
- curl -H "Authorization: Bearer hh_9uA2…" -X POST http://127.0.0.1:3999/api/servers/omniroute/restart
200
- curl -N -H "Authorization: Bearer hh_9uA2…" 'http://127.0.0.1:3999/api/events?serverId=omniroute' # SSE
204
+ curl -H "Authorization: Bearer hh_9uA2…" -X POST 'http://127.0.0.1:3999/api/servers/omniroute/restart?workspace=default'
205
+ curl -N -H "Authorization: Bearer hh_9uA2…" 'http://127.0.0.1:3999/api/servers/omniroute/stream?workspace=default' # SSE
201
206
  home-hosted status --json # machine-readable: pid, url, health, paths
202
207
  ```
203
208
 
204
209
  A token has the same authority as a signed-in browser and outlives restarts; `set-token --clear`
205
- revokes it instantly.
210
+ revokes it instantly. Workspace-scoped routes take `?workspace=<id>`; omitting it means the panel's
211
+ default workspace.
206
212
 
207
213
  <details>
208
214
  <summary><b>🌐 The endpoints worth knowing</b></summary>
209
215
 
210
216
  | | |
211
217
  | --- | --- |
212
- | `GET /api/state` | the full snapshot: config, live status, host vitals |
213
- | `GET /api/events` | SSE: the live state, plus logs (`?logs=0`, `?serverId=…`) |
214
- | `GET /api/servers/:id/stream` | SSE: one server's state and logs |
215
- | `POST /api/servers/:id/{start,stop,restart}` | lifecycle |
218
+ | `GET /api/state` | the full snapshot: global settings, every workspace with its servers, host vitals |
219
+ | `GET`/`POST /api/workspaces`, `PATCH`/`DELETE /api/workspaces/:id` | the registry: list, create, rename, remove |
220
+ | `GET /api/events` | SSE: the live state, plus logs (`?logs=0`, `?serverId=…`; a `server` payload carries its `workspaceId`) |
221
+ | `GET /api/servers/:id/stream` | SSE: one server's state and logs (`?workspace=…`) |
222
+ | `POST /api/servers/:id/{start,stop,restart}` | lifecycle (workspace-scoped) |
216
223
  | `POST /api/servers/:id/free-port` | ask whatever holds that server's port to stop |
217
- | `PATCH /api/servers/:id`, `PATCH /api/settings` | edit configuration |
224
+ | `PATCH /api/servers/:id`, `PATCH /api/settings`, `PATCH /api/settings/workspace` | edit configuration |
218
225
  | `GET /api/logs`, `/api/backups` | history and archives |
219
226
  | `PUT`/`DELETE /api/notifications/token`, `POST /api/notifications/{test,detect-chats}` | the bot credential, a test send |
220
227
  | `GET /healthz` | no session needed — the one an external monitor wants (its per-server detail needs a credential) |
@@ -246,8 +253,9 @@ can be told what to be: *"Help me build a UI for home-hosted: nostalgic game the
246
253
 
247
254
  | | |
248
255
  | --- | --- |
256
+ | 🗂 **Workspaces** | One panel, many scopes: pick a workspace in the header and it owns its servers, secrets, logs and settings. Create, rename and delete them there. Panel-wide things — listener, auth, host vitals, backups, TLS, UI — stay in **Global settings**. |
249
257
  | 🚦 **Lifecycle** | Start, stop, restart from the panel or the API; `autostart` entries come up with it. |
250
- | 📝 **Hand edits welcome** | Change `servers.config.json` in an editor, a `git checkout` or a config tool: the panel notices within seconds, no restart. A file it cannot read is reported in the panel, and the running servers are left alone. |
258
+ | 📝 **Hand edits welcome** | Change a workspace's `servers.config.json` in an editor, a `git checkout` or a config tool: the panel notices within seconds, no restart. A file it cannot read is reported in the panel, and the running servers are left alone. |
251
259
  | ♻️ **Auto-restart** | Exponential backoff on crash, with the counter reset once a process stays up. |
252
260
  | 🩺 **Health that acts** | TCP or HTTP probes per server: warn on the card, force a restart after a timeout, check ports before starting — and [follow or replace](./docs/SERVERS.md#when-a-program-restarts-itself) a program that restarts itself. |
253
261
  | 🔗 **Ordered startup** | `dependsOn` waits for a dependency to be *healthy* — not merely spawned — and stops in reverse. |
@@ -257,7 +265,7 @@ can be told what to be: *"Help me build a UI for home-hosted: nostalgic game the
257
265
  | 🤖 **Token API** | Scripts and agents drive it with `Authorization: Bearer` — no browser, no session. [↑](#-agents-scripts-and-tools) |
258
266
  | 🔔 **Notifications** | Telegram on crash, unhealthy, forced restart, recovery, host thresholds and DNS changes — [setup here](./docs/NOTIFICATIONS.md). |
259
267
  | 🌐 **Dynamic DNS** | Keep hostnames pointed at your public IP — Cloudflare, Namecheap, Spaceship, Porkbun, GoDaddy, Gandi and more. [DDNS.md](./docs/DDNS.md) |
260
- | 💾 **Backups** | One click for config, secrets, TLS and your declared data directories — plain `.zip`, or AES-256 with a password, restored per path. |
268
+ | 💾 **Backups** | Two levels: pick global settings, global secrets, TLS or a whole workspace at the top, then the pieces inside it (settings, servers, secrets, data directories) — plain `.zip`, or AES-256 with a password, restored per path. |
261
269
  | 🎨 **BYOU — Bring Your Own UI** | Upload a static build, `ui-update` to follow its releases, `ui-revert` to go back. [UI_CREATION.md](./docs/UI_CREATION.md) |
262
270
  | 🔐 **Security** | Cookie sessions, API tokens, scrypt hashes, per-IP lockout, optional TLS, and a refusal to expose itself without a password. |
263
271
  | 🧩 **No special treatment** | A server is `command` + `args` + `env` + `cwd`; nothing is built in for any particular app. |
@@ -265,9 +273,46 @@ can be told what to be: *"Help me build a UI for home-hosted: nostalgic game the
265
273
 
266
274
  ---
267
275
 
276
+ ## 🗂 Workspaces
277
+
278
+ A workspace is the ownership boundary: its own servers, secrets, logs, nanny state and settings. The
279
+ header picker is the whole control — choose one, create one, rename it, or delete it (which stops
280
+ what it supervises first). The first boot creates a `default` workspace; a pre-workspaces instance is
281
+ relocated into one automatically.
282
+
283
+ > [!IMPORTANT]
284
+ > **Upgrading from before workspaces (0.6 → 0.7)?** That release is breaking: state moves under
285
+ > `$HHOSTED_HOME/.hh`, settings split global vs. per-workspace, and backups become two-level. The
286
+ > relocation runs on the next command — `home-hosted migrate` reports it and stamps every file. See
287
+ > [Upgrading](#-cli).
288
+
289
+ | page | url | scope |
290
+ | --- | --- | --- |
291
+ | **Overview** | `/w/<workspace>` | the selected workspace: its servers, counts and live status |
292
+ | **Servers** · **Logs** · **Workspace settings** | `/w/<workspace>/servers` · `/w/<workspace>/logs` · `/w/<workspace>/settings` | the selected workspace |
293
+ | **Global Overview** | `/global/overview` | host vitals plus every server from every workspace |
294
+ | **Global settings** | `/global/settings` | Listener, Authentication, Host vitals, Backups, TLS, Interface, Paths |
295
+
296
+ Every page is bookmarked by its own URL: the workspace id is part of it, so a link opens the same
297
+ workspace even in a browser that never selected it. `/` opens **Global Overview**. The sidebar lists
298
+ the panel-wide pages first, then the selected workspace's.
299
+
300
+ <details>
301
+ <summary><b>🗂 What lives where</b></summary>
302
+
303
+ | | |
304
+ | --- | --- |
305
+ | global — `$HHOSTED_HOME/.hh/` | `settings.json` (listener, auth, TLS, host vitals, backups), `workspaces.json`, `.control-secrets.json` (password + API token, 0600), `.tls/`, `.ui/`, `.backups/`, `.logs/`, `run.json` |
306
+ | workspace — `.hh/<id>/` | `settings.json` (server defaults, logs, notifications, DDNS), `servers.config.json`, `.secrets.json` (Telegram + DDNS credentials, 0600), `.logs/`, `.state/` |
307
+
308
+ </details>
309
+
310
+ ---
311
+
268
312
  ## 🧩 Servers
269
313
 
270
- An entry is a few lines. Add one with **➕ Add server**, or write it into `servers.config.json`:
314
+ An entry is a few lines. Add one with **➕ Add server**, or write it into the selected workspace's
315
+ `servers.config.json` (`$HHOSTED_HOME/.hh/<workspace>/servers.config.json`):
271
316
 
272
317
  ```json
273
318
  {
@@ -294,11 +339,11 @@ restarts itself), and how hand-edits are validated: [SERVERS.md](./docs/SERVERS.
294
339
  | `home-hosted down` | stop it cleanly — supervised processes included, persistent entries left running |
295
340
  | `home-hosted restart` | `down`, then `up` |
296
341
  | `home-hosted status` | pid, URL, health, uptime, state and log paths (`--json` for scripts) |
297
- | `home-hosted start <id>` | start one server — and anything it `dependsOn` |
298
- | `home-hosted stop <id>` | stop one server, nothing else |
342
+ | `home-hosted start <id>` | start one server in the default workspace — and anything it `dependsOn` (`--workspace <id>`) |
343
+ | `home-hosted stop <id>` | stop one server, nothing else (`--workspace <id>`) |
299
344
  | `home-hosted set-password` | set the panel password without opening a browser |
300
345
  | `home-hosted set-token` | set the API token scripts and agents use (`--generate`, `--clear`) |
301
- | `home-hosted migrate` | bring `servers.config.json` up to this release's schema (`--dry-run`, `--yes`) |
346
+ | `home-hosted migrate` | relocate a pre-workspaces state directory and stamp every config for this release (`--dry-run`, `--yes`) |
302
347
  | `home-hosted init` | scaffold a project that keeps `state/` and its data in the repo |
303
348
  | `home-hosted ui-switch` | install a UI from a release asset, a zip file or a URL (interactive) |
304
349
  | `home-hosted ui-update` | bring an installed UI up to date, or pick a release (`--old`, `--check`) |
@@ -314,7 +359,7 @@ up, restart -c/--config -p/--port --host --open --no-autostart --foregroun
314
359
 
315
360
  down (no flags)
316
361
  status --json
317
- start, stop <id> (the server's id in servers.config.json; both need the panel up)
362
+ start, stop <id> [-w/--workspace <id>] (both need the panel up)
318
363
  init --dir --name --pm --no-install -y/--yes
319
364
  set-password --clear
320
365
  set-token --generate --clear
@@ -328,14 +373,34 @@ env vars HHOSTED_PASSWORD, HHOSTED_MIGRATE=allow, HHOSTED_TOKEN, GITHUB
328
373
  ```
329
374
 
330
375
  `up` and `restart` share the same flags: `restart` is `down`, then `up` with exactly what it was given.
376
+ `-c/--config` is the **default workspace's** servers file (`<state>/.hh/default/servers.config.json`),
377
+ for a launcher that pins one; a workspace picked in the UI keeps its own. `start`/`stop` omit
378
+ `--workspace` to act in the panel's default workspace.
331
379
 
332
380
  </details>
333
381
 
334
382
  <details>
335
383
  <summary><b>🧭 Upgrading, and why the panel sometimes refuses to start</b></summary>
336
384
 
337
- `servers.config.json` records what wrote it: `meta.writtenBy` (the release) and `meta.schema` (the
338
- config shape). That buys two guarantees:
385
+ > **Breaking in 0.7 — workspaces.** State moved under `$HHOSTED_HOME/.hh`: global files at its top
386
+ > level, one directory per workspace. Settings split into panel-wide **Global settings** and
387
+ > per-workspace **Workspace settings**, backups became two-level, and `--config` now points at the
388
+ > default workspace's servers file. The old `servers.config.json` becomes
389
+ > `.hh/default/servers.config.json`.
390
+
391
+ Relocation is automatic: the CLI relocates a pre-`.hh` `$HHOSTED_HOME` before any command runs, so an
392
+ existing instance keeps working after the upgrade. `home-hosted migrate` reports what it moved and
393
+ stamps every file for this release:
394
+
395
+ ```bash
396
+ home-hosted migrate --dry-run # print the steps, write nothing
397
+ home-hosted migrate # ask, then write — keeps a .bak beside each rewritten file
398
+ ```
399
+
400
+ Unattended, consent comes from `--yes` or `HHOSTED_MIGRATE=allow`; without it the command stops.
401
+
402
+ Each config records what wrote it: `meta.writtenBy` (the release) and `meta.schema` (the shape). That
403
+ buys two guarantees:
339
404
 
340
405
  - **A newer home-hosted always reads an older config** — every existing key keeps its meaning.
341
406
  - **Keys a newer release added are ignored, not fatal.** The panel names them in its log, leaves them
@@ -346,15 +411,6 @@ or a config whose schema is newer than the running release stops `up` with the e
346
411
  than starting with defaults that quietly differ from your file. Fix the file, or install the release
347
412
  that wrote it.
348
413
 
349
- When a release changes the shape itself, `home-hosted migrate` applies the steps it ships:
350
-
351
- ```bash
352
- home-hosted migrate --dry-run # print the steps, write nothing
353
- home-hosted migrate # ask, then write — keeps servers.config.json.bak
354
- ```
355
-
356
- Unattended, consent comes from `--yes` or `HHOSTED_MIGRATE=allow`; without it the command stops.
357
-
358
414
  </details>
359
415
 
360
416
  ---
@@ -375,8 +431,10 @@ Everything binds `127.0.0.1` until you say otherwise.
375
431
  never taken from the message, and anything the panel supervises is refused, not killed. A server
376
432
  that [restarts itself](./docs/SERVERS.md#when-a-program-restarts-itself) can be followed, or replaced
377
433
  with a supervised copy.
378
- - **Secrets never enter the config**: the password hash, the API token hash and the Telegram bot
379
- token live in `$HHOSTED_HOME/.control-secrets.json` with mode `0600`; the TLS pair in `.tls/`.
434
+ - **Secrets never enter the config**: the password hash and API token hash live in the global
435
+ `.hh/.control-secrets.json`, each workspace's Telegram bot token and DDNS credentials in its own
436
+ `.hh/<workspace>/.secrets.json`, all mode `0600`; the TLS pair sits in `.hh/.tls/`. DDNS credentials
437
+ are sealed with AES-256-GCM under `HHOSTED_DDNS_SECRET` (default `hh` — set your own).
380
438
  - **Behind a proxy** turn on `trustProxy` and let `cookieSecure: auto` add `Secure` on https, or
381
439
  upload a PEM pair and let home-hosted terminate TLS itself.
382
440
 
@@ -393,19 +451,23 @@ stays in the secrets file. **Two minutes of setup: [NOTIFICATIONS.md](./docs/NOT
393
451
 
394
452
  ## 🌐 Dynamic DNS
395
453
 
396
- **Settings → Dynamic DNS** keeps a list of hostnames pointed at this machine's public IP — add an
397
- account, paste its credentials, add hostnames. The panel checks the address on an interval and calls a
398
- provider only when it actually changed, and the last confirmed address survives a restart. Provider
399
- tokens stay in the secrets file. **Providers and the config shape: [DDNS.md](./docs/DDNS.md).**
454
+ **Workspace Settings → Dynamic DNS** keeps a list of hostnames pointed at this machine's public IP —
455
+ add an account, paste its credentials, add hostnames. The panel checks the address on an interval and
456
+ calls a provider only when it actually changed, and the last confirmed address survives a restart.
457
+ Provider tokens stay in the workspace's secrets file. **Providers and the config shape:
458
+ [DDNS.md](./docs/DDNS.md).**
400
459
 
401
460
  ---
402
461
 
403
462
  ## 💾 Backups
404
463
 
405
- **Settings → Backups** archives the config, secrets, TLS pair and every data directory your entries
406
- declare — an ordinary `.zip`, or WinZip AES-256 with a password, restored per path. Known build output
407
- and dependency directories (`node_modules`, `dist`, `.next`, framework caches) are skipped per entry;
408
- `backupIgnoreGenerated: false` captures them anyway.
464
+ **Global settings → Backups** archives the panel's own settings, its secrets and the TLS pair — plus
465
+ every workspace you pick. It is two-level: the top of *Create backup…* lists the global items and
466
+ each workspace; opening a workspace lists its **settings**, **servers**, **secrets** and every data
467
+ directory its entries declare, and you can drop any single piece. *Restore…* shows the same list from
468
+ an archive before anything is written. Either way it is an ordinary `.zip`, or WinZip AES-256 with a
469
+ password, restored per path. Known build output and dependency directories (`node_modules`, `dist`,
470
+ `.next`, framework caches) are skipped per entry; `backupIgnoreGenerated: false` captures them anyway.
409
471
 
410
472
  <details>
411
473
  <summary><b>🚚 One archive is a whole setup</b></summary>
@@ -414,9 +476,11 @@ Start a **blank** home-hosted anywhere — another machine, another user, a fres
414
476
  archive and restore. Definitions come back, data lands where *this* machine's config says, and
415
477
  `autostart` entries come up immediately.
416
478
 
417
- It works because an archive carries its own `servers.config.json` and paths are matched by the
418
- **declaration** (`omniroute:DATA_DIR`), not by an absolute path from the source machine. A restore never
419
- writes where no config declares.
479
+ It works because an archive carries each workspace's `settings.json` and `servers.config.json`, and
480
+ paths are matched by the **declaration** (`omniroute:DATA_DIR`), not by an absolute path from the
481
+ source machine. A restore never writes where no config declares. A declaration using `{projectDir}`,
482
+ `{dataRoot}` or `{home}` follows the restoring panel (`{projectDir}` is `HHOSTED_PROJECT` or the
483
+ panel's cwd, not `HHOSTED_HOME`); a literal absolute path is restored to that same path.
420
484
 
421
485
  </details>
422
486
 
@@ -424,9 +488,9 @@ writes where no config declares.
424
488
 
425
489
  ## 🎨 Bring your own UI (BYOU)
426
490
 
427
- The panel is a static site: `$HHOSTED_HOME/.ui` overrides the packaged one, and **Settings →
428
- Interface** takes a zip. No restart, no fork — and `home-hosted ui-revert` brings back the stock
429
- panel if yours breaks.
491
+ The panel is a static site: `$HHOSTED_HOME/.hh/.ui` overrides the packaged one, and **Global
492
+ settings → Interface** takes a zip. No restart, no fork — and `home-hosted ui-revert` brings back the
493
+ stock panel if yours breaks.
430
494
 
431
495
  Two ship in this repo: `uis/stock`, and `uis/noc-console` for TUI and shortcuts wizards; a release
432
496
  attaches both as `home-hosted-ui-<name>.zip`. Yours can be anything that compiles to static files —
@@ -486,16 +550,24 @@ opened.
486
550
  <details>
487
551
  <summary><b>Where is my state?</b></summary>
488
552
 
489
- `$HHOSTED_HOME`, default `~/.home-hosted`:
553
+ `$HHOSTED_HOME/.hh`, default `~/.home-hosted/.hh`. Global files at the top level, one directory per
554
+ workspace:
490
555
 
491
556
  ```text
492
- servers.config.json your servers, plus meta: which release and schema wrote it
493
- servers.config.schema.json regenerated on every start, for editor autocomplete
494
- .control-secrets.json password hash + API token hash + Telegram token (mode 0600)
495
- .logs/ rotated per-server logs + history
557
+ settings.json panel-wide: listener, auth, TLS policy, host vitals, backups
558
+ workspaces.json the registry: ids and labels, in selector order
559
+ .control-secrets.json password hash + API token hash (mode 0600)
496
560
  .tls/ an uploaded PEM pair
561
+ .ui/ an installed custom UI
497
562
  .backups/ zip archives
563
+ .logs/ the panel's own console log
498
564
  run.json the running panel (pid, url, token, mode 0600)
565
+
566
+ <workspace>/settings.json server defaults, log retention, notifications, DDNS
567
+ <workspace>/servers.config.json your servers, plus meta: which release and schema wrote it
568
+ <workspace>/.secrets.json Telegram token + DDNS credentials (mode 0600)
569
+ <workspace>/.logs/ rotated per-server logs + history
570
+ <workspace>/.state/ persistent entries' nanny state
499
571
  ```
500
572
 
501
573
  `home-hosted status` prints the paths.