diff --git a/plugins/EXTRACTING-A-PLUGIN.md b/plugins/EXTRACTING-A-PLUGIN.md new file mode 100644 index 00000000..03fbde06 --- /dev/null +++ b/plugins/EXTRACTING-A-PLUGIN.md @@ -0,0 +1,190 @@ +# 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 _ +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//sidecar/`, `api//router.ts` → + `plugins//api/router.ts` (export `router`, not `Router`), `officer_db/src//*` → + `plugins//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 + `: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 `: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.