diff --git a/.gitignore b/.gitignore index 498d9576..57919df2 100644 --- a/.gitignore +++ b/.gitignore @@ -94,18 +94,13 @@ src/databases/officer_db/src/plugin-schemas.gen.ts # Generate one locally whenever a diff is useful to read. It stays local. src/databases/officer_db/migrations/ -# ── Marketplace-installed plugins ── +# ── plugins/ holds documentation, not plugins ── # -# A plugin fetched from the marketplace is a CLONE with its own .git and its own repository. Left -# tracked, `git add -A` after an install commits it as a gitlink — a pointer to a commit this repo does -# not contain, which then breaks anyone who clones the platform and does not have access to that plugin. -# Happened on 2026-08-15, one commit after the marketplace landed. +# Every plugin is its own repository (gitea.officer.dev/plugins/*) and arrives here by `git clone` when +# somebody installs it. A cloned plugin carries its own .git, so leaving it tracked means `git add -A` +# commits a gitlink — a pointer to a commit this repo does not contain. That happened on 2026-08-15. # -# The plugins that genuinely ship with the platform are re-included by name below. Adding one is a -# deliberate act, so keeping this list by hand is the right amount of friction — an unlisted plugin -# directory is an install, not a source file. +# The trailing slash matters: this ignores DIRECTORIES only, so files at the top of plugins/ — +# EXTRACTING-A-PLUGIN.md and anything beside it — stay tracked. The documentation about plugins belongs +# to the platform; the plugins do not. /plugins/*/ -# `example` is the only exception left, and only until it has a repository of its own. It is the -# reference implementation EXTRACTING-A-PLUGIN.md sends people to read, and it has no remote — the -# platform repo is its single copy, so untracking it would delete it from everywhere but this disk. -!/plugins/example/ diff --git a/plugins/EXTRACTING-A-PLUGIN.md b/plugins/EXTRACTING-A-PLUGIN.md index 02613c16..cc133a60 100644 --- a/plugins/EXTRACTING-A-PLUGIN.md +++ b/plugins/EXTRACTING-A-PLUGIN.md @@ -2,14 +2,24 @@ 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//` 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` — 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. `plugins/music/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` +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. --- @@ -138,7 +148,8 @@ A normal refresh is enough; the shell is `no-store`. When the log's last line ap ## 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` is the +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 @@ -192,3 +203,21 @@ No decision has been made. What the tree says, for whoever picks it: 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. diff --git a/plugins/example/api/router.ts b/plugins/example/api/router.ts deleted file mode 100644 index 9a7dfded..00000000 --- a/plugins/example/api/router.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { createRouter } from '@@/create-router'; - -// Mounted at `/api/example` — the prefix comes from `mountPrefix()`, which reads the manifest's -// `publisher`. Nothing here knows or cares whether this plugin is first-party. -// -// `createRouter()` rather than a bare `new Hono()`: it carries the platform's context types, so -// `ctx.get('user')` is typed and the middleware above behaves the same as it does for core routes. -export const router = createRouter(); - -router.get('/ping', (ctx) => ctx.json({ plugin: 'example', ok: true })); diff --git a/plugins/example/manifest.ts b/plugins/example/manifest.ts deleted file mode 100644 index 26ded1eb..00000000 --- a/plugins/example/manifest.ts +++ /dev/null @@ -1,35 +0,0 @@ -import type { PluginManifest } from '@@/plugins/manifest'; - -// The reference plugin. Not a fixture — this is what a plugin author reads first, and it is deliberately -// the smallest thing that is still a real one: a manifest and one route. -// -// Everything structural is convention, so this directory IS the documentation: -// -// manifest.ts you are here — only what a directory listing cannot say -// api/router.ts exports `router`; mounted at /api/example -// db/schema.ts tables, if it had any (every name prefixed `example_`) -// sidecar/index.ts a process, if it needed one (.mjs instead means node) -// web/Router.tsx a frontend, if it had one -// -// `appName` is not declared anywhere: it is the directory name, so the id cannot disagree with where the -// code sits. -export const manifest: PluginManifest = { - publisher: 'officerdev', - version: '1.0.0', - platform: '>=1.0.0', - - label: 'Example', - summary: 'The reference plugin — one route, nothing else', - icon: 'Puzzle', - color: '#94a3b8', - - // One permission gating the whole surface. `ownerOnly: false` means a role can be granted it — which is - // the interesting case, because it is the one the permission gate actually has to resolve. - permissions: [ - { - key: 'example', - label: 'Example', - description: 'The reference plugin', - }, - ], -}; diff --git a/plugins/example/sidecar/index.ts b/plugins/example/sidecar/index.ts deleted file mode 100644 index a92d3db1..00000000 --- a/plugins/example/sidecar/index.ts +++ /dev/null @@ -1,24 +0,0 @@ -// The reference sidecar: a long-lived process PM2 supervises. -// -// A sidecar is a PEER of `officer`, never a child — that is why restarting the platform does not disturb -// it, and it is the property that makes install-without-restart possible on the platform side too. -// -// A real one binds a loopback port and registers over `/api/sidecar/register` so the platform can reach -// it by permission (see `servers/sidecar/connect.ts`). This one does neither, on purpose: it exists to -// prove that a plugin's process is written into the ecosystem file, started, stopped and deleted by the -// installer, and adding a socket here would test Bun rather than that. - -const name = 'officer-example'; -console.log(`[${name}] started (pid ${process.pid})`); - -// Something to see in `pm2 logs officer-example`, and a reason for the process to still be alive. -const beat = setInterval(() => console.log(`[${name}] alive`), 60_000); - -const shutdown = (signal: string) => { - console.log(`[${name}] ${signal} — exiting`); - clearInterval(beat); - process.exit(0); -}; - -process.on('SIGTERM', () => shutdown('SIGTERM')); -process.on('SIGINT', () => shutdown('SIGINT')); diff --git a/plugins/example/web/ExampleDetail.tsx b/plugins/example/web/ExampleDetail.tsx deleted file mode 100644 index 366b5905..00000000 --- a/plugins/example/web/ExampleDetail.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import { useParams } from 'react-router'; - -// The second panel, reading the URL rather than being told by its sibling. -// -// The shell registers `` and `/:section`, so a plugin's sections are addressable, -// linkable and cmd-clickable — the same convention every core screen follows. Panels read `useParams` -// independently; nothing is passed between them, so they cannot disagree. -export const ExampleDetail = () => { - const { section } = useParams(); - - return ( -
-

Detail

-

- Section from the URL: {section ?? '(none)'} -

-

- Try /example/anything — this panel reads it from useParams, with no state passed from - the panel beside it. -

-
- ); -}; diff --git a/plugins/example/web/ExampleOverview.tsx b/plugins/example/web/ExampleOverview.tsx deleted file mode 100644 index 0975c7ca..00000000 --- a/plugins/example/web/ExampleOverview.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import { useClient } from 'hooks/useClient'; -import { useQuery } from '@tanstack/react-query'; - -// A panel, not a screen. It gets whatever space the layout gives it and knows nothing about routing. -// -// `useClient` comes from the platform's workspace packages, resolved because a plugin lives inside the -// repository — no publishing, no version negotiation. This is the whole plugin↔host API in one line. -export const ExampleOverview = () => { - const client = useClient(); - const { data, isLoading } = useQuery({ - queryKey: ['example', 'ping'], - queryFn: () => client.get<{ plugin: string; ok: boolean }>('/example/ping'), - }); - - return ( -
-

Example

-

- A panel from plugins/example/web/, rendered by the shell's WorkspaceView. -

-
-
GET /api/example/ping
- {isLoading ? : {JSON.stringify(data)}} -
-
- ); -}; diff --git a/plugins/example/web/layout.ts b/plugins/example/web/layout.ts deleted file mode 100644 index 8cb98846..00000000 --- a/plugins/example/web/layout.ts +++ /dev/null @@ -1,16 +0,0 @@ -import type { LayoutNode } from 'officerdev'; - -// How this plugin's panels are arranged. The shell renders `WorkspaceView` with this as the default and -// persists the user's version per plugin, so this is the starting arrangement rather than a fixed one. -// -// Every `appType` here must be a key from `panels.ts` — `appTypes.allowed` is pinned to them, so a -// mismatch falls back rather than rendering another plugin's panel inside this screen. -export const defaultLayout: LayoutNode = { - type: 'group', - id: 'example-root', - direction: 'horizontal', - children: [ - { node: { type: 'panel', id: 'example-overview', appType: 'example-overview' }, size: 40 }, - { node: { type: 'panel', id: 'example-detail', appType: 'example-detail' }, size: 60 }, - ], -}; diff --git a/plugins/example/web/panels.ts b/plugins/example/web/panels.ts deleted file mode 100644 index 4746a532..00000000 --- a/plugins/example/web/panels.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { Puzzle, ListTree } from 'lucide-react'; -import type { AppRegistryMeta } from 'officerdev'; -import { ExampleOverview } from './ExampleOverview'; -import { ExampleDetail } from './ExampleDetail'; - -// The panels this plugin contributes. AT LEAST ONE, or discovery refuses the plugin. -// -// A plugin never renders a screen — the shell renders `WorkspaceView` around these, arranged by -// `layout.ts`. That is what makes "every plugin route is a Workspace" a property of the shape rather than -// a rule someone has to remember. -// -// `availableOnPanel: false` keeps them off the generic panel picker: they belong to this plugin's screen. -export const appRegistryMetas: AppRegistryMeta[] = [ - { key: 'example-overview', name: 'Overview', icon: Puzzle, component: ExampleOverview, availableOnPanel: false }, - { key: 'example-detail', name: 'Detail', icon: ListTree, component: ExampleDetail, availableOnPanel: false }, -]; diff --git a/src/servers/api/plugins/router.ts b/src/servers/api/plugins/router.ts index 1cf421e6..950a01cb 100644 --- a/src/servers/api/plugins/router.ts +++ b/src/servers/api/plugins/router.ts @@ -78,6 +78,8 @@ pluginsRouter.get('/', async (ctx) => { label: e.label, summary: e.summary, image: e.iconUrl, + // Same precedence the installed list uses: artwork wins, the glyph name is the fallback. + ...(e.iconUrl ? {} : { icon: e.icon }), tile: e.tile ?? 'badge', color: e.color, publisher: e.publisher, diff --git a/src/servers/plugins/marketplace.ts b/src/servers/plugins/marketplace.ts index cdc283e8..6e74fe20 100644 --- a/src/servers/plugins/marketplace.ts +++ b/src/servers/plugins/marketplace.ts @@ -50,6 +50,12 @@ export type CatalogueEntry = { * has never seen, so the catalogue carries a link rather than a file. */ iconUrl?: string; + /** + * A lucide NAME, for a plugin that ships no artwork. Mirrors `manifest.icon`, and the same precedence + * applies: `iconUrl` wins where both are given. Without this a glyph plugin draws the generic puzzle + * piece in the store and its real icon only after install, which reads as the icon having changed. + */ + icon?: string; /** * Where to clone from. HTTPS, never SSH: a marketplace serves the same URL to every machine, and an * `ssh://` clone assumes a key on that host for that user — true on the machine that published the @@ -110,6 +116,40 @@ const CATALOGUE: CatalogueEntry[] = [ ], has: { api: true, schema: true, sidecar: true, web: true }, }, + { + appName: 'offscale', + publisher: 'officerdev', + version: '1.0.0', + platform: '>=1.0.0', + label: 'Offscale', + summary: 'Your tailnet — machines, users, pre-auth keys, access policy and device invites', + color: '#818cf8', + icon: 'Network', + source: 'https://gitea.officer.dev/plugins/offscale.git', + permissions: [ + { + key: 'offscale', + label: 'Offscale', + description: 'The tailnet: machines, routes, keys and ACLs', + readOnlyWrites: ['/ssh-test', '/policy/assist'], + }, + ], + has: { api: true, schema: true, sidecar: true, web: true }, + }, + { + appName: 'example', + publisher: 'officerdev', + version: '1.0.0', + platform: '>=1.0.0', + label: 'Example', + summary: 'The reference plugin — one route, nothing else', + color: '#94a3b8', + icon: 'Puzzle', + source: 'https://gitea.officer.dev/plugins/example.git', + permissions: [{ key: 'example', label: 'Example', description: 'The reference plugin' }], + // No `db/` — the one part it deliberately omits, so the smallest real plugin stays the smallest. + has: { api: true, schema: false, sidecar: true, web: true }, + }, ]; /** diff --git a/src/workspaces/officerdev/src/apps/Plugins/PluginDetail.tsx b/src/workspaces/officerdev/src/apps/Plugins/PluginDetail.tsx index 4b49a491..785283a7 100644 --- a/src/workspaces/officerdev/src/apps/Plugins/PluginDetail.tsx +++ b/src/workspaces/officerdev/src/apps/Plugins/PluginDetail.tsx @@ -1,4 +1,5 @@ import { Link, useSearchParams } from 'react-router'; +import { resolveIcon } from '../../utils/resolve-icon'; import { usePlugins, type AvailablePlugin, @@ -158,6 +159,13 @@ const ActionLog = ({ ); }; +/** Artwork if the catalogue has a URL, else the plugin's own lucide glyph. */ +const AvailableGlyph = ({ plugin }: { plugin: AvailablePlugin }) => { + if (plugin.image) return ; + const Glyph = resolveIcon(plugin.icon ?? 'Box'); + return ; +}; + /** * A marketplace entry, before this machine has the code. * @@ -183,7 +191,7 @@ const AvailableDetail = ({ }) => (
- {plugin.image ? : null} +

{plugin.label}

{plugin.summary}

diff --git a/src/workspaces/officerdev/src/apps/Plugins/PluginsList.tsx b/src/workspaces/officerdev/src/apps/Plugins/PluginsList.tsx index ba9ae6da..42c85bcf 100644 --- a/src/workspaces/officerdev/src/apps/Plugins/PluginsList.tsx +++ b/src/workspaces/officerdev/src/apps/Plugins/PluginsList.tsx @@ -1,6 +1,7 @@ import { Link, useSearchParams } from 'react-router'; import { Puzzle, AlertTriangle } from 'lucide-react'; import { usePlugins, type PluginItem } from './usePlugins'; +import { resolveIcon } from '../../utils/resolve-icon'; // The left panel: every plugin in the tree, installed or not. // @@ -40,10 +41,16 @@ export const PluginsList = () => { > {/* The plugin's own artwork when it ships some and is installed; otherwise the generic glyph this drew for everything before plugins could carry an icon. */} + {/* Artwork, then the plugin's own glyph, then a generic one. The middle step matters: without + it a glyph plugin shows a puzzle piece in the store and its real icon after install, which + reads as the icon having changed rather than as the store not knowing it. */} {plugin.image ? ( ) : ( - + (() => { + const Glyph = plugin.icon ? resolveIcon(plugin.icon) : Puzzle; + return ; + })() )}
{plugin.label}
diff --git a/src/workspaces/officerdev/src/apps/Plugins/usePlugins.ts b/src/workspaces/officerdev/src/apps/Plugins/usePlugins.ts index 7ceb0397..014b2262 100644 --- a/src/workspaces/officerdev/src/apps/Plugins/usePlugins.ts +++ b/src/workspaces/officerdev/src/apps/Plugins/usePlugins.ts @@ -94,6 +94,8 @@ export type AvailablePlugin = { summary: string; /** An absolute URL to the marketplace's copy — there is no local asset until it is installed. */ image?: string; + /** A lucide NAME, for a catalogue entry that ships no artwork. Only when `image` is absent. */ + icon?: string; tile: 'badge' | 'bare'; color: string; publisher: string;