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>
223 lines
12 KiB
Markdown
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.
|