it previously said 'decide before moving a file, and tell me the decision', which turns an overnight run into a blocked one. every open call here has two defensible answers, a corrected decision is cheap, and a stalled extraction is not. adds a default for each of the three homeless parts — close the platform gap when there is runway, because every later plugin needs it too, but a working music with cliamp left in the platform beats a perfect design that did not land. the bar at the end is unchanged: install, enable, disable, uninstall cleanly. a piece left in the platform is a documented seam; a piece left dangling is a bug. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
206 lines
11 KiB
Markdown
206 lines
11 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 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.** `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**.
|
|
|
|
### 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 | wire `server.reload({ routes })` — already proven, never called — so a manifest can declare ws providers | leave `cliamp`/`cliamp-audio` and the relay in the platform, note it in the manifest |
|
|
| global overlay | add one slot the shell renders from installed plugins | leave `MusicPlayerHost` in `DashboardLayout`, gated on the permission as it already is |
|
|
| widget | let a plugin contribute `widgetRegistryMetas` | leave `widgets/MusicPlayer` where it is |
|
|
|
|
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 worth fixing on the way past if you touch the websocket path — the cliamp
|
|
routes are the live example of the drift it cannot currently see (routes upgrading into commented-out
|
|
handlers). Not required.
|
|
|
|
**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.
|