Done with `tea` as admin: plugins/offscale made public, plugins/example created public, both verified anonymously over https — which is the check that matters, because fetch.ts clones with no credentials and a private plugin simply cannot be installed from the marketplace. plugins/example 8 files, cloned back and diffed identical plugins/offscale now public (was private, so was uninstallable) `.gitignore` is one line now — `/plugins/*/`. The trailing slash is the whole design: it ignores DIRECTORIES, so files at the top of plugins/ stay tracked. The documentation about plugins belongs to the platform; the plugins do not. All three are in the marketplace catalogue, which is what makes this reversible: before this, untracking offscale meant a fresh install could never get it back. Now the store is the way in for all three. CatalogueEntry gains `icon` (a lucide name) alongside `iconUrl`. Both new entries ship glyphs rather than artwork, and without it they drew the generic puzzle piece in the store and their real icon after install — which reads as the icon changing rather than the store not knowing it. EXTRACTING-A-PLUGIN.md pointed at four directories a fresh checkout no longer has. It now gives repository URLs, says up front that this directory holds documentation rather than plugins, and carries a table of where each one went. Checked first that no tracked source statically imports a plugin directory — none does, so a fresh clone still builds. tsgo clean, frontend builds, 787 pass / 7 fail (unchanged). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
224 lines
14 KiB
Markdown
224 lines
14 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.
|
|
|
|
**This directory holds documentation, not plugins.** Every plugin is its own repository as of
|
|
2026-08-15, and arrives in `plugins/<name>/` by `git clone` when somebody installs it — so on a fresh
|
|
checkout the four references below are URLs, and on a machine where they are installed they are also
|
|
directories. Both are given.
|
|
|
|
**Read first, in this order:**
|
|
|
|
1. [`plugins/offscale` → `PLUGIN.md`](https://gitea.officer.dev/plugins/offscale/src/branch/main/PLUGIN.md)
|
|
— every decision and why, including the three that reversed
|
|
2. [`plugins/example`](https://gitea.officer.dev/plugins/example) — the reference implementation,
|
|
deliberately the smallest thing that is still a real plugin
|
|
3. [`plugins/offscale`](https://gitea.officer.dev/plugins/offscale) — the worked example, all four parts
|
|
4. [`plugins/music` → `PLUGIN.md`](https://gitea.officer.dev/plugins/music/src/branch/main/PLUGIN.md)
|
|
— the MESSY worked example: three pieces that stayed behind, and why each is a seam rather than a
|
|
loose end. Read it if your feature has anything the platform also uses.
|
|
5. `src/servers/plugins/` — the system itself: `manifest`, `discover`, `mount`, `install`, `ecosystem`,
|
|
`schema`, `generate`. This one IS in this repository: the platform owns the plugin system, and only
|
|
the plugins left.
|
|
|
|
---
|
|
|
|
## 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/
|
|
```
|
|
|
|
**A host binary is the one exception, and it goes in the manifest** — the tree cannot say it. Declare
|
|
`osDependencies` when your plugin shells out to something: the binary to probe on PATH, why it is needed,
|
|
and a package name per package manager. Absent means self-sufficient, which offscale and example are.
|
|
Music added the field; see its PLUGIN.md for what it is guarding against.
|
|
|
|
`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 `permissions/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.
|
|
- **Delete the feature's `app-store/catalogue.ts` entry, or its screen goes blank.** `permissionAvailability`
|
|
derives from `sidecar_installs`, and a plugin never gets a row there — its install state is
|
|
`plugin_installs`. A leftover catalogue entry therefore makes the permission permanently `unavailable`,
|
|
which puts its route into `deniedRoutes` and withholds the dock tile, on a server where the plugin is
|
|
installed and healthy. This has now bitten twice: headscale (2026-08-14) and nearly music. The note in
|
|
`catalogue.ts` is the one to read.
|
|
- **Moving a `*.test.ts` into `plugins/` used to stop it running, silently.** `[test] root` was `./src`
|
|
until music; it is now `.`. If that ever goes back, every extraction quietly shrinks the suite. Compare
|
|
the FILE COUNT across a run, not just pass/fail — that is the only thing that shows it.
|
|
- **A manifest is read once per server process.** Discovery does `await import(manifest.ts)`, and the
|
|
module cache holds it for the lifetime of the process — so editing a manifest while developing changes
|
|
nothing until `pm2 restart officer`. Costs ten minutes the first time, because the plugins page keeps
|
|
cheerfully showing the old values. `outdated` cannot notice a version bump without a restart either.
|
|
- **A plugin importing platform code is fine (`@@/…`); the reverse is not.** If something in `src/` imports
|
|
from your feature and cannot move — a widget, a relay — that piece stays, and the boundary goes around
|
|
it. Find those before you plan the split; they decide it for you.
|
|
|
|
---
|
|
|
|
## Music is done. What it changed about this runbook
|
|
|
|
Extracted 2026-08-15 and verified live through the whole table above.
|
|
[`plugins/music` → `PLUGIN.md`](https://gitea.officer.dev/plugins/music/src/branch/main/PLUGIN.md) is the
|
|
record; the parts worth carrying forward are already folded into the rules and traps above.
|
|
|
|
The one thing that generalises: **map what the PLATFORM still needs from your feature before you plan the
|
|
split.** Music's boundary was not chosen — it was dictated by two imports pointing the wrong way (a
|
|
dashboard widget reaching for `useMusicPlayer`, a cliamp relay reaching for `getMusicServerWsUrl`), and
|
|
both were found by reading the import graph rather than by reasoning about what music "is". Offscale had
|
|
none, so it came out whole and made the job look cleaner than it is.
|
|
|
|
The three pieces music left behind are `officerdev/src/MusicPlayer/`, `src/servers/api/music/router.ts`
|
|
and everything cliamp. Each is documented where it sits. **None of them is work waiting for you** — do
|
|
not tidy them into a plugin as a warm-up.
|
|
|
|
### The global-overlay question is answered, and the answer is no
|
|
|
|
Music was the first feature wanting to render on every route. It does not get to, and neither will the
|
|
next one: a shell slot for a plugin-provided component reopens "there is no way to export a component",
|
|
which is the rule the whole frontend contract rests on. `MusicPlayerHost` stays in `DashboardLayout`,
|
|
gated on its plugin's permission so it switches itself off with the plugin.
|
|
|
|
Reopen this only for a feature where the overlay is the whole product, and expect to argue for it.
|
|
|
|
---
|
|
|
|
## Which one next
|
|
|
|
No decision has been made. What the tree says, for whoever picks it:
|
|
|
|
- **`schema.ts` still lists eight commented plugin schemas** — email, notify, dav, photos, jellyfin,
|
|
invoiceshelf, soulseek, vault, wallet. Each line names its tables and the file that defines them, which
|
|
is exactly what its extraction needs.
|
|
- **`hono.ts` still has fifteen commented mounts.** Same list, roughly.
|
|
- **Soulseek is the interesting one**, and not because it is easy: `docs/navigation-audit.md` records its
|
|
panels making 37 raw upstream calls, which is the mistake the offscale sidecar exists to avoid. Its
|
|
extraction is a rewrite wearing a move's clothes. Say so up front rather than discovering it at 2am.
|
|
- **Email and wallet both hold credentials**, so they meet `secret-store` and `service_connections` in a
|
|
way neither of the first two did. Read `docs/secret-store.md` first.
|
|
|
|
## Still open, platform-wide. Do not rediscover these
|
|
|
|
- **Websocket providers** — `server.reload({ routes })` proven, never called. No plugin owns a socket yet;
|
|
music would have been the first and cliamp being out of scope is what let it pass.
|
|
- **`assertPermissionTotality` 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.
|
|
The live example is the two cliamp sockets: served in the route table, claimed by no permission, and
|
|
invisible to the check. Pinned by a test in `registry.test.ts` so it stays a known fact.
|
|
- **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.
|
|
- **`protocol.ts` declares `<name>:server` per sidecar.** `music:server` and `headscale:server` are both
|
|
still there for plugins that have left. Generalising the union to `` `${string}:server` `` is the fix.
|
|
- **`hasPersonalWrites` reads `c.personal` only**, so a plugin declaring the same thing through
|
|
`readOnlyWrites` reports `false`. Nothing renders it, so it is dead on the wire.
|
|
|
|
---
|
|
|
|
## Where the plugins went
|
|
|
|
| Plugin | Repository | In this repo? |
|
|
| ---------- | ------------------------------------ | ------------- |
|
|
| `example` | `gitea.officer.dev/plugins/example` | no |
|
|
| `offscale` | `gitea.officer.dev/plugins/offscale` | no |
|
|
| `music` | `gitea.officer.dev/plugins/music` | no |
|
|
|
|
All three are public and clone anonymously over https, which is what the marketplace requires — it
|
|
clones with no credentials on purpose, so a private plugin cannot be installed from it at all.
|
|
|
|
`.gitignore` ignores `/plugins/*/` — directories only, so this file and anything beside it stay tracked.
|
|
The rule exists because a cloned plugin carries its own `.git`, and a tracked one turns `git add -A` into
|
|
a commit of a gitlink: a pointer to a commit this repository does not contain. That is not a mistake to
|
|
be careful about, it is what installing a plugin does, so it is handled by rule.
|