plugins/EXTRACTING-A-PLUGIN.md: the rules that are not preferences, the order that worked, the verification cycle the owner actually ran, and the traps that each cost real time. the music section is the important half. music is NOT a bigger offscale — three of its parts have nowhere to go: two websocket providers (and no plugin can own a socket), a global UI overlay rendered by DashboardLayout on every route, and a dashboard widget in a third workspace package. its relay is platform code that imports the music router, so deleting that router breaks the platform. it also needs six external binaries a manifest cannot declare, ships a non-TS asset, and is the worked example the capability tests are written through. recorded before starting rather than discovered halfway, because a half-extracted music is worse than an unextracted one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
191 lines
10 KiB
Markdown
191 lines
10 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 not a bigger offscale. It does not fit the shape, and three of its parts have nowhere to go.**
|
|
Mapped 2026-08-15. Decide what to do about each *before* moving a file, or the extraction strands
|
|
half-done.
|
|
|
|
### What has no home yet
|
|
|
|
1. **Two websocket providers.** `cliamp` and `cliamp-audio`, upgraded at `/api/cliamp/ws` and
|
|
`/api/cliamp/audio/ws` in `server.tsx` — the routes are **still live** while their handlers are
|
|
commented out, so they currently upgrade into nothing. **No plugin can own a socket**:
|
|
`server.reload({ routes })` is proven and never called. Worse, the relay itself
|
|
(`src/servers/api/cliamp/relay.ts`, 110 lines) is *platform* code that imports `getMusicServerWsUrl`
|
|
from the music router — so deleting that router breaks the platform.
|
|
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.** `src/workspaces/widgets/MusicPlayer/` in a *third* workspace package, wired
|
|
through `WidgetRegistry`. Plugins cannot contribute widgets.
|
|
|
|
### What else differs
|
|
|
|
- **External binaries**: `cliamp`, `ffmpeg`, `ffprobe`, `parec`, `pulseaudio`, `pactl`. `pulse-audio.ts`
|
|
starts a daemon and loads a null sink at boot. A manifest has no way to declare a host dependency.
|
|
- **A non-TS asset**: `sidecar/music/asoundrc`, loaded via `import.meta.dir`. It must travel.
|
|
- **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**.
|
|
|
|
### Suggested order
|
|
|
|
**Do not** invent the visibility model during the move. Extract with the semantics it already has, or the
|
|
two changes tangle and a bug in either is a bug in both.
|
|
|
|
1. Decide the three homeless parts first — close the platform gaps, or leave those pieces behind and say
|
|
so explicitly in the manifest. Either is defensible; drifting is not.
|
|
2. `assertCapabilityTotality` should be fixed **before** this one: the cliamp routes are the live example
|
|
of the exact drift it cannot currently see.
|
|
3. Then the move, in the order above.
|
|
|
|
---
|
|
|
|
## 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.
|