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 +103 -15
- package/NOTIFICATIONS.md +69 -0
- package/README.md +208 -179
- package/SERVERS.md +101 -0
- package/UI_CREATION.md +104 -77
- package/dist/cli.js +1197 -142
- package/dist/cli.js.map +1 -1
- package/package.json +12 -4
- 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-CmkrtNU0.css +0 -2
- package/uis/stock/dist/assets/index-DqcdQi-W.js +0 -47
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
|
-
|
|
5
|
-
|
|
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
|
|
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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
|
|
21
|
-
|
|
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
|
-
|
|
25
|
-
|
|
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
|
|
30
|
-
|
|
31
|
-
2. **Same origin, relative paths.** Call `/api
|
|
32
|
-
|
|
33
|
-
3. **`index.html` at the root** (or
|
|
34
|
-
|
|
35
|
-
4. **Keep the login flow.**
|
|
36
|
-
|
|
37
|
-
5. **Self-host your assets.** An offline home server should not need a CDN
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
|
|
44
|
-
|
|
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
|
|
51
|
-
| **Typed RPC** |
|
|
52
|
-
| **Generated types** | `
|
|
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
|
-
|
|
55
|
-
|
|
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
|
-
###
|
|
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
|
|
62
|
-
| `GET /api/events` | **the live feed
|
|
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
|
-
| `
|
|
66
|
-
| `GET /api/
|
|
67
|
-
| `GET
|
|
68
|
-
| `
|
|
69
|
-
| `POST` / `DELETE /api/
|
|
70
|
-
| `
|
|
71
|
-
| `GET /api/
|
|
72
|
-
|
|
73
|
-
`
|
|
74
|
-
|
|
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
|
-
|
|
86
|
-
|
|
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,
|
|
94
|
-
| `state` | `{ ts, state }` — anything changed: a status, a resource sample,
|
|
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
|
-
|
|
100
|
-
|
|
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
|
-
##
|
|
131
|
+
## Building one in this repo
|
|
106
132
|
|
|
107
|
-
|
|
108
|
-
`vite.config.ts` calling `createUiConfig` from
|
|
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
|
|
112
|
-
pnpm run build:uis # every UI, zipped;
|
|
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
|
|
116
|
-
|
|
117
|
-
|
|
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 &&
|
|
125
|
-
|
|
152
|
+
cd my-panel && pnpm install
|
|
153
|
+
pnpm run build # → dist/
|
|
126
154
|
|
|
127
|
-
# 2.
|
|
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,
|
|
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
|
-
|
|
135
|
-
Vite dev server on 3998 with `/api` proxied
|
|
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
|
|
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
|
|
146
|
-
- [ ] works
|
|
147
|
-
- [ ] `ui.json` with a name and version, so
|
|
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
|