home-hosted 0.7.17 → 0.7.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/AGENTS.md CHANGED
@@ -2,37 +2,59 @@
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 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`, `docs/REVERSE_PROXY.md`; UI authors:
7
- `docs/UI_CREATION.md`.
5
+ `servers.config.json` and serves a UI. User docs: `README.md` and `docs/` (SERVERS, NOTIFICATIONS,
6
+ DDNS, REVERSE_PROXY, UI_CREATION).
8
7
 
9
8
  State lives only in `$HHOSTED_HOME/.hh` (default `~/.home-hosted/.hh`). Global files sit at its top
10
9
  level: `settings.json` (listener, auth, TLS policy, host vitals, backups, reverse proxy),
11
10
  `workspaces.json`, `.control-secrets.json` (0600: password hash, API token hash), `.tls/`,
12
- `.backups/`, `.ui/`, `.logs/` (the panel console), `.proxy/` (the reverse-proxy engine, its generated
13
- configuration and its nanny state), and `run.json` — the live daemon's pid/url/token, 0600. Every workspace owns
14
- `.hh/<id>/`: `settings.json` (server defaults, log retention, notifications, DDNS), `servers.config.json`,
15
- `.secrets.json` (0600: Telegram bot token, plus DDNS credentials sealed with AES-256-GCM under
16
- `HHOSTED_DDNS_SECRET` — only DDNS is sealed, because it is the one thing replayed to a third party),
17
- `.logs/`, and `.state/` (a persistent entry's nanny state plus its 0600 spawn
18
- spec until the nanny reads it, plus `ddns.json`). A pre-`.hh` instance is relocated automatically by
19
- `ensureLayout()` (`src/config/layout.ts`). The package ships **no servers**: never commit a config, a
20
- seed entry, or a path that names one.
11
+ `.backups/`, `.ui/`, `.logs/` (the panel console), `.proxy/` (the engine, its generated config and
12
+ nanny state), and `run.json` (the live daemon's pid/url/token, 0600). Every workspace owns
13
+ `.hh/<id>/`: `settings.json` (server defaults, log retention, notifications, DDNS),
14
+ `servers.config.json`, `.secrets.json` (0600: Telegram token, plus DDNS credentials sealed with
15
+ AES-256-GCM under `HHOSTED_DDNS_SECRET` — only DDNS is sealed, because it is the one thing replayed
16
+ to a third party), `.logs/` (per server, `<id>.log` plus `<id>.log.1` when it rotates), and
17
+ `.state/` (nanny state, its 0600 spawn spec until read, `ddns.json`). A pre-`.hh` instance is
18
+ relocated automatically by `ensureLayout()`. **The package ships no servers**: never commit a config,
19
+ a seed entry, or a path that names one.
20
+
21
+ ## Docs
22
+
23
+ Three tiers, so a reader loads only what the task needs: **this file** (orientation and the rules —
24
+ read every session), **`.agentDocs/`** (depth — read on demand), and **`docs/`** (for a person using the
25
+ package, not for an agent). The section map for every repo in this workspace is
26
+ [`.agentDocs/AGENTS-SKELETON.md`](.agentDocs/AGENTS-SKELETON.md).
27
+
28
+ | file | what it covers |
29
+ | --- | --- |
30
+ | [`.agentDocs/AGENTS-SKELETON.md`](.agentDocs/AGENTS-SKELETON.md) | the canonical section names and order, and the required content |
31
+ | [`.agentDocs/ARCHITECTURE.md`](.agentDocs/ARCHITECTURE.md) | what each module owns and why; UI versioning |
32
+ | [`.agentDocs/GOTCHAS.md`](.agentDocs/GOTCHAS.md) | the traps this codebase already paid for, with causes |
33
+ | [`.agentDocs/COMPATIBILITY.md`](.agentDocs/COMPATIBILITY.md) | config and UI compatibility, migrations |
34
+ | [`docs/SERVERS.md`](docs/SERVERS.md) | entries, port conflicts, persistent entries (user-facing) |
35
+ | [`docs/UI_CREATION.md`](docs/UI_CREATION.md) | the contract for a new UI |
21
36
 
22
37
  ## Commands
23
38
 
39
+ `pnpm-workspace.yaml` is load-bearing: dropping an `allowBuilds` entry makes `pnpm install` **exit 1**
40
+ (`Ignored build scripts: esbuild@0.24.2` — measured, not inferred). Keep the file's own comments
41
+ explaining why each entry is there.
42
+
24
43
  ```sh
25
44
  pnpm run up|down|restart|status # detached; `down` asks /_hh/shutdown, signals are the fallback
26
45
  # `restart <id>` restarts one server via /_hh, panel stays up
27
46
  pnpm run start # up --foreground (systemd, docker, a foreground shell)
28
47
  pnpm exec tsx src/cli.ts logs # the panel's own console output (--lines, --follow, --json)
29
- pnpm dev # tsx-watch panel :6000 + the stock UI's Vite :6001 (proxies /api), state in .dev-state/
48
+ pnpm dev # tsx-watch panel :6000 + the stock UI's Vite :6001, state in .dev-state/
30
49
  pnpm dev --ui noc-console # the same, for another UI; --port/--ui-port override
31
50
  pnpm run build # dist/cli.js + the stock UI (uis/stock/dist)
32
51
  pnpm run build:uis # every UI under uis/, zipped into uis/dist/ (release assets)
33
- pnpm run quickcheck # eslint + tsc + vue-tsc for every UI under uis/
52
+ pnpm run quickcheck # eslint + tsc + vue-tsc + the doc links — NOT the tests
34
53
  pnpm exec vitest run # `pnpm test` is vitest in watch mode
35
- pnpm run check # quickcheck + vitest run --coverage
54
+ pnpm run check # ← THE GATE: quickcheck + vitest run --coverage. Run this before
55
+ # every push — CI's `test.yml` is `pnpm test --coverage`, so an
56
+ # area floor failing there fails the push while `quickcheck` stays
57
+ # green. That divergence cost a day of red CI (2026-10-06).
36
58
  pnpm run set-password # non-interactive through HHOSTED_PASSWORD
37
59
  pnpm run set-token # --generate prints a new API token once
38
60
  pnpm run migrate # bring the config up to this release's schema
@@ -43,383 +65,176 @@ pnpm run media # regenerate docs/media (mockups, both served
43
65
  The published bin is `home-hosted`, with an `hh` alias: both names run the same CLI.
44
66
 
45
67
  **Dev servers and test instances stay in the 6xxx range** — panel 6000, UI 6001, test-spawned panels
46
- 6100+ (`pnpm dev` takes `--ui`/`--port`/`--ui-port`, or `HHOSTED_DEV_PANEL_PORT` /
47
- `HHOSTED_DEV_UI_PORT`). Never assume 3999 is free: an installed panel or another dev instance may hold
48
- it, and the CLI's port preflight runs *before* its config guard, so a busy default port turns config
49
- tests red for the wrong reason.
50
-
51
- Releases are dispatched from `.github/workflows/release.yml` with a version (and a `dry-run` switch
52
- that stops before pushing). It verifies the version, lints/types/tests, builds the CLI plus the stock
53
- UI and every UI zip, lets changelogen write the changelog, bump `package.json`, commit and tag
54
- `v<version>`, creates the GitHub release with the UI bundles attached, and publishes to npm through
55
- trusted publishing (OIDC, no token). npm only offers a trusted publisher for a package that already
56
- exists, so the first release has to be published by hand.
57
-
58
- Four workflows, and the split is deliberate:
59
-
60
- | workflow | runs on | what it proves |
61
- | --- | --- | --- |
62
- | `quickcheck.yml` / `test.yml` | every push and PR | lint, types, the whole suite with coverage — Linux only |
63
- | `cross-platform.yml` | the release gate, or by hand | the same suite on macOS and Windows |
64
- | `release.yml` | dispatched with a version | the platform gate, then tag, publish, release assets |
65
-
66
- The platform gate is not a per-push job on purpose: it is the expensive half, and it is what makes a
67
- platform-specific break fail before shipping rather than on someone's machine. It also runs on a
68
- `dry-run`, because that is exactly what a rehearsal needs to exercise. Every job carries a
69
- `timeout-minutes`, so a wedged runner is reported instead of sitting out GitHub's 6-hour default.
70
-
71
- ### Which version to dispatch
72
-
73
- Below 1.0 the **minor is the breaking channel**: a fix or a non-breaking feature is a **patch**
74
- (`0.6.2` → `0.6.3`), while a minor (`0.6.3` → `0.7.0`) means someone has to read the release notes and
75
- act. A `feat:` commit does not decide this — ask what the user has to do about it.
76
-
77
- Never edit `package.json` by hand; changelogen bumps, commits and tags inside the workflow:
78
-
79
- ```sh
80
- gh workflow run release.yml -f version=0.6.3 # -f dry-run=true to rehearse
81
- ```
82
-
83
- `scripts/check-release-version.mjs` refuses a patch while a breaking commit is pending, and warns on
84
- a minor without one.
85
-
86
- ## Architecture (and why)
87
-
88
- - `src/cli.ts` — the CLI's thin root. Two things happen before [citty](https://github.com/unjs/citty)
89
- is asked anything: `--home`/`--project` are peeled off and applied (`src/cli/args.ts`), and the
90
- curated dispatch — `help`/`version`, `unknown command`, and the `-p 4000` shorthand for `up` — is
91
- decided, because `#src/helpers/paths.ts` resolves at import time. Its static imports stay node
92
- builtins, citty and the two path-free local modules (`src/cli/args.ts`, `src/helpers/runtime.ts`);
93
- every command is a lazy
94
- `() => import('#src/cli/<command>')` in citty's `subCommands`, so a command module *may* use static
95
- `#src` imports (that is the whole point of the pre-pass). The hidden `__nanny` is dispatched by an
96
- early branch, not `subCommands`, so it stays out of the curated help and the unknown-command
97
- message — the panel spawns it, nobody types it. citty's `runMain` is deliberately not
98
- used: it prints its own usage and `console.error`s before `process.exit(1)`, replacing `fail()`'s
99
- one error shape; the root calls `runCommand` and catches. `src/cli/<command>.ts` is one command per
100
- file — citty owns dispatch and argument parsing, with `--no-autostart`/`--no-install` declared as
101
- boolean negations rather than literal arg names. The curated `--help` prose stays in `src/cli.ts`
102
- because citty cannot generate it. `up` re-spawns itself detached as `up --foreground …`, with the
103
- argv built from the *parsed* flags (`buildDaemonArgv`) plus this file's own URL as the entry, so it
104
- works under tsx and from `dist/cli.js` alike. `src/cli/io.ts` is the one place the readline prompts
105
- and colours live; commands take `prompt`/`style` through that seam (`src/cli/ui-switch.ts` keeps
106
- `UiSwitchIo`).
107
- - `src/index.ts` — `runControlPlane()`: wiring, startup guards (exposure, free port, live run.json),
108
- `run.json`, signals. Wiring belongs here and nowhere else.
109
- - `src/app.ts` — the Hono root, chained routes only. `/_hh` is mounted *before* the `/api/*` auth
110
- guard on purpose (a local `down` uses run.json's token, not a session). `AppType` is the route type
111
- that `hc<AppType>` clients and the OpenAPI document derive from.
112
- - `src/api/**` — one file per URL group (`$.routes.ts` = several routes), mirroring the path.
113
- - `src/shared/contracts.ts` — every ArkType schema (config, API and SSE DTOs), shared with the UIs;
114
- the OpenAPI spec is generated from it, never hand-written.
115
- - `src/config/` — split by scope. `settings.ts` (`GlobalSettingsStore`: `.hh/settings.json` — listener,
116
- auth, TLS policy, host vitals, backups) and `store.ts` (`WorkspaceStore`: a workspace's
117
- `settings.json` + `servers.config.json`, validate/merge/atomic commit, reporting
118
- `configError`/`configWarnings` instead of throwing on a bad file). `workspaces.ts` is the registry
119
- (`workspaces.json`), `layout.ts` the one-time relocation of a pre-workspace `$HHOSTED_HOME`.
120
- `schema.ts` holds the three on-disk shapes plus the `meta` stamp, `parse.ts` the tolerant readers
121
- (`parseGlobalSettings`/`parseWorkspaceSettings`/`parseServersFile`: unknown keys are reported and
122
- kept, everything else blocking), `patch.ts` the merge helpers, `migrations.ts` the schema constant
123
- and ordered step registry, and `secrets.ts`/`seed.ts` the two secret scopes and the seeds.
124
- - `src/providers/` — stateless leaves: `process` (spawn, `terminate`, `terminatePid` for a process we
125
- adopted), `port` (probe, holder lookup, `terminatePids`), `proc` (the sampler, plus
126
- `processCarriesServerId` for the environment marker and `processTreePids` for tree ownership),
127
- `identity` (which port holder is this entry's own successor: marker first, then resolved image +
128
- argv), `nanny` (a persistent entry's spec/state files and the liveness rules for one),
129
- `log-tail` (an offset reader that survives rotation and truncation), `ddns/` (one `DdnsProvider` per
130
- registrar over one HTTP call each, plus `ip` and `zone` helpers), `proxy/` (the reverse-proxy
131
- engines this build can drive: where the binary comes from and how to start it — the route model
132
- stays the panel's), health-check, host, telegram, archive.
133
- - `src/services/` — stateful orchestration: `panel` (one `WorkspaceRuntime` per workspace, workspace
134
- CRUD, and the single aggregate state frame — every per-workspace service hangs off it), supervisor,
135
- control-server, config-watch, state, auth + exposure, dependencies, history, log-buffer/log-files,
136
- notifications, host-monitor, ddns, proxy, backups, tls, ui, plus `init` (the scaffold behind
137
- `home-hosted init`: a manifest, a `.gitignore`, and the prompts stay in the CLI), `nanny` (the
138
- process a persistent entry runs under) and `log-relay` (the tailer that feeds its lines to the
139
- supervisor). It names no server — the scaffold must stay as neutral as the supervisor.
140
- - `src/middleware/auth.ts` — the `/api/*` guard, and `requestIdentity()`, the one place a request's
141
- credentials are read: the `hh_session` cookie or `Authorization: Bearer <api token>`. A token is
142
- a first-class credential (same authority as a signed-in browser) and is verified from the secrets
143
- file on every request, so `set-token` needs no restart.
144
- - `src/helpers/` — paths (`dataRoot` vs `projectDir`), daemon (run.json + a loopback probe that
145
- bypasses `fetch`, so TLS with a self-signed pair still answers), error, validator, atomic,
146
- template, env-file, openapi, factory.
147
- - `uis/<name>/` — each UI is a Vite app (Vue 3 + Tailwind v4) built through `uis/vite.shared.ts`;
148
- `stock` is the one shipped inside the package. Aliases: `@` → that UI's `src`, `@shared` →
149
- `src/shared`, `@server` → `src` (**types only** — never import runtime server code into a UI).
150
- Its `public/ui.json` is the UI's identity: `name` and `version` (what Global Settings → Interface shows),
151
- `repo`/`tag`/`asset` (which release carries it, for `ui-update`) and `unix` (when it was built).
152
- `UiService` carries those fields into the installed `$HHOSTED_HOME/.ui/ui.json` unchanged and adds
153
- `uploadedAt`/`files` of its own.
154
- - **Bumping a UI ships a new asset, so its `ui.json` is part of the change — and it is bumped once,
155
- when the commit is made, not on every edit.** A commit that alters what a UI *ships*
156
- (`uis/<name>/src`, `public`, its build inputs — not `test/`, which is not in the asset) raises that
157
- `ui.json`'s `version`: patch for a fix, minor for a feature, and **major only for a rewrite or a
158
- restyle**. The number is what tells a person how big the change is — `ui-update` prints it and holds
159
- it against the release it is offering. `unix` is that commit's own epoch, never a leftover from
160
- whenever the file was edited: `scripts/stamp-uis.mjs` runs from the pre-commit hook, stamps it, and
161
- refuses a commit that changed a shipped UI without raising its version. `tag` is what pairs an
162
- official UI with its panel, and it is **stamped by `build-uis.mjs`**, not trusted from the source
163
- file: a UI zip is built before its release is cut, so the committed value is always a release behind
164
- the asset it ends up inside. The release workflow passes `HHOSTED_UI_TAG`; anywhere else the build
165
- stamps `v<package.json version>`. Do not hand-edit `tag` to chase a release — a stale one makes
166
- `ui-update` re-install the same UI on every boot.
167
- - `bin/home-hosted.mjs` — the published bin: `dist/cli.js`, or `src/cli.ts` through tsx when the
168
- build is missing (a linked checkout).
169
- - `scripts/` — `build-uis.mjs` (build one UI, optionally zip it), `typecheck-uis.mjs`,
170
- `capture-media.mjs` (the README's media), `check-release-version.mjs` and `release-notes.mjs`
171
- (used by the release workflow), `dev.mjs` (`pnpm dev`).
172
- - `docs/` — the topic docs (`SERVERS.md`, `NOTIFICATIONS.md`, `DDNS.md`, `REVERSE_PROXY.md`,
173
- `UI_CREATION.md`),
174
- `mockups/*.html` (hand-written UI examples in four directions) and `media/*`: their screenshots plus
175
- the README's `tour.gif`, all regenerated by `pnpm run media`. Only the five `.md` files are
176
- published; the media is a build artifact.
68
+ 6100+ (`--port`/`--ui-port`, or `HHOSTED_DEV_PANEL_PORT`/`HHOSTED_DEV_UI_PORT`). Never assume 3999 is
69
+ free: an installed panel or another dev instance may hold it, and the CLI's port preflight runs
70
+ *before* its config guard, so a busy default port turns config tests red for the wrong reason.
177
71
 
178
72
  ## Conventions
179
73
 
180
- - `#src/*` imports inside `src/`; UIs use `@shared/*`.
181
- - A single on/off setting is a `ToggleSwitch`; `CheckField` is only for picking items out of a set
182
- (the restore plan). A checkbox in a `FieldGroup` grid reads as misaligned next to the inputs.
183
- - A create body carries only what differs from what the entry would inherit — the schema's defaults
184
- with `Workspace Settings → Server defaults` on top (each UI's own `inheritBaseline` plus the shared
185
- `diffFields` in `src/shared/patch-diff.ts`). A value written into `servers.config.json` stops
186
- following those defaults, so anything the person did not decide stays out; the editor applies the
187
- same rule to an edit.
188
- - ArkType at every runtime boundary: routes use `validate('json'|'query'|'param', schema)` then
189
- `c.req.valid(...)`; ad-hoc payloads use `parseOrThrow`. Schemas reject undeclared keys.
190
- - Every failure is a `DetailedError` (`@namesmt/utils`), mapped by `src/helpers/error.ts` into one
74
+ - **Server-agnostic core; UIs are clients.** `#src/*` inside `src/`; UIs use `@shared/*` (and
75
+ `@server` for types only).
76
+ - **ArkType at every runtime boundary.** Routes use `validate('json'|'query'|'param', schema)` then
77
+ `c.req.valid(...)`; ad-hoc payloads use `parseOrThrow`. **A request schema rejects undeclared keys**
78
+ (`.onUndeclaredKey('reject')`); **a response/view schema does not** — a newer panel may add a field
79
+ while an older client still reads it, so rejecting there would blank that client. 46 of the 94
80
+ schemas in `contracts.ts` are responses and omit it on purpose.
81
+ - **Every failure is a `DetailedError`** (`@namesmt/utils`), mapped by `src/helpers/error.ts` into one
191
82
  envelope `{ message, code, detail }`. Never hand-roll `c.json({ error })`.
192
- - Document routes with `describeRoute` + `jsonBody(schema)`; `jsonBody` needs a real schema.
193
- - Patch schemas carry no defaults; nested groups (`restart`/`health`/`stop`/`auth`/`tls`/`telegram`/
194
- `http`) merge key-by-key, and an explicit `null` clears a key. `ddns` is the exception: its
195
- `accounts`/`domains` lists are **replaced** (a merge cannot express removing a hostname), and only
196
- `ipv4`/`ipv6` merge.
197
- - Two-sided bounds read inclusively (`'1 <= number.integer <= 512'`). `test/shared/contracts.test.ts`
83
+ - **Document routes with `describeRoute` + `jsonBody(schema)`**; `jsonBody` needs a real schema.
84
+ - **Patch schemas carry no defaults**; nested groups merge key-by-key and an explicit `null` clears a
85
+ key. `ddns`'s `accounts`/`domains` are **replaced** — a merge cannot express removing a hostname.
86
+ - **Two-sided bounds read inclusively** (`'1 <= number.integer <= 512'`). `test/shared/contracts.test.ts`
198
87
  pins every boundary and the patch/schema parity — update it with any schema change.
199
- - A new field in a **response** DTO is optional (`'x?'`) and its clients read it defensively. An
200
- upgrade writes a new `uis/stock/dist` to disk while the old panel process is still serving, so a
201
- new UI meets an older payload for a while — a required field there rejects the whole frame and
202
- blanks the app. Request bodies and config keep their strict, defaulted shape.
203
- - Conventional commits; ESLint via `@antfu/eslint-config`; sparse comments.
204
- - UI tests live in `uis/<name>/test/`: pure modules on node, and a component that is worth
205
- guarding mounts under `// @vitest-environment happy-dom` (see `number-field.test.ts`). Reach for
206
- that rather than trusting a component to be thin: `NumberField`'s setter assumed the string a text
207
- input reports, but Vue casts `<input type="number">` to a *number* first, so `raw.trim()` threw and
208
- every value typed into a numeric field was silently discarded — no pure-module test could see it.
209
- The suite resolves `@/…` from the UI that asks, so each UI's own tests can import its own
210
- components; a single pinned target made every UI but that one untestable, and resolved a
211
- `noc-console` test's `@/lib/proxy` to *stock's* differing copy without a murmur.
212
- - A destructive action that is one click away confirms in a **popover** (`KillPortButton.vue`),
213
- never by arming the same button for a second press: an impatient double click on an arming
214
- button fires it. Keep the safe choice first in the popover's tab order.
215
- - **Docs ship with the change.** A user-visible change updates the docs that describe it
216
- (`README.md`, `docs/*.md`) in the same commit — do not forget them; a stale doc is a bug like
217
- stale code.
218
- - **UIs move together.** `uis/stock` is not the only client: a change to it — or to a shared
219
- contract it reads — lands in every other UI under `uis/`, and each altered UI bumps its
220
- `ui.json` per the rule above.
221
-
222
- ## Compatibility
223
-
224
- Two surfaces outlive the release that wrote them: **configs absolutely, UIs within reason.** Breaking
225
- either is a last resort, and never an accidental one.
226
-
227
- - **A config written by an older release has to load in a newer one.** That direction is the priority:
228
- add fields with defaults, never repurpose or remove one, and treat every existing key as permanent.
229
- - **Both directions matter, and both are now handled.** An unrecognized key is read, reported and
230
- left on disk instead of failing anything: it is the normal way a config from a newer release looks
231
- here. Anything that is not merely unrecognized — a wrong value, a duplicate id, an unreadable file —
232
- stops the panel instead of being papered over with defaults.
233
- - **The UI moves in minor steps.** Routes, response fields and SSE frames are additive: keep the old
234
- one and add the new one. A new response field is optional (`'x?'`) and read defensively, because an
235
- upgrade writes a new `uis/stock/dist` while the old panel process keeps serving — and a
236
- user-uploaded UI may be older than the panel it talks to.
237
- - **Breaking is allowed; silent is not.** When nothing compatible can be done, say so in the final
238
- answer *and* in the commit message with a `BREAKING CHANGE:` footer, naming the exact migration the
239
- user must run.
240
- - **A config records what wrote it.** Every write stamps a top-level `meta`
241
- (`{ writtenBy, schema }`): the release that wrote the file and the config shape it wrote. An
242
- unstamped file reads as the current schema, so nothing that existed before needed changing.
243
- - **Nobody runs a config this release cannot read.** `up` refuses to start — exit 1, the exact
244
- problem printed — when the file is unparseable, has an invalid value or a duplicate id, carries a
245
- newer `meta.schema`, or has a registered migration pending. A panel that is *already* running keeps
246
- the config it has and only reports the error, so a bad edit never disturbs supervision.
247
- - **Unknown keys are dropped from the resolved config, kept on disk, and listed in a startup
248
- warning.** Dropping one is the normal way a newer config looks here, so it must never fail the
249
- group it sits in (that used to reset `control` — port, bind, auth policy — to schema defaults).
250
- A *write* to a group validates the same tolerant way (`parseTolerant` via the store), so a key this
251
- release does not know never blocks a save — removing a key from a schema must not brick the page
252
- that writes it.
253
- - **Migrations ship inside the package** (`src/config/migrations.ts`), are ordered, idempotent and
254
- described in one line each. `home-hosted migrate` prints the plan, keeps a `.bak` beside each file it
255
- rewrites, refuses to write a config it cannot read, and needs consent: `--yes`, `HHOSTED_MIGRATE=allow`,
256
- or a person at a terminal. The layout relocation is not a schema change: it runs automatically from
257
- the CLI pre-pass (`ensureLayout()`), so an upgrade never starts against a half-moved directory. Starting a fetch of migration
258
- code from GitHub was considered and rejected: the panel supervises processes, so remote code is an
259
- RCE surface, and a migration would age against a newer store API anyway.
88
+ - **A new response *field* is optional (`'x?'`)** and read defensively: an upgrade writes a new UI
89
+ while an old panel keeps serving, so a required field blanks the app.
90
+ - **A single on/off setting is a `ToggleSwitch`**; `CheckField` is only for picking items out of a set.
91
+ - **A destructive action one click away confirms in a popover**, never by arming the same button for a
92
+ second press — an impatient double click fires an armed button. Safe choice first in tab order.
93
+ - **Conventional commits**; ESLint via `@antfu/eslint-config` owns formatting; sparse comments.
260
94
 
261
95
  ## Rules that matter
262
96
 
97
+ These prevent defects if broken. They stay here rather than in `.agentDocs/` for that reason — a rule
98
+ nobody reads is worse than a long file.
99
+
263
100
  - **Server-agnostic.** No blessed ids, no `dataDir`-style globals: a server gets only its own
264
101
  `command`/`args`/`env`/`dataEnvs`/`envFile`/`bootstrap`. A test fails if a core file learns one.
265
- - **Paths.** `dataRoot` is state, and the state itself lives under `dataRoot/.hh` (global files plus
266
- one directory per workspace); `projectDir` is the base for relative entry paths. `{id}{port}`
267
- `{host}{bind}{cwd}{projectDir}{dataRoot}{home}` and `${ENV}` expand in config; there is no
268
- package-relative state.
269
- - **Secrets never enter the config.** Global `.hh/.control-secrets.json` holds the password hash and
270
- API token hash, each workspace's `.secrets.json` holds its Telegram token and DDNS credentials, and
271
- the TLS pair sits in `.hh/.tls/`; every one is 0600 and the config holds only policy.
272
- - **A port holder that is this entry's own successor is not a stranger.** A program that restarts
273
- itself leaves a detached process behind; with `follow` the panel adopts it as-is (pid, liveness,
274
- health, resources, stop — but not its output); with `reclaim` it stops that successor and starts a
275
- fully supervised child instead; with `kill` it stops whatever holds the port without asking whose it
276
- is. All three beat blocking forever on a port that is already serving, and only `kill` ever touches a
277
- process the panel could not identify — which is why it logs the pids it stopped.
278
- Ownership is read from `HHOSTED_SERVER_ID` in the environment (`/proc` on Linux, `ps -E` on macOS),
279
- falling back to the entry's own resolved image **plus its expanded argv** where that is unavailable
280
- or unmatched — the fallback is what gives `follow`/`reclaim` any reach on Windows, which exposes no
281
- per-process environment. That fallback is strict on purpose, because `reclaim` kills what it
282
- identifies: arguments are compared **literally** (never by basename — `/srv/a/server.js` must not
283
- match `/tmp/b/server.js`), an argument can never be satisfied by a word that is not in the argv, and
284
- two matching holders mean the panel refuses to guess and blocks. It still cannot see through a
285
- Windows `.cmd` shim (the holder is `node.exe`, whose argv never mentions the shim) or a macOS
286
- argument containing a space (`ps` joins argv without quoting); those cases block, and `kill` is the
287
- answer for them. `src/providers/identity.ts` owns the matching, and the argv it is given comes from
288
- the supervisor's single `resolveSpawn()`, the same one the spawn itself used — keep it to one
289
- resolver, because a second one that expands or filters args differently re-opens these cases.
290
- Ownership covers the entry's whole **tree**, not the pid it recorded (`processTreePids`): a nanny
291
- puts the real server one generation down, and so does any wrapper entry. The panel's own tree is
292
- deliberately excluded — "ours" has to mean a process a server owns.
293
- - **A persistent entry is run by its own nanny, not by the panel.** `persistent: true` makes the panel
294
- spawn the hidden `__nanny` (`src/services/nanny.ts`), which owns the child's pipes, writes its JSONL
295
- and mirrors its exit — the only arrangement that outlives the panel *and* keeps logging. The panel
296
- skips such an entry in `stopAll()`/`dispose()` (`down` reports what it left running), reattaches on
297
- boot through `.state/<id>.json` before any port preflight (`nannyIsAlive` = live pid **and** a fresh
298
- heartbeat, the `HHOSTED_SERVER_ID` marker or the argv), tails that file for live logs, and reads
299
- `lastExit` once so a crash nobody watched is reported. The spawn spec is consumed by the read and
300
- swept at boot — it carries expanded env. Three lifetimes are pinned: the nanny **exits with its child**
301
- (an inherited pipe would keep it alive, and the panel would report a healthy entry whose server is a
302
- detached stranger), a stop reaches the child by pid from the state file, because `SIGKILL` and
303
- `killGroup: false` cannot be forwarded — which is why a self-restarting program belongs on `reclaim` —
304
- and its `SIGTERM`/`SIGINT` traps are armed *before* the child exists and before that state file
305
- appears, so a stop that beats the spawn can never orphan a child nobody has a record of.
306
- The nanny never restarts anything: retries and health stay the supervisor's. `logs.persist: false`
307
- keeps that file out of the Logs page but never stops it being written — it is the transport.
308
- - **A port is only ever freed by re-listing its listeners.** `POST /api/servers/:id/free-port` never
309
- trusts a pid quoted in a message, and refuses any listener in `supervisedPids()` (the panel plus
310
- every entry's child) instead of killing it — a port held by a sibling is a config mistake.
102
+ - **Paths.** `dataRoot` is state, under `dataRoot/.hh`; `projectDir` is the base for relative entry
103
+ paths. `{…}` placeholders and `${ENV}` expand in config — the full list is the table in
104
+ [`docs/SERVERS.md`](docs/SERVERS.md), which is what to keep in step. There is no package-relative
105
+ state.
106
+ - **Secrets never enter the config.** Only policy goes in a config file; every secret is 0600.
311
107
  - **Never expose beyond loopback without auth and a non-default password.** `checkExposure()` is the
312
- single rule, enforced at startup, on every settings write, and in the UI.
108
+ single rule, enforced at startup, on every settings write, and in the UI. `checkProxyExposure()` is
109
+ the same bar for a proxy route pointing at the panel — **`tls` does not change that bar.**
313
110
  - **The CLI drives a running panel through `/_hh`, never `/api`.** `down` and `start`/`stop` read
314
- run.json's token and speak over loopback through one guard (`assertLocalCall`), so they need no
315
- session, password or API token — and a new local command must not open an unauthenticated `/api` route.
316
- - **A server id is only unique inside its workspace.** Anything keyed by id alone — SSE
317
- (`EventHub` scopes on `serverKey(workspaceId, serverId)`), log buffers, series, route params — must key
318
- on the pair, or two workspaces' same-named servers mix frames.
111
+ run.json's token over loopback through one guard (`assertLocalCall`), so they need no session. A new
112
+ local command must not open an unauthenticated `/api` route.
113
+ - **A server id is only unique inside its workspace.** Anything keyed by id alone — SSE, log buffers,
114
+ series, route params — must key on `(workspaceId, serverId)`, or same-named servers mix frames.
319
115
  - **A workspace is the ownership boundary.** Servers, secrets, logs and nanny state belong to exactly
320
- one workspace; only listener, auth, TLS, host vitals, backups, UI and `run.json` are panel-wide.
321
- A per-workspace service is reached through `PanelService.requireWorkspace(id)`, never a module global.
116
+ one; only listener, auth, TLS, host vitals, backups, UI and `run.json` are panel-wide. Reach a
117
+ per-workspace service through `PanelService.requireWorkspace(id)`, never a module global.
322
118
  - **UIs are external clients.** Nothing in `src/**` may know a UI's markup or files;
323
119
  `$HHOSTED_HOME/.hh/.ui` overrides the packaged UI at runtime.
324
- - **A restore writes only where a config says** — archive allowlist, `origin` matching first,
325
- symlinks never recreated.
326
- - **The reverse proxy's engine is a process the panel owns, not a server entry.** Its generated
327
- configuration is applied through the engine's admin API (atomic, never hand-edited), the engine runs
328
- under the same nanny a `persistent: true` entry uses so a panel restart does not drop the socket,
329
- and its admin endpoint stays panel-side — a unix socket in a `0700` directory, loopback plus the
330
- engine's origin check on Windows. A route that points at the panel is exposure and obeys
331
- `checkProxyExposure()`, the same bar as a non-loopback bind.
332
- - **Cross-platform.** Linux/macOS/Windows: `/proc` vs `ps` vs Win32_Process, process groups vs
333
- `taskkill /T`, graceful fallbacks, no shell utilities assumed.
120
+ - **A restore writes only where a config says** — archive allowlist, `origin` matching first, symlinks
121
+ never recreated.
122
+ - **A port is only ever freed by re-listing its listeners.** `free-port` never trusts a pid quoted in
123
+ a message, and refuses any listener in `supervisedPids()` instead of killing it.
124
+ - **Only `kill` ever touches a process the panel could not identify** — which is why it logs the pids
125
+ it stopped. `follow` adopts this entry's own successor, `reclaim` replaces it. Ownership is the
126
+ entry's whole **tree**, and the panel's own tree is excluded. Full matching rules:
127
+ [`.agentDocs/GOTCHAS.md`](.agentDocs/GOTCHAS.md).
128
+ - **A persistent entry is run by its own nanny** (`src/services/nanny.ts`), which owns the pipes and
129
+ mirrors the exit — the only arrangement that outlives the panel *and* keeps logging. The nanny
130
+ never restarts anything: retries and health stay the supervisor's. Three lifetimes are pinned (it
131
+ exits with its child; a stop reaches the child by pid; its signal traps arm before the child
132
+ exists) — see [`.agentDocs/GOTCHAS.md`](.agentDocs/GOTCHAS.md).
133
+ - **The reverse proxy's engine is a process the panel owns, not a server entry.** Its config is
134
+ applied through the engine's admin API (atomic, never hand-edited), it runs under the same nanny a
135
+ `persistent: true` entry uses, and its admin endpoint stays panel-side.
136
+ - **Cross-platform is a hard requirement.** Linux/macOS/Windows: `/proc` vs `ps` vs Win32_Process,
137
+ process groups vs `taskkill /T`, graceful fallbacks, no shell utilities assumed.
334
138
  - **Writes are atomic** (`writeFileAtomic`) and validated before commit.
335
139
 
336
- ## Gotchas
337
-
338
- - `run.json` is the daemon's identity and `down`'s credential; keep `runtimeSchema` in sync with the
339
- `Runtime` written in `src/index.ts`. It now lives at `.hh/run.json`.
340
- - **`ensureLayout()` runs before any command resolves a state path** (the CLI pre-pass) and is
341
- idempotent. It moves rather than copies, never overwrites an existing `.hh` file, and removes a
342
- legacy directory only when every entry in it actually moved — a refused move must not become a
343
- delete.
344
- - Moving the listener (host/port/TLS) kills the connection answering that request, so
345
- `PATCH /api/settings`, the TLS routes and `/_hh/shutdown` defer with `afterResponse()`.
346
- - `Supervisor.start()` sets `starting` synchronously before its first await, and `stop()` sets
347
- `stopping` first: overlapping calls would double-spawn or resurrect a stopped process. Tests must
348
- call `supervisor.dispose()`. `stopping` is cleared on **every** exit path of `stopEntry()`: the
349
- early return for an entry with no child used to leave it set, and since `start()` refuses while it
350
- is true, one Stop on an already-stopped server made that entry unstartable until a daemon restart.
351
- - Timers and `void`-ed promises in the supervisor carry a `.catch`: a throw in the tick or in a retry
352
- is an unhandled rejection, and Node 24 ends the process on one.
353
- - **`Workspace Settings → Server defaults` merge into the nested groups key by key** (`mergeDefaults`
354
- in `src/config/schema.ts`, used by the servers parser and `validateServer`). A shallow
355
- `{ ...defaults, ...entry }` lets an entry that decides `restart.maxRetries` silently drop the
356
- workspace's `restart.baseDelayMs` — and the create/edit diff flow depends on inheriting exactly that.
357
- - The curated CLI dispatch reads only the **flag** forms (`--help`/`-h`, `--version`/`-v`) after a
358
- command name, never a bare `help`/`version`: that is an option's value (`init --name help`).
359
- - Port preflight re-probes after 300 ms — a just-closed listener can still complete a handshake.
360
- - **A workspace's config is re-read whenever its files change on disk** (`src/services/config-watch.ts`
361
- → `WorkspaceStore.reloadFromDisk()`, wired per workspace in `src/services/panel.ts`). The watch is on the *directory*, because
362
- an editor's save is a temporary file renamed over the target — the inode changes, the name does not
363
- — and a two-second poll backs it up where `fs.watch` is undependable (network mounts). The store
364
- compares the bytes it last read and the bytes it wrote, so the panel's own saves never reload
365
- anything, and a revision it cannot read (unparseable JSON included — that path used to fall back to
366
- an *empty* config until a watcher made it reachable, which would have stopped every server) is
367
- reported in the state frame while the running config is kept. A changed definition takes effect on
368
- that entry's next start; only an added `autostart` entry is started, and `--no-autostart` still
369
- means the panel starts nothing on its own.
370
- - **The shell must watch the *session*, not only the flags it derives.** With authentication off,
371
- `authRequired` and `authenticated` are both `false` from the first paint to the last, so a watcher
372
- on those two never ran after the session landed: the panel never opened its event stream, the
373
- connection badge sat on "Connecting", and the dashboard showed one stale snapshot forever. Both
374
- UIs turn it into a single `streamDecision(session)` value (`wait` | `connect` | `login`) and watch
375
- that, which is also what the test pins.
376
- - **A form that copies live state must guard per block, never globally.** The host thresholds and the
377
- backups policy arrive from `/api/settings` *after* the SSE frame, so a single "has anything
378
- changed?" gate leaves them showing schema defaults forever — the backups toggle reported itself as
379
- changed and flipped back to `true` on every reload. `uis/stock` compares each block against the
380
- snapshot it was last filled from (`blockSnapshot`/`isBlockEdited`, `syncFromLive`) and reads the
381
- file-only blocks on their own. The copy has to be **detached**, too: a shallow one leaves a nested
382
- group (`health.http`) aliased to live state, so an edit to "Healthy below status" mutated the very
383
- config the guard compared against and the next frame reset the whole editor (`cloneHealth`).
384
- - **An absolutely positioned element inside a *static* scroll container resolves against the
385
- page, not the scroller** — an `sr-only` label in a table row was enough to give the document a
386
- second, empty scrollbar. The shell's `main` carries `relative` for that reason; keep it there.
387
- - **A dialog's footer has to be a flex sibling of a scrolling body** (`uis/stock/src/components/ui/Modal.vue`):
388
- the sheet is `flex flex-col` with `max-h-[88dvh]`, the body `min-h-0 flex-1 overflow-y-auto`. When
389
- only the body carried a max-height, a tall form pushed its own save button below the clipped
390
- edge — the add-server dialog looked like it had no save button at all.
391
- - Vue does not notify a computed's subscribers when its recomputed value is `Object.is`-equal to the
392
- old one, so anything mutated in place silently freezes every value derived from it. The log buffers
393
- (`uis/stock/src/composables/useControlPlane.ts`) therefore hand out a **new array per batch**, and
394
- the `version` counter only exists to make the views re-read at all. Getting this wrong is what kept
395
- the live output view empty until a remount — a test that reads the array itself will not catch it.
396
- - An adopted process is not a `ChildProcess`, so nothing reports its exit: the tick polls liveness and
397
- hands the entry back to the normal `afterExit` path. Its output is not captured either — it was
398
- redirected by whoever spawned it. The exception is a persistent entry, whose output the panel
399
- *does* capture: its nanny writes the JSONL, and `log-relay` tails it into the same buffer and SSE
400
- frames a pipe would feed.
401
- - `stop.killPortHolders` frees a port only from a *listener* that is not our own process tree. Broad
402
- `lsof -ti:<port>` sweeps and pid-as-text parses have killed supervisors in the field; don't add one.
403
- `free-port` reuses the same lookup and adds the supervisor's own pid set on top.
404
- - `ServerView.config.port` is normalized to `number | null`; the hand-narrowed types in
405
- `contracts.ts` are deliberate.
406
- - ArkType: an optional property (`'x?'`) rejects an explicit `undefined` (omit the key). Fields a UI
407
- must clear are `'type | null?'`.
408
- - Server args are logged *before* `${VAR}` expansion, so an expanded secret never reaches the log
409
- buffer, disk, SSE or Telegram.
410
- - Backups are zips; a password makes them WinZip AES-256/AE-2, and zero-byte entries stay
411
- unencrypted on purpose (p7zip 16.02 reports a CRC failure otherwise). `list()` is sync, so
412
- encryption flags are cached and refreshed in the background.
413
- - Generated directories are skipped by the `filter` handed to `fs.cpSync`, matched on an exact path
414
- segment at any depth (`src/shared/generated.ts`): `dist/` and `app/node_modules/` go, while
415
- `distributed/` and `my-node_modules/` stay. Only the paths an entry declares are filtered — a
416
- global `backups.includePaths` entry is captured as it stands.
417
- - `vite.server.config.ts` targets `node22` while `engines` requires >= 24 — deliberate margin, leave it.
418
- - `pnpm test` watches; CI runs `vitest run`.
419
- - `pnpm run media` drives Chromium through Playwright, which needs fonts *and* the X/NSS/Mesa
420
- libraries. In a bare container point `FONTCONFIG_PATH` at a `fonts.conf` covering any TTF and
421
- `LD_LIBRARY_PATH` at a directory holding those libraries — without fonts, Skia panics instead of
422
- rendering, and the failure looks like a broken page rather than a missing package.
140
+ ## How to work here
141
+
142
+ - **Check who calls it before you change it.** Grep the callers and the tests that name it, and say
143
+ when the impact is unclear rather than guessing. One guard here looked like the one that runs and
144
+ was not: `declaredData` marks a path `included: false` so the copy never starts, while the check
145
+ inside `copyInto` is a second line that route does not reach.
146
+ - **Never overwrite or delete a large section you have not fully understood.** Read it, or say what
147
+ you could not determine and ask.
148
+ - **Report the risk, not only the change** — what could break, what you could not verify, and every
149
+ assumption you made. Worth checking by kind: correctness, security, operational (deploy, host,
150
+ scaling) and integration (dependencies, breaking changes). A confident answer that omits this is
151
+ worth less than a hedged one that has it.
152
+ - **Fix the root cause, not the instance.** When the same bug keeps reappearing under different
153
+ names — a copied helper, a rule stated twice, a guard bypassed by a second code path — fix the
154
+ class: one shared implementation, one formatter, one guard. That is the work, not a follow-up to
155
+ ask for. Say what you changed and what it now prevents, and keep it inside the task's scope rather
156
+ than refactoring the world.
157
+ - **Verify before claiming, and say what you checked.** A green test proves only what it asserts —
158
+ **break the thing it guards and watch it fail.** If it still passes, either the test is decoration
159
+ or a different guard is running; find out which. Where a stub cannot answer the question, drive the
160
+ real thing. Mark anything unverified as unverified.
161
+ - **If recall of this project is missing** (a compacted or fresh session, a different machine), read
162
+ this file, `.agentDocs/`, and `git log` before acting, and ask 1–3 targeted questions rather than
163
+ reconstructing intent from guesswork.
164
+ - **Leave the docs better than you found them.** Delete what is stale, compact what has grown, and
165
+ never leave a pointer to content that does not exist.
166
+
167
+ ## Conciseness
168
+
169
+ **Prune verbose; keep correctness.** This covers code, comments, user docs and agent docs alike.
170
+
171
+ - Code: say it once, name it well; a comment only for non-obvious *intent*, never to restate the line.
172
+ - Docs: one idea per sentence, prefer a table or a line to a paragraph. Cut a sentence that would not
173
+ change what a reader does.
174
+ - Delete history that `git log` already holds. Keep the *rule* that came out of it, not the story —
175
+ a path list of where something used to live is archaeology, not guidance.
176
+ - Do not drop a caveat to save a line. Concise means no filler, not fewer facts.
177
+
178
+ ## User-facing docs
179
+
180
+ `README.md` and the topical `docs/*.md` are for a person, not an agent:
181
+
182
+ - **Concise first read**, depth behind collapsible `<details>` spoilers, and **visuals for skimmers**
183
+ — the media in `docs/media/` is regenerated by `pnpm run media`.
184
+ - **Docs ship with the change.** A user-visible change updates the docs that describe it in the *same
185
+ commit*. A stale doc is a bug like stale code.
186
+ - **UIs move together.** `uis/stock` is not the only client: a change to it — or to a shared contract
187
+ it reads — lands in every other UI under `uis/`, and each altered UI bumps its `ui.json` (see
188
+ [`.agentDocs/ARCHITECTURE.md`](.agentDocs/ARCHITECTURE.md)).
189
+
190
+ ## Releasing
191
+
192
+ Dispatched from `.github/workflows/release.yml` with a version (`-f dry-run=true` to rehearse). It
193
+ verifies the version, lints/types/tests, builds the CLI and every UI zip, lets changelogen write the
194
+ changelog and bump `package.json`, commits and tags `v<version>`, creates the GitHub release with the
195
+ UI bundles attached, and publishes to npm through trusted publishing (OIDC, no token). npm only
196
+ offers a trusted publisher for a package that **already exists**, so a first-ever release has to be
197
+ published by hand.
198
+
199
+ ```sh
200
+ gh workflow run release.yml -f version=0.6.3
201
+ ```
202
+
203
+ Four workflows, and the split is deliberate:
204
+
205
+ | workflow | runs on | what it proves |
206
+ | --- | --- | --- |
207
+ | `quickcheck.yml` / `test.yml` | every push and PR | lint, types, the whole suite with coverage — Linux only |
208
+ | `cross-platform.yml` | the release gate, or by hand | the same suite on macOS and Windows |
209
+ | `release.yml` | dispatched with a version | the platform gate, then tag, publish, release assets |
210
+
211
+ The platform gate is not a per-push job on purpose: it is the expensive half, and it is what makes a
212
+ platform-specific break fail before shipping rather than on someone's machine. Every job carries a
213
+ `timeout-minutes`, so a wedged runner is reported instead of sitting out GitHub's 6-hour default.
214
+
215
+ **Read the skip count, not just the tick.** Linux has no skips; macOS and Windows report a handful
216
+ (Windows more), all from platform guards. What to watch is the *shape*: a new skip appearing, or a
217
+ clean pass where a guard should have fired. **A test that returns early instead of skipping reports a
218
+ pass**, hiding a platform that never ran the code — use `it.runIf`/`it.skipIf` with the condition
219
+ computed at **module load**.
220
+
221
+ **`npm publish` exiting 0 is the whole answer.** The registry can take up to ten minutes to show a
222
+ package it has accepted — npm's queue, not this run's. Do not poll it, and do not hold work waiting.
223
+
224
+ ### Which version to dispatch
225
+
226
+ Below 1.0 the **minor is the breaking channel**: a fix or non-breaking feature is a **patch**, while a
227
+ minor means someone has to read the release notes and act. A `feat:` commit does not decide this —
228
+ ask what the user has to do about it. Never edit `package.json` by hand; changelogen bumps inside the
229
+ workflow. `scripts/check-release-version.mjs` refuses a patch while a breaking commit is pending.
230
+
231
+ ### What the package ships
232
+
233
+ `pnpm pack` runs `prepack` (a full build) and ships the `files` list in `package.json`: `bin/`,
234
+ `dist/`, `uis/stock/dist`, `README.md`, the five user-facing `docs/*.md`, `AGENTS.md` and `LICENSE`.
235
+ `.agentDocs/` is deliberately not published — it is for agents working in this repo, not for people
236
+ installing the package. The bin falls back to tsx so `pnpm link` works before a build;
237
+ `vue`/`vue-router` are devDependencies because the UIs are prebuilt.
423
238
 
424
239
  ## Where to extend
425
240
 
@@ -430,14 +245,7 @@ either is a last resort, and never an accidental one.
430
245
  - **Server field**: `serverSchema` + its patch in contracts, merge keys in `config/patch.ts` when
431
246
  nested, the form in `uis/stock/src/components/settings/`, and the contract tests.
432
247
  - **UI**: a new `uis/<name>/` with a `vite.config.ts` from the shared factory;
433
- `node scripts/build-uis.mjs <name> --zip`. The contract is `docs/UI_CREATION.md`. A change to one
434
- UI (`stock`, `noc-console`) is carried to the others in the same commit.
248
+ `node scripts/build-uis.mjs <name> --zip`. The contract is `docs/UI_CREATION.md`.
435
249
  - **Capability**: a stateless `src/providers/*` returning plain data; a DDNS provider is one file plus
436
- a registry line (`src/providers/ddns/index.ts`), and the settings form, credentials and validation
437
- follow from its metadata — see `docs/DDNS.md`.
438
-
439
- ## Publishing
440
-
441
- `pnpm pack` runs `prepack` (a full build) and ships `bin/`, `dist/`, `uis/stock/dist`, `README.md`, `docs/DDNS.md`,
442
- `docs/SERVERS.md`, `docs/NOTIFICATIONS.md`, `docs/REVERSE_PROXY.md`, `docs/UI_CREATION.md`, `AGENTS.md` and `LICENSE`. The bin falls back to tsx so `pnpm link` works before a
443
- build; `vue`/`vue-router` are devDependencies because the UIs are prebuilt.
250
+ a registry line (`src/providers/ddns/index.ts`) — the settings form, credentials and validation
251
+ follow from its metadata. See `docs/DDNS.md`.