readme
This commit is contained in:
@@ -0,0 +1,290 @@
|
||||
# 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.
|
||||
Reference in New Issue
Block a user