Files
2026-08-16 04:09:04 +00:00

291 lines
18 KiB
Markdown

# Example
The reference Officer plugin. One API route, two panels, a sidecar that does nothing but stay alive —
deliberately the smallest thing that is still a real plugin, so that every part of it is there to be
copied rather than read around.
It is not a fixture. It clones from the marketplace, installs, mounts, appears on the dock and answers
`GET /api/example/ping` like anything else. The only part it leaves out is `db/`, because tables are the
one piece that is easier to add than to strip back out.
If you are about to write a plugin, read this file and then read the directory. The comments in
`manifest.ts`, `api/router.ts`, `web/panels.ts` and `web/layout.ts` say why each convention is what it is;
this file says what the conventions are.
## What it ships
| Part | Present | What it demonstrates |
| ----------------------- | ------- | ----------------------------------------------------------------------- |
| `manifest.ts` | yes | the required file — identity, presentation, permissions |
| `api/router.ts` | yes | one route, mounted at `/api/example` |
| `web/` | yes | two panels and a layout, arranged into a Workspace by the shell |
| `sidecar/index.ts` | yes | a PM2 process named `officer-example` |
| `db/schema.ts` | no | omitted on purpose — see above |
| `assets/icon.png` | no | omitted, so the dock falls back to the manifest's lucide glyph |
## The anatomy of a plugin
A plugin is a directory at `plugins/<app-name>/` inside the platform repository. Discovery is by
convention and **presence is the declaration**: there is no field anywhere saying "this plugin has a
sidecar", because the file either exists or it does not.
```
plugins/example/
├── manifest.ts REQUIRED. Must export `manifest`. Everything a directory listing cannot say.
├── api/router.ts There is a backend. Must export `router`, a Hono router. Mounted at the prefix.
├── db/schema.ts There are tables. Pushed on install. Every name prefixed `example_`.
├── sidecar/index.ts There is a process. Run under bun, as PM2's `officer-example`.
├── sidecar/index.mjs The same, run under node. The extension IS the runtime; `.ts` wins if both exist.
├── web/ There is a frontend. Its presence REQUIRES the two files below.
│ ├── panels.ts REQUIRED with web/. Exports `appRegistryMetas` — at least one panel.
│ ├── layout.ts REQUIRED with web/. Exports `defaultLayout` — how those panels are arranged.
│ └── *.tsx The panel components themselves. No naming rule; nothing reads these directly.
└── assets/icon.png The dock tile's artwork. Copied to `public/plugins/example/` on install.
```
Everything except `manifest.ts` is optional. A directory whose name starts with `.` or `_` is not a
plugin at all, which is what makes `_scratch/` a usable scratch space next to real ones.
Four things follow from that tree and are worth stating plainly, because each is a rule you can only
break silently:
- **`appName` is the directory name.** It is not declared anywhere. It is the route segment, the table
prefix, the PM2 process suffix and the install key, all at once — taken from the directory so the id
cannot disagree with where the code sits. It must match `^[a-z][a-z0-9-]{0,38}$`: it has to work as a
URL segment, a SQL identifier prefix and a directory name simultaneously. Renaming the directory
re-identifies the plugin.
- **`web/` is all or nothing.** A `web/` directory missing either `panels.ts` or `layout.ts` makes the
plugin broken rather than partially built. This is how "every plugin route renders a Workspace with at
least one panel" is enforced by shape instead of by review — the plugin never renders a screen, it
hands over panels and an arrangement, and the shell renders `WorkspaceView` around them. There is
nowhere to put anything else.
- **The sidecar's runtime is its file extension.** `sidecar/index.ts` runs under bun, `sidecar/index.mjs`
under node. Implicit, but it is the rule the platform already follows for its own processes, and it
beats a manifest field that can contradict the file it describes.
- **A broken plugin is reported, not thrown.** One unreadable manifest does not stop the platform booting
or hide the plugins beside it. It shows up on the plugins page with the reason.
### Where it mounts
`publisher` is the only input to the prefix:
| Publisher | API | Frontend route |
| -------------- | ---------------------- | -------------- |
| `officerdev` | `/api/example` | `/example` |
| anything else | `/api/p/<pub>/example` | `/p/<pub>/example` |
Third-party plugins live under `/p/<publisher>/` so that no plugin can shadow a core route — which is
what lets the platform keep adding core routes forever without breaking an install. First-party ones sit
at the root because Officer Dev owns that namespace anyway. Nothing else in the platform branches on
provenance; if it did, first-party and third-party would be two systems and only one of them would get
tested.
The frontend gets a route **pair**: `<prefix>` and `<prefix>/:section`, exactly as the core screens do.
Panels read `useParams` themselves, so which section is open lives in the URL rather than in state passed
between them. `web/ExampleDetail.tsx` is there to show that and nothing else — try `/example/anything`.
## Writing your own
1. **Clone this repository and rename the directory.** The new name is your `appName`; everything else
derives from it.
```
git clone https://gitea.officer.dev/plugins/example.git ~/officer/platform/plugins/weather
cd ~/officer/platform/plugins/weather && rm -rf .git && git init
```
It has to live inside the platform repository. That is load-bearing rather than tidy: Bun resolves
`hooks/useClient`, `officerdev`, `officerdb` and the `@@/*` alias through the platform's workspace
links, and resolution walks up from the importing file. A plugin cloned beside the platform can import
nothing the platform provides.
2. **Edit `manifest.ts`.** New `label`, `summary`, `color`, and a permission key that starts with your
app name. Leave `publisher` as `officerdev` unless you have your own.
3. **Rename what the manifest does not cover.** The PM2 process name comes from the directory, but the
string inside `sidecar/index.ts`, the panel keys in `web/panels.ts`, the `appType`s and ids in
`web/layout.ts`, and the component names are all yours to change. Every `appType` in `layout.ts` must
be a `key` from `panels.ts` — the screen pins the allowed types to your plugin's own panels, so a
mismatch silently falls back to the first one instead of rendering what you meant.
4. **Delete what you do not need.** No frontend? Remove `web/` entirely — but do not remove only one of
`panels.ts` or `layout.ts`. No process? Remove `sidecar/`. Both are absences, not settings.
5. **Add `db/schema.ts` if you need tables**, prefixing every table name with `<appName>_`.
6. **`bunx tsgo`**, then install it from `/plugins`.
## The manifest
```ts
import type { PluginManifest } from '@@/plugins/manifest';
export const manifest: PluginManifest = { … };
```
| Field | Required | What it does |
| --------------- | -------- | --------------------------------------------------------------------------------------- |
| `publisher` | yes | Who published it. The only input to the mount prefix. Lowercase letters, digits, dashes. |
| `version` | yes | The plugin's own semver. Updates compare against this. |
| `platform` | yes | Which platform versions this build is good for. Refused at install when it does not match. |
| `label` | yes | The dock tile's name, and how the plugin is titled in the marketplace. |
| `summary` | yes | One line, shown in the marketplace before anyone installs it. |
| `color` | yes | Accent colour: tile background, active glow, indicator dot, tint on the plugins list. Required even when the tile draws no background, because the other three still use it. |
| `icon` | no | A lucide icon name. The **fallback**, used only when the plugin ships no `assets/icon.png`. |
| `tile` | no | `badge` (a coloured square with the icon inset) or `bare` (artwork fills the tile). Defaulted from whether `assets/icon.png` exists — artwork gets `bare`, a glyph gets `badge`. Only set it to override that. |
| `permissions` | yes | See below. `[]` is valid and means the plugin gates nothing. |
| `osDependencies`| no | Host binaries the plugin shells out to. Absent means self-sufficient, which `example` is. |
There is deliberately no `sidecar`, `schema`, `frontend`, `dock` or `title` field. Those are all either
declared by a file's presence or already derivable — the dock tile is exactly
`{ label, icon, color, route: prefix }`. Writing them twice could only ever drift.
A manifest is validated on discovery and reports **every** problem at once rather than the first, because
a manifest fixed one field per attempt is a manifest nobody finishes.
### `osDependencies`
```ts
osDependencies: [
{
binary: 'ffprobe',
reason: 'Reads tags and duration. Without it every track indexes as a bare filename.',
packages: { apt: 'ffmpeg', pacman: 'ffmpeg', dnf: 'ffmpeg', brew: 'ffmpeg' },
},
];
```
The **binary** is probed on `PATH`, not the package — so a manager where the tool is built in simply has
no entry, and a present binary means the package names are never looked at. Package names are per manager
rather than canonical-with-overrides because the alternative was tried and hides real differences.
`reason` is required: it is what the owner reads when deciding whether to let this machine install
something as root, and a package name alone does not answer that question.
These two strings are the only values in a manifest that reach a root command line, so they are validated
more narrowly than any package manager would accept — no `+`, no `~`, no `:`, no version pins. A plugin
that needs one of those wants a conversation, not a wider regex.
Missing dependencies are checked **first** on install, before anything on the machine changes, and refuse
the install when they cannot be satisfied. The failure being guarded against is not a crash: it is a
plugin that runs, answers 200 and quietly produces nothing.
## Permissions
```ts
permissions: [
{
key: 'example',
label: 'Example',
description: 'The reference plugin',
},
];
```
- **The key shares the platform's permission namespace**, so prefix it with your app name. A plugin
declaring `chat` or `terminal` would be redefining a core permission — and widening or narrowing one is
a security change made by accident. Keys colliding with a core one are refused at mount and logged;
the plugin is left with no permission of that name rather than with somebody else's. One key per
surface, prefixed, is the only way to be sure you are not taking something.
- **The first permission gates the plugin's whole surface** — it claims the mount prefix, on the API and
in the router. Later entries are registered as grantable keys that claim no path, which is useful for
something the plugin checks itself and misleading if you expected them to gate sub-paths. They do not,
yet.
- **Renaming a key is a data change.** It is stored as the subject of every grant.
- **There is no `ownerOnly`, and no kind of any sort.** Every plugin permission is registered as kind
`app`: grantable per role, at read or write, on the same page as the platform's own. A plugin cannot
name a restricted kind because its manifest has no field for one — the escalation question is removed
rather than answered. A plugin that wanted to be owner-only would be a plugin the owner cannot
delegate, and "which members may reach this" belongs to whoever runs the server, not to whoever wrote
the code.
- **`readOnlyWrites`** lists paths that look like writes and are not — a `POST /ssh-test` that probes, a
`POST /assist` that proposes and never saves. Without declaring them, an account holding the permission
at `read` meets what reads as a broken feature where a withheld permission should be.
- **Finer visibility is the plugin's own business.** Whose rows a member sees, and what "read" means for
your data, lives in your queries. The platform's answer is uniform: read, write, or nothing.
## What a plugin may and may not do
**May:**
- Mount a Hono router under its prefix, built with `createRouter()` so the platform's context types and
middleware behave exactly as they do for core routes — `ctx.get('user')` is typed and populated.
- Import anything the platform's workspaces expose, by package name: `hooks`, `officerdev`, `officerdb`,
`helpers`, `types`, and the `@@/*` alias for server code. This is the whole plugin-to-host API. There is
no publishing step and no version negotiation.
- Own tables in the platform's Postgres database, created by `bun db:push` on install.
- Run a long-lived process, supervised by PM2 as `officer-<appName>`.
- Contribute panels to its own screen, and a tile to the dock.
- Cause host packages to be installed, through `osDependencies` and only with the owner's consent.
**May not:**
- **Render a screen.** Panels and a layout, or no frontend.
- **Mount outside its prefix.** The prefix is computed from the manifest; a router cannot choose it.
- **Choose a permission kind, or take a core permission key.** Always `app`, always grantable, never a
core key.
- **Be installed by anyone but the owner.** `/api/plugins` refuses every non-owner: installing a plugin
mounts routes and starts a process, which is process control rather than a feature to grant a read of.
The permission that gates a plugin's *use* is separate — a member can hold `example` at read without
being able to install or remove anything.
**And two things the platform will not do to a plugin:**
- **Uninstall never drops a table or a row.** Uninstall means "stop running this", and for a plugin
holding a user's data the two are unrecoverably different. Reinstalling is a restore. The generated
schema barrel follows plugin *directories* rather than the install table precisely so that this holds —
which also means deleting a plugin's source and then running `bun db:push` **will** drop its tables.
That is the only path that loses data, and it is deliberate that it requires deleting the code.
- **Uninstall never removes a host package.** A package is a machine-wide resource that other things may
have started depending on the moment it appeared.
There is **no sandbox**. A plugin's router runs inside the platform's process and its sidecar runs as a
PM2 peer under the same OS user, which on a normal install is the owner. Installing a third-party plugin
is trusting its code with everything the owner has. The permission system decides which *accounts* reach
a plugin; it does not constrain the plugin itself.
## Installing
### From the marketplace
The plugins page lists what the catalogue offers alongside what is already on this machine. Installing
something not yet present clones it first — shallow, over anonymous https, into `plugins/<appName>/` —
then re-reads the manifest from disk. The catalogue *describes* a plugin; the manifest on disk is what
the platform obeys, and a catalogue that disagrees with it is a catalogue bug rather than a second source
of truth.
A clone is refused when the directory already exists, because that directory may be a plugin somebody is
developing and `git clone` over the top of local work is a loss no undo covers.
### For development
Put the directory in `plugins/` yourself. Being on disk and being installed are different states: a
plugin you are writing sits in the tree, appears on `/plugins`, and mounts nothing until you install it
from that page. Install then skips the clone and proceeds with what is there.
```
cd ~/officer/platform/plugins
git clone https://gitea.officer.dev/plugins/example.git
pm2 restart officer
```
Three things to know before you lose time to them:
- **A manifest is read once per server process.** Discovery does a dynamic import and the module cache
holds it for the life of the process, so editing `manifest.ts` changes nothing until
`pm2 restart officer` — while the plugins page keeps cheerfully showing the old values.
- **Install, enable and disable rebuild the SPA** (about a second) and rewrite the routes in place. You
do not need to restart the platform for a code change to a panel or a route, only for a manifest
change. A normal refresh picks up the new bundle.
- **`plugins/*/` is gitignored by the platform.** A cloned plugin carries its own `.git`, and tracking it
would turn `git add -A` into a commit of a pointer to a commit the platform's repository does not have.
Your plugin is its own repository with its own remote.
## Further reading
- `plugins/offscale/PLUGIN.md` — the worked example. Every decision in the plugin system, with the
reasoning, kept next to the plugin it produced.
- `plugins/EXTRACTING-A-PLUGIN.md` — the runbook for pulling an existing feature out of the platform.
- `src/servers/plugins/` in the platform — the system itself. `discover.ts` is the authority on the file
tree above, `manifest.ts` on the fields, `install.ts` on what installing actually does and in what
order.