From 176fb1995024b7281bed9ad0265c64899663801d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Sat, 15 Aug 2026 18:17:03 +0000 Subject: [PATCH] example, extracted from the platform into its own repository MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reference plugin — deliberately the smallest thing that is still a real plugin, and what EXTRACTING-A-PLUGIN.md sends people to read. It leaves the platform tree with music and offscale, so that `plugins/` in the platform repository holds documentation and nothing else. That is the point of the move: a directory that contains both tracked source and untracked installs is one where "is this mine or is this installed" has no answer you can see. Same extraction as the two before it: source only, no history. The platform's history still holds every commit that shaped this. Co-Authored-By: Claude Opus 5 --- .gitignore | 2 ++ api/router.ts | 10 ++++++++++ manifest.ts | 35 +++++++++++++++++++++++++++++++++++ sidecar/index.ts | 24 ++++++++++++++++++++++++ web/ExampleDetail.tsx | 23 +++++++++++++++++++++++ web/ExampleOverview.tsx | 27 +++++++++++++++++++++++++++ web/layout.ts | 16 ++++++++++++++++ web/panels.ts | 16 ++++++++++++++++ 8 files changed, 153 insertions(+) create mode 100644 .gitignore create mode 100644 api/router.ts create mode 100644 manifest.ts create mode 100644 sidecar/index.ts create mode 100644 web/ExampleDetail.tsx create mode 100644 web/ExampleOverview.tsx create mode 100644 web/layout.ts create mode 100644 web/panels.ts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..552f221 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +*.log diff --git a/api/router.ts b/api/router.ts new file mode 100644 index 0000000..9a7dfde --- /dev/null +++ b/api/router.ts @@ -0,0 +1,10 @@ +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/manifest.ts b/manifest.ts new file mode 100644 index 0000000..26ded1e --- /dev/null +++ b/manifest.ts @@ -0,0 +1,35 @@ +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/sidecar/index.ts b/sidecar/index.ts new file mode 100644 index 0000000..a92d3db --- /dev/null +++ b/sidecar/index.ts @@ -0,0 +1,24 @@ +// 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/web/ExampleDetail.tsx b/web/ExampleDetail.tsx new file mode 100644 index 0000000..366b590 --- /dev/null +++ b/web/ExampleDetail.tsx @@ -0,0 +1,23 @@ +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/web/ExampleOverview.tsx b/web/ExampleOverview.tsx new file mode 100644 index 0000000..0975c7c --- /dev/null +++ b/web/ExampleOverview.tsx @@ -0,0 +1,27 @@ +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/web/layout.ts b/web/layout.ts new file mode 100644 index 0000000..8cb9884 --- /dev/null +++ b/web/layout.ts @@ -0,0 +1,16 @@ +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/web/panels.ts b/web/panels.ts new file mode 100644 index 0000000..4746a53 --- /dev/null +++ b/web/panels.ts @@ -0,0 +1,16 @@ +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 }, +];