diff --git a/README.md b/README.md new file mode 100644 index 0000000..b0e3889 --- /dev/null +++ b/README.md @@ -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//` 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//example` | `/p//example` | + +Third-party plugins live under `/p//` 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**: `` and `/: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 `_`. + +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-`. +- 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//` — +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.