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. # Generate one locally whenever a diff is useful to read. It stays local.
src/databases/officer_db/migrations/ 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 # Every plugin is its own repository (gitea.officer.dev/plugins/*) and arrives here by `git clone` when
# tracked, `git add -A` after an install commits it as a gitlink — a pointer to a commit this repo does # somebody installs it. A cloned plugin carries its own .git, so leaving it tracked means `git add -A`
# not contain, which then breaks anyone who clones the platform and does not have access to that plugin. # commits a gitlink — a pointer to a commit this repo does not contain. That happened on 2026-08-15.
# Happened on 2026-08-15, one commit after the marketplace landed.
# #
# The plugins that genuinely ship with the platform are re-included by name below. Adding one is a # The trailing slash matters: this ignores DIRECTORIES only, so files at the top of plugins/ —
# deliberate act, so keeping this list by hand is the right amount of friction — an unlisted plugin # EXTRACTING-A-PLUGIN.md and anything beside it — stay tracked. The documentation about plugins belongs
# directory is an install, not a source file. # to the platform; the plugins do not.
/plugins/*/ /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. 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:** **Read first, in this order:**
1. `plugins/offscale/PLUGIN.md` — every decision and why, including the three that reversed 1. [`plugins/offscale` → `PLUGIN.md`](https://gitea.officer.dev/plugins/offscale/src/branch/main/PLUGIN.md)
2. `plugins/example/` — the reference implementation, deliberately the smallest real plugin — every decision and why, including the three that reversed
3. `plugins/offscale/` — the worked example, all four parts 2. [`plugins/example`](https://gitea.officer.dev/plugins/example) — the reference implementation,
4. `plugins/music/PLUGIN.md` — the MESSY worked example: three pieces that stayed behind, and why each deliberately the smallest thing that is still a real plugin
is a seam rather than a loose end. Read it if your feature has anything the platform also uses. 3. [`plugins/offscale`](https://gitea.officer.dev/plugins/offscale) — the worked example, all four parts
5. `src/servers/plugins/` — the system itself: `manifest`, `discover`, `mount`, `install`, `ecosystem`, `schema`, `generate` 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 ## 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. 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 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. 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 - **`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. `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, label: e.label,
summary: e.summary, summary: e.summary,
image: e.iconUrl, 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', tile: e.tile ?? 'badge',
color: e.color, color: e.color,
publisher: e.publisher, 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. * has never seen, so the catalogue carries a link rather than a file.
*/ */
iconUrl?: string; 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 * 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 * `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 }, 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 { Link, useSearchParams } from 'react-router';
import { resolveIcon } from '../../utils/resolve-icon';
import { import {
usePlugins, usePlugins,
type AvailablePlugin, 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. * 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="h-full overflow-auto p-6">
<div className="flex items-center gap-3"> <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"> <div className="min-w-0">
<h2 className="text-lg font-semibold text-duck-dark">{plugin.label}</h2> <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> <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 { Link, useSearchParams } from 'react-router';
import { Puzzle, AlertTriangle } from 'lucide-react'; import { Puzzle, AlertTriangle } from 'lucide-react';
import { usePlugins, type PluginItem } from './usePlugins'; import { usePlugins, type PluginItem } from './usePlugins';
import { resolveIcon } from '../../utils/resolve-icon';
// The left panel: every plugin in the tree, installed or not. // 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 {/* 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. */} 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 ? ( {plugin.image ? (
<img src={plugin.image} alt="" className="h-5 w-5 shrink-0 object-contain" /> <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="min-w-0 flex-1">
<div className="truncate text-sm font-medium text-duck-dark">{plugin.label}</div> <div className="truncate text-sm font-medium text-duck-dark">{plugin.label}</div>
@@ -94,6 +94,8 @@ export type AvailablePlugin = {
summary: string; summary: string;
/** An absolute URL to the marketplace's copy — there is no local asset until it is installed. */ /** An absolute URL to the marketplace's copy — there is no local asset until it is installed. */
image?: string; image?: string;
/** A lucide NAME, for a catalogue entry that ships no artwork. Only when `image` is absent. */
icon?: string;
tile: 'badge' | 'bare'; tile: 'badge' | 'bare';
color: string; color: string;
publisher: string; publisher: string;