Offscale was self-sufficient. Music is not — it shells out to ffmpeg and ffprobe — and the way it fails without them is the reason this is a check rather than a line in a README. It does not fail. Missing ffprobe means the indexer catches the spawn error and returns a track carrying its filename and nothing else: no title, artist, album, duration or embedded lyrics. It then walks the whole library, writes a complete cache tree and reports success. Five swallowed catches, no log, no counter, and the only tell is coversSaved: 0 in a report nobody reads. So `osDependencies` is a manifest field: the binary to probe on PATH, why it is needed, and a package name per package manager. The shape is taken from scripts/setup-old/setup.sh rather than invented — probe the binary, case on $PM — and the names are per-manager rather than canonical-with-overrides because lib/packages.sh already recorded why that indirection was rejected. Probing the binary is what makes "built-in on this OS" free: on PATH means the package map is never consulted. Four decisions worth naming. Missing and uninstallable REFUSES the install, first, before a table is created or a row written — so there is nothing to undo, and the alternative is a plugin that installs, answers 200 and quietly produces nothing. The status is on GET /api/plugins and rendered before the button, because the owner is deciding whether to let the server run a package manager as root and that needs answering first. Installing by hand and watching it flip to present is the escape hatch on a machine without passwordless sudo. Package names get a deliberately narrow regex and reach Bun.spawn as an argv ARRAY, never a shell. Both halves are load-bearing: the regex means a metacharacter cannot get there, argv means it would be an argument rather than syntax if it did. Narrower than package managers actually accept — no `:`, no `+` version pins — because a plugin needing one wants a conversation. Success is OBSERVED, not inferred: after installing, the binaries are re-probed. A package manager exiting 0 having installed something that does not provide the binary is exactly the failure this exists to catch. installCommand mirrors lib/packages.sh's pkg_install_now exactly, including apt's non-interactive environment, so there is one definition of "install a package" rather than two that drift. sudo always gets -n: under PM2 a password prompt is not a slow path, it is a hang. brew never escalates. Verified live. ffmpeg and ffprobe were absent on this machine all evening; the page showed both missing with the exact root command, the install streamed `dependencies: installing ffmpeg with apt` then `ffprobe, ffmpeg now on PATH`, and X-Audio-Duration appeared on a stream response for the first time. The refusal path was exercised against a temporary probe dependency: HTTP 400, steps: [], reason named. THIS CHANGED THE MACHINE: ffmpeg 6.1.1-3ubuntu5 is now installed via apt. Found on the way: a manifest is read once per process. Discovery does `await import()` and the module cache holds it, so editing a manifest changes nothing until pm2 restart officer — including `outdated`. Cost ten minutes and is now in the runbook. bunx tsgo clean. 797 tests, 787 pass, 7 fail — the same seven, +25 new.
195 lines
12 KiB
Markdown
195 lines
12 KiB
Markdown
# Extracting a feature into a plugin
|
|
|
|
The runbook, written the day offscale became the first one. Follow it for music, then for the rest.
|
|
|
|
**Read first, in this order:**
|
|
|
|
1. `plugins/offscale/PLUGIN.md` — every decision and why, including the three that reversed
|
|
2. `plugins/example/` — the reference implementation, deliberately the smallest real plugin
|
|
3. `plugins/offscale/` — the worked example, all four parts
|
|
4. `plugins/music/PLUGIN.md` — the MESSY worked example: three pieces that stayed behind, and why each
|
|
is a seam rather than a loose end. Read it if your feature has anything the platform also uses.
|
|
5. `src/servers/plugins/` — the system itself: `manifest`, `discover`, `mount`, `install`, `ecosystem`, `schema`, `generate`
|
|
|
|
---
|
|
|
|
## The rules. These are not preferences
|
|
|
|
**Every plugin route renders a Workspace with at least one panel.** A plugin contributes `web/panels.ts`
|
|
(`appRegistryMetas`, at least one) and `web/layout.ts` (`defaultLayout`); the shell renders
|
|
`WorkspaceView` around them. There is no way to export a component — a `web/` directory missing either
|
|
file is **refused at discovery, by name**. Non-compliance is unrepresentable, not forbidden.
|
|
|
|
**Every plugin permission is grantable, per role, at read or write.** No `kind`, no `ownerOnly`, no field
|
|
of any sort. The platform's answer is uniform; what a grant _means_ — whose rows a member sees, whether a
|
|
resource is shared or per-user — is the plugin's own job, in its own queries.
|
|
|
|
**Say `permissions`, never the other word.** It already means three things in this codebase.
|
|
|
|
**The manifest holds only what a directory listing cannot say.** Identity facts and human choices:
|
|
`publisher`, `version`, `platform`, `label`, `summary`, `icon`, `color`, `permissions`. Everything
|
|
structural is convention — presence is the declaration:
|
|
|
|
```
|
|
manifest.ts required
|
|
api/router.ts a backend router, mounted at mountPrefix()
|
|
db/schema.ts tables, prefixed <app-name>_
|
|
sidecar/index.ts a process (.mjs instead means node)
|
|
web/panels.ts panels — REQUIRED with web/
|
|
web/layout.ts layout — REQUIRED with web/
|
|
```
|
|
|
|
**A host binary is the one exception, and it goes in the manifest** — the tree cannot say it. Declare
|
|
`osDependencies` when your plugin shells out to something: the binary to probe on PATH, why it is needed,
|
|
and a package name per package manager. Absent means self-sufficient, which offscale and example are.
|
|
Music added the field; see its PLUGIN.md for what it is guarding against.
|
|
|
|
`appName` is the **directory name**. The sidecar runtime is the **file extension**.
|
|
|
|
**Nothing may branch on provenance** except `mountPrefix()`. First-party and third-party differing
|
|
anywhere else means two systems, and only one gets tested.
|
|
|
|
**Uninstall never destroys data.** The generated schema barrel follows plugin **directories**, not the
|
|
install table — `db:push` drops what it cannot see, so following installs would delete a plugin's tables
|
|
on uninstall. Only deleting a plugin's source can lose its data.
|
|
|
|
---
|
|
|
|
## The order that worked
|
|
|
|
1. **Map it first.** Sidecar, api router, db, frontend, and every line of platform wiring that names it.
|
|
2. **Move the backend**: `sidecar/` → `plugins/<name>/sidecar/`, `api/<name>/router.ts` →
|
|
`plugins/<name>/api/router.ts` (export `router`, not `<name>Router`), `officer_db/src/<name>/*` →
|
|
`plugins/<name>/db/`.
|
|
3. **Rewrite imports.** Platform code becomes `@@/…` (resolves from `plugins/` — verified). Queries take
|
|
`officerdb/db` and `officerdb/crypto`. Schema takes `officerdb/auth/schema` — `users.id` is the one
|
|
reference a plugin may make.
|
|
4. **Write `manifest.ts`.**
|
|
5. **Move the frontend** to `web/`, as `panels.ts` + `layout.ts`. Imports of platform UI become
|
|
`officerdev` (the barrel exports `WorkspaceView`, `TerminalView`, `AppRegistryMeta`); `hooks/useClient`
|
|
and `helpers/clipboard` stay as they are.
|
|
6. **Remove every trace from the platform**, and delete rather than comment out: `hono.ts` mount and
|
|
import, the `capabilities/registry.ts` entry, `App.tsx` routes, `Screens/Dashboard/index.tsx`,
|
|
`AppRegistry.tsx`, `officerdev/src/index.ts` re-exports, `Dock.tsx` tile, `usePageTitle.ts` rule, and
|
|
**both** database barrels (`index.ts` and `schema.ts`).
|
|
7. **`bunx tsgo`** until clean. It finds the wiring you missed.
|
|
8. **Verify on the live server** — see below.
|
|
9. **Commit and push.** Message says what moved, what it found, and what is still open.
|
|
|
|
---
|
|
|
|
## Verification — run all of it
|
|
|
|
```
|
|
bun test # 757 pass, 10 pre-existing failures. Any 11th is yours
|
|
pm2 restart officer
|
|
```
|
|
|
|
Then through `/plugins`, watching PM2 and the browser at each step:
|
|
|
|
| Step | Expect |
|
|
| ------------------------------- | ----------------------------------------------------------- |
|
|
| install | streamed log; schema applied; sidecar online; route mounted |
|
|
| the plugin's API | answers |
|
|
| the plugin's screen | renders as a Workspace |
|
|
| dock | tile appears |
|
|
| permissions page | its permission is listed, read/write/none |
|
|
| disable | route 404s, sidecar stops, **tables and rows survive** |
|
|
| enable | comes back |
|
|
| uninstall | route gone, `pm2 list` loses it, **data still there** |
|
|
| `bun db:push` while uninstalled | `No changes detected` — data survives |
|
|
| install again | identical to the first install |
|
|
|
|
A normal refresh is enough; the shell is `no-store`. When the log's last line appears, the bundle exists.
|
|
|
|
---
|
|
|
|
## Traps, all of which cost real time once
|
|
|
|
- **Mount before starting the sidecar.** `createSidecarProxy` learns its port from a one-shot
|
|
`<name>:server` event and subscribes when the router is first imported — at mount. Start first and the
|
|
announcement fires into a void: online process, mounted routes, every request `503`. Already fixed in
|
|
`install.ts`; do not reorder it.
|
|
- **`src/servers/sidecar/protocol.ts` still declares `<name>:server` per sidecar.** Music will need its
|
|
line kept, or the union generalised to `` `${string}:server` `` — which is the better fix and is
|
|
pending for the whole protocol.
|
|
- **`bunfig.toml` plugins do not reach `Bun.build()`.** Tailwind is passed explicitly in `generate.ts`.
|
|
- **The shell output is named for the entrypoint** (`index.gen.html`), and `naming` does not change it.
|
|
- **A stale generated file** (`Plugins.gen.tsx`, `plugin-schemas.gen.ts`) will fail the typecheck after a
|
|
contract change. Regenerate rather than hand-edit.
|
|
- **Delete the feature's `app-store/catalogue.ts` entry, or its screen goes blank.** `capabilityAvailability`
|
|
derives from `sidecar_installs`, and a plugin never gets a row there — its install state is
|
|
`plugin_installs`. A leftover catalogue entry therefore makes the capability permanently `unavailable`,
|
|
which puts its route into `deniedRoutes` and withholds the dock tile, on a server where the plugin is
|
|
installed and healthy. This has now bitten twice: headscale (2026-08-14) and nearly music. The note in
|
|
`catalogue.ts` is the one to read.
|
|
- **Moving a `*.test.ts` into `plugins/` used to stop it running, silently.** `[test] root` was `./src`
|
|
until music; it is now `.`. If that ever goes back, every extraction quietly shrinks the suite. Compare
|
|
the FILE COUNT across a run, not just pass/fail — that is the only thing that shows it.
|
|
- **A manifest is read once per server process.** Discovery does `await import(manifest.ts)`, and the
|
|
module cache holds it for the lifetime of the process — so editing a manifest while developing changes
|
|
nothing until `pm2 restart officer`. Costs ten minutes the first time, because the plugins page keeps
|
|
cheerfully showing the old values. `outdated` cannot notice a version bump without a restart either.
|
|
- **A plugin importing platform code is fine (`@@/…`); the reverse is not.** If something in `src/` imports
|
|
from your feature and cannot move — a widget, a relay — that piece stays, and the boundary goes around
|
|
it. Find those before you plan the split; they decide it for you.
|
|
|
|
---
|
|
|
|
## Music is done. What it changed about this runbook
|
|
|
|
Extracted 2026-08-15 and verified live through the whole table above. `plugins/music/PLUGIN.md` is the
|
|
record; the parts worth carrying forward are already folded into the rules and traps above.
|
|
|
|
The one thing that generalises: **map what the PLATFORM still needs from your feature before you plan the
|
|
split.** Music's boundary was not chosen — it was dictated by two imports pointing the wrong way (a
|
|
dashboard widget reaching for `useMusicPlayer`, a cliamp relay reaching for `getMusicServerWsUrl`), and
|
|
both were found by reading the import graph rather than by reasoning about what music "is". Offscale had
|
|
none, so it came out whole and made the job look cleaner than it is.
|
|
|
|
The three pieces music left behind are `officerdev/src/MusicPlayer/`, `src/servers/api/music/router.ts`
|
|
and everything cliamp. Each is documented where it sits. **None of them is work waiting for you** — do
|
|
not tidy them into a plugin as a warm-up.
|
|
|
|
### The global-overlay question is answered, and the answer is no
|
|
|
|
Music was the first feature wanting to render on every route. It does not get to, and neither will the
|
|
next one: a shell slot for a plugin-provided component reopens "there is no way to export a component",
|
|
which is the rule the whole frontend contract rests on. `MusicPlayerHost` stays in `DashboardLayout`,
|
|
gated on its plugin's permission so it switches itself off with the plugin.
|
|
|
|
Reopen this only for a feature where the overlay is the whole product, and expect to argue for it.
|
|
|
|
---
|
|
|
|
## Which one next
|
|
|
|
No decision has been made. What the tree says, for whoever picks it:
|
|
|
|
- **`schema.ts` still lists eight commented plugin schemas** — email, notify, dav, photos, jellyfin,
|
|
invoiceshelf, soulseek, vault, wallet. Each line names its tables and the file that defines them, which
|
|
is exactly what its extraction needs.
|
|
- **`hono.ts` still has fifteen commented mounts.** Same list, roughly.
|
|
- **Soulseek is the interesting one**, and not because it is easy: `docs/navigation-audit.md` records its
|
|
panels making 37 raw upstream calls, which is the mistake the offscale sidecar exists to avoid. Its
|
|
extraction is a rewrite wearing a move's clothes. Say so up front rather than discovering it at 2am.
|
|
- **Email and wallet both hold credentials**, so they meet `secret-store` and `service_connections` in a
|
|
way neither of the first two did. Read `docs/secret-store.md` first.
|
|
|
|
## Still open, platform-wide. Do not rediscover these
|
|
|
|
- **Websocket providers** — `server.reload({ routes })` proven, never called. No plugin owns a socket yet;
|
|
music would have been the first and cliamp being out of scope is what let it pass.
|
|
- **`assertCapabilityTotality` reads the wrong list** — `Object.keys(handlers)` while Bun serves the route
|
|
table, and plugin routes are not in `PROTECTED_API_PREFIXES` at all. It belongs in `buildHonoApp()`,
|
|
now the single place routes are mounted. Security-adjacent; close it before members reach plugin routes.
|
|
The live example is the two cliamp sockets: served in the route table, claimed by no capability, and
|
|
invisible to the check. Pinned by a test in `registry.test.ts` so it stays a known fact.
|
|
- **Two dock sources** — the app store keeps its own catalogue; one when it is rebuilt on this
|
|
- **Offscale's queries scope by caller**, so a granted member sees their own empty list rather than the
|
|
owner's. Its own job, not the platform's.
|
|
- **`protocol.ts` declares `<name>:server` per sidecar.** `music:server` and `headscale:server` are both
|
|
still there for plugins that have left. Generalising the union to `` `${string}:server` `` is the fix.
|
|
- **`hasPersonalWrites` reads `c.personal` only**, so a plugin declaring the same thing through
|
|
`readOnlyWrites` reports `false`. Nothing renders it, so it is dead on the wire.
|