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
-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 },
];