example leaves too; plugins/ is documentation now

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>
This commit is contained in:
2026-08-15 18:21:51 +00:00
co-authored by Claude Opus 5
parent cca85a7524
commit dd26e4a688
14 changed files with 104 additions and 172 deletions
+7 -12
View File
@@ -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/
+36 -7
View File
@@ -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/<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` — 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.
-10
View File
@@ -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 }));
-35
View File
@@ -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',
},
],
};
-24
View File
@@ -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'));
-23
View File
@@ -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 `<prefix>` and `<prefix>/: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 (
<div className="h-full overflow-auto p-6">
<h2 className="text-lg font-semibold text-duck-dark">Detail</h2>
<p className="mt-1 text-sm text-duck-dark/60">
Section from the URL: <code>{section ?? '(none)'}</code>
</p>
<p className="mt-3 text-xs text-duck-dark/40">
Try <code>/example/anything</code> this panel reads it from <code>useParams</code>, with no state passed from
the panel beside it.
</p>
</div>
);
};
-27
View File
@@ -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 (
<div className="h-full overflow-auto p-6">
<h2 className="text-lg font-semibold text-duck-dark">Example</h2>
<p className="mt-1 text-sm text-duck-dark/60">
A panel from <code>plugins/example/web/</code>, rendered by the shell's <code>WorkspaceView</code>.
</p>
<div className="mt-4 rounded-md border border-duck-dark/10 bg-duck-dark/[0.02] p-3 font-mono text-xs">
<div className="mb-1 text-duck-dark/50">GET /api/example/ping</div>
{isLoading ? <span className="text-duck-dark/40"></span> : <span>{JSON.stringify(data)}</span>}
</div>
</div>
);
};
-16
View File
@@ -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 },
],
};
-16
View File
@@ -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 },
];
+2
View File
@@ -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,
+40
View File
@@ -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 },
},
];
/**
@@ -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 <img src={plugin.image} alt="" className="h-10 w-10 shrink-0 object-contain" />;
const Glyph = resolveIcon(plugin.icon ?? 'Box');
return <Glyph className="h-9 w-9 shrink-0" style={{ color: plugin.color }} />;
};
/**
* A marketplace entry, before this machine has the code.
*
@@ -183,7 +191,7 @@ const AvailableDetail = ({
}) => (
<div className="h-full overflow-auto p-6">
<div className="flex items-center gap-3">
{plugin.image ? <img src={plugin.image} alt="" className="h-10 w-10 shrink-0 object-contain" /> : null}
<AvailableGlyph plugin={plugin} />
<div className="min-w-0">
<h2 className="text-lg font-semibold text-duck-dark">{plugin.label}</h2>
<p className="mt-0.5 text-sm text-duck-dark/60">{plugin.summary}</p>
@@ -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 ? (
<img src={plugin.image} alt="" className="h-5 w-5 shrink-0 object-contain" />
) : (
<Puzzle className="h-4 w-4 shrink-0" style={{ color: plugin.color }} />
(() => {
const Glyph = plugin.icon ? resolveIcon(plugin.icon) : Puzzle;
return <Glyph className="h-4 w-4 shrink-0" style={{ color: plugin.color }} />;
})()
)}
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium text-duck-dark">{plugin.label}</div>
@@ -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;