Files
platform/plugins/EXTRACTING-A-PLUGIN.md
T
pastilhasandClaude Opus 5 18c4ebd0b4 the per-user model is not tonight's work either
personal, the four user-scoped tables, and what a member's grant means all stay
exactly as they are. it gets built inside the plugin later, which is the entire
reason the platform's answer is a uniform read/write and nothing more.

leaving it alone is safe rather than lazy: the web frontend does not use those
routes at all — favourites, playlists and now-playing are used only by the phone
and tablet apps — so nothing visible in a browser can regress by carrying them
across verbatim. and /queue, which is in that list, has no route anywhere. dead
or aspirational; carried as-is, not investigated.

the one unavoidable consequence stays named: registry.test.ts tests the personal
mechanism through the music entry, so removing it breaks those tests. re-anchor
on another entry that has personal. a test fix, not a redesign.

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

12 KiB

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.tsplugins/<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/schemausers.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 model is NOT tonight's work

personal: ['/favorites', '/now-playing', '/playlists', '/queue'] on music's registry entry, the four user-scoped tables, and the question of what a member's grant meansall of it stays exactly as it is. Do not design it, do not improve it, do not think about it. It will be built inside the plugin later, which is the whole reason the platform's answer is a uniform read/write and nothing more.

Two facts that make leaving it alone safe rather than lazy:

  • The web frontend does not use those routes at all. Favourites, playlists and now-playing are used only by the phone and tablet apps. Nothing you can see in a browser depends on them, so nothing in this extraction can regress by carrying them across untouched.
  • /queue is in that list and no such route exists, in the sidecar or anywhere else. Dead or aspirational. Carry it as-is; do not investigate.

Move the routes, the tables and the queries verbatim. userId keeps arriving from X-Officer-User and the queries keep scoping by it, exactly as today.

The one mechanical consequence you cannot avoid: registry.test.ts tests the personal mechanism through the music entry, and registry.ts uses music as its worked example in prose. Removing the entry breaks those tests. Re-anchor them on another entry that has personal — the smallest edit that makes them green and still test the mechanism. That is a test fix, not a redesign.

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.


Still open, platform-wide. Do not rediscover these

  • Websocket providersserver.reload({ routes }) proven, never called
  • assertCapabilityTotality reads the wrong listObject.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.