Files
platform/plugins/EXTRACTING-A-PLUGIN.md
T
pastilhasandClaude Opus 5 7d65732f77 the widget is out of scope too, leaving one open call
widgets/MusicPlayer stays in its third workspace package, registered where it
is. plugins cannot contribute widgets and are not going to learn how tonight.

with cliamp and the widget both cut, exactly one judgement is left in the music
extraction: whether the global MusicPlayerHost overlay gets a contribution slot
in the shell or stays in DashboardLayout gated as it already is. either is fine.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 01:21:54 +00:00

223 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. `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/
```
`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.
---
## Music specifically — READ THIS BEFORE STARTING
**Music is bigger than offscale, and one part of it has nowhere to go.** Two others did, and the owner cut
both from tonight's scope — see below.
Mapped 2026-08-15.
**Decide these yourself. Do not stop to ask.** Every one of them has two defensible answers, the owner has
said either is fine, and a corrected decision is cheap where a stalled extraction is not. Record what you
chose and why — in the manifest, in a comment, in the commit message — and keep going. The failure mode to
avoid is not a wrong call; it is a tree left half-moved with a question attached.
### What has no home yet
1. ~~**Two websocket providers.**~~ **OUT OF SCOPE — do not touch cliamp.**
`cliamp` and `cliamp-audio` exist for one thing: running the `cliamp` TUI player on the server and
piping its terminal and its audio (a PulseAudio null sink tapped by `parec`) to the browser. It is a
second, separate playback path and the owner's word is that it is the least important part of music —
fun, wanted eventually, not tonight.
**This is the whole reason the websocket gap does not block this extraction.** Both sockets are already
inert (routes upgrade into commented-out handlers). Leave `sidecar/music/cliamp-ws.ts`,
`sidecar/music/pulse-audio.ts`, `sidecar/music/asoundrc`, `src/servers/api/cliamp/relay.ts` and the two
providers in `server.tsx` exactly where they are, untouched. If the relay's import of
`getMusicServerWsUrl` is the only thing keeping `api/music/router.ts` alive, leave that file too and
say so — a small documented seam is fine.
What music actually is: the `/music` screen, the library, and the phone and tablet apps that stream
from it. That is what has to work.
2. **A global UI overlay.** `MusicPlayerHost` is rendered by `DashboardLayout` on **every route**, not
inside a panel, and gates itself on `can('music')`. A plugin contributes panels and a layout; there is
no "render this everywhere" slot, and inventing one is a platform change.
3. ~~**A dashboard widget.**~~ **OUT OF SCOPE — leave it where it is.**
`src/workspaces/widgets/MusicPlayer/` sits in a third workspace package and is wired through
`WidgetRegistry`. Plugins cannot contribute widgets and are not going to learn how tonight. Leave the
directory, the registration and the barrel export alone; note the seam in the manifest.
### What else differs
- **External binaries**: with cliamp out of scope, what the plugin still needs is `ffmpeg` and `ffprobe`,
for tag reading, cover compression and poster frames. A manifest has no way to declare a host
dependency; note it and move on. (`cliamp`, `parec`, `pulseaudio`, `pactl` and `asoundrc` stay behind
with cliamp.)
- **Range requests**: `stream-audio.ts` does 206 / `Content-Range` / 416 and a custom `X-Audio-Duration`;
the proxy runs at `timeoutSeconds: 1800` for reindexes. Verify `Range` survives the hop.
- **Cross-capability**: `MusicBrowser` calls `/file-browser/ls`, not the music sidecar.
- **A CLI tool**: `scripts/reindex-music.ts` reads `DATA_PATH/music/.server`.
- **Music is the platform's worked example.** `registry.ts` explains `personal` through it and
`registry.test.ts` tests the mechanism *through the music capability*. Those tests must be rewritten
against something else, not deleted.
- **Already half-disabled**: `hono.ts` mount and `schema.ts` export are commented out since 2026-08-13;
`officer_db/src/index.ts:49` is still live. So the tree is mid-migration already.
### The per-user shape, which is why music was chosen
```ts
personal: ['/favorites', '/now-playing', '/playlists', '/queue'],
```
No `readOnlyWrites`. Note `/queue` — **no such route exists**, in the sidecar or the frontend. Dead or
aspirational; find out which.
Four tables, all `userId` + cascade to `users.id`: `music_favorites`, `music_playlists`,
`music_playlist_items`, `music_now_playing` (composite PK `(userId, device)`).
**And the mismatch that makes music the interesting case:** the state is per-user, the library is one
global directory. `MUSIC_ROOT = join(homedir(), 'Music')`, and `stream-audio.ts` and `cliamp-ws.ts` each
take `homedir()` as their root independently. So a granted member's favourites are theirs, while
`/stream` serves them the **owner's** files and cliamp spawns a process in the **owner's** home. The
app-store entry promises a configurable `library` folder and **nothing reads it**.
### How to decide the three, if you want a default
Each of these is "close the platform gap" or "leave the piece behind". **Closing the gap is better when
you have the runway**, because every later plugin needs it too — but a music that works with cliamp left
in the platform is worth far more than a perfect design that did not land.
| Part | Close the gap | Leave it behind |
| --- | --- | --- |
| ~~websockets~~ | — | **out of scope. Do not spend a minute on cliamp.** |
| ~~widget~~ | — | **out of scope. Leave `widgets/MusicPlayer` exactly as it is.** |
| global overlay | add one slot the shell renders from installed plugins | leave `MusicPlayerHost` in `DashboardLayout`, gated as it already is |
**One open call, then.** The player overlay is the only judgement left, and either answer is fine.
Whatever you pick, the plugin must **install, enable, disable and uninstall cleanly** at the end. A piece
left in the platform is a documented seam; a piece left dangling is a bug.
`assertCapabilityTotality` is **not** part of this. It is worth fixing eventually and the cliamp routes
are the live example of the drift it cannot see, but both are someone else's evening.
**Do not** invent music's per-user visibility model during the move. Extract with the semantics it has, or
the two changes tangle and a bug in either is a bug in both.
---
## Still open, platform-wide. Do not rediscover these
- **Websocket providers** — `server.reload({ routes })` proven, never called
- **`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.
- **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.