plugins page becomes a marketplace: catalogue, clone-on-install

The /plugins route showed only what was already on disk, so a plugin nobody had
copied in by hand did not exist. It now shows a third list — what a remote
catalogue offers — and installing one fetches its source.

- plugins/marketplace.ts — a stand-in for the remote store, a JSON array in
  process. One entry: music, pointing at gitea.officer.dev/plugins/music. It is
  deliberately a module and not a fetch, so the shape of a catalogue entry gets
  settled before anything depends on a server answering.

- plugins/fetch.ts — git clone --depth 1 into plugins/<appName>/, INSIDE the
  platform repo because bun resolves the workspace links by walking up and a
  plugin cloned beside it cannot import anything the platform provides
  (measured, not assumed). https only, no credentials, GIT_TERMINAL_PROMPT=0 so
  a repo that turned private fails instead of hanging. A failed clone removes
  the directory: a half-written one reads as a broken plugin rather than an
  absent one, which is the worse of the two.

- install.ts fetches first when the directory is missing, then RE-DISCOVERS
  rather than assuming what it cloned. Everything after is the existing path,
  including the frontend rebuild — so a cloned plugin's panels reach the SPA
  without a restart.

- GET /api/plugins returns `available`, catalogue minus what is on disk. Each
  entry gets the same live PATH probe a local plugin gets, so music's ffmpeg and
  ffprobe are answered for this machine BEFORE the clone rather than after.

- The detail panel is a separate component for a catalogue entry, not a mode of
  the installed one: there is no sidecar status, no enabled state and no route
  to open, and what there IS — the source URL — the installed panel has no
  reason to show. The streaming log is shared, because the marketplace install
  is the case that needs it most: it clones before it does anything, and a slow
  clone behind a blank panel looks like a hang.

The catalogue's icon is an absolute URL to the store's copy, which is the
[open] question the previous commit left: assets only publish at install, so a
plugin you have never installed had no artwork to draw.

Not done: no update verb, no signature, no private repos, and removePluginSource
has no caller — deleting source drops tables on the next push, and that wants a
verb that says so.

tsgo clean. 767 pass / 6 fail, all 6 identical on HEAD without this change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-15 17:56:18 +00:00
co-authored by Claude Opus 5
parent 256f8185f5
commit dadfdc26af
7 changed files with 517 additions and 34 deletions
@@ -1,5 +1,12 @@
import { Link, useSearchParams } from 'react-router';
import { usePlugins, type PluginDependencies, type PluginItem } from './usePlugins';
import {
usePlugins,
type AvailablePlugin,
type PluginActionResult,
type PluginDependencies,
type PluginItem,
type PluginVerb,
} from './usePlugins';
// The right panel: one plugin, and the four verbs.
//
@@ -107,17 +114,149 @@ const Dependencies = ({ deps }: { deps: PluginDependencies }) => {
);
};
/**
* The log, as it arrives.
*
* `steps` fills while the stream is open and `result` lands when it closes, so the same block is both the
* progress and the verdict — an install that mounted routes but could not start a sidecar reads
* differently from one that worked, and a spinner cannot.
*
* Shared by both panels rather than living in the installed one, because the marketplace install is the
* case that needs it MOST: it clones before it does anything else, and a clone over a slow link behind a
* blank panel is indistinguishable from a hang.
*/
const ActionLog = ({
steps,
result,
running,
}: {
steps: string[];
result: PluginActionResult | null;
running: PluginVerb | null;
}) => {
if (!steps.length && !result) return null;
return (
<div className="mt-5 rounded-md border border-duck-dark/10 bg-duck-dark/[0.02] p-3">
<div className="mb-1.5 flex items-center gap-2 text-xs font-medium text-duck-dark/60">
{running ? (
<>
<span className="inline-block h-1.5 w-1.5 animate-pulse rounded-full bg-duck-teal" />
{running}
</>
) : (
<span className={result?.ok ? 'text-emerald-600' : 'text-red-600'}>{result?.ok ? 'Done' : 'Failed'}</span>
)}
</div>
<ul className="space-y-0.5 font-mono text-xs text-duck-dark/70">
{steps.map((s, i) => (
<li key={i}>· {s}</li>
))}
</ul>
{result?.error ? <div className="mt-2 text-xs text-red-600">{result.error}</div> : null}
</div>
);
};
/**
* A marketplace entry, before this machine has the code.
*
* A separate component rather than a mode of the one below, because almost nothing they render is the
* same question. There is no sidecar status, no enabled state, no version-on-disk to differ from the
* installed one, and no route to open. What there IS, and what the one below cannot show, is where the
* code will come from — an install that fetches source should say so before it runs.
*/
const AvailableDetail = ({
plugin,
busy,
onInstall,
steps,
result,
running,
}: {
plugin: AvailablePlugin;
busy: boolean;
onInstall: () => void;
steps: string[];
result: PluginActionResult | null;
running: PluginVerb | null;
}) => (
<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}
<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>
</div>
</div>
<div className="mt-5 border-t border-duck-dark/10 pt-4">
<Row label="Publisher">{plugin.publisher}</Row>
<Row label="Version">{plugin.version}</Row>
<Row label="Needs platform">{plugin.platform}</Row>
<Row label="Would mount">
<code>/api{plugin.prefix}</code>
</Row>
<Row label="Ships">
<Parts has={plugin.has} />
</Row>
<Row label="Permissions">
{plugin.permissions.length ? plugin.permissions.map((p) => p.key).join(', ') : 'none'}
</Row>
{/* Shown because installing this runs `git clone` against it. An owner agreeing to put code on
their machine should be able to read where it comes from without opening a config file. */}
<Row label="Source">
<code className="break-all text-xs">{plugin.source}</code>
</Row>
</div>
<Dependencies deps={plugin.dependencies} />
<div className="mt-6">
<Button
tone="primary"
disabled={busy || (!plugin.dependencies.satisfied && !plugin.dependencies.canInstall)}
onClick={onInstall}
>
Install
</Button>
<p className="mt-2 text-xs text-duck-dark/50">
Clones the repository into <code>plugins/{plugin.appName}</code>, then installs it.
</p>
</div>
<ActionLog steps={steps} result={result} running={running} />
</div>
);
export const PluginDetail = () => {
const { plugins, steps, result, running, run } = usePlugins();
const { plugins, available, steps, result, running, run } = usePlugins();
const [params] = useSearchParams();
const plugin = plugins.find((p) => p.appName === params.get('selected'));
const selected = params.get('selected');
const plugin = plugins.find((p) => p.appName === selected);
const offer = available.find((p) => p.appName === selected);
const busy = running !== null;
// A catalogue entry, not on this machine yet. Checked before the local list's "select a plugin",
// because an entry with no directory is a real selection rather than a missing one.
if (!plugin && offer) {
return (
<AvailableDetail
plugin={offer}
busy={busy}
onInstall={() => run('install', offer.appName)}
steps={steps}
result={result}
running={running}
/>
);
}
if (!plugin) {
return <div className="p-6 text-sm text-duck-dark/50">Select a plugin.</div>;
}
const busy = running !== null;
return (
<div className="h-full overflow-auto p-6">
<div className="flex items-center gap-3">
@@ -217,29 +356,7 @@ export const PluginDetail = () => {
)}
</div>
{/* The log, as it arrives. `steps` fills while the stream is open and `result` lands when it
closes, so the same block is both the progress and the verdict — an install that mounted routes
but could not start a sidecar reads differently from one that worked, and a spinner cannot. */}
{steps.length || result ? (
<div className="mt-5 rounded-md border border-duck-dark/10 bg-duck-dark/[0.02] p-3">
<div className="mb-1.5 flex items-center gap-2 text-xs font-medium text-duck-dark/60">
{running ? (
<>
<span className="inline-block h-1.5 w-1.5 animate-pulse rounded-full bg-duck-teal" />
{running}
</>
) : (
<span className={result?.ok ? 'text-emerald-600' : 'text-red-600'}>{result?.ok ? 'Done' : 'Failed'}</span>
)}
</div>
<ul className="space-y-0.5 font-mono text-xs text-duck-dark/70">
{steps.map((s, i) => (
<li key={i}>· {s}</li>
))}
</ul>
{result?.error ? <div className="mt-2 text-xs text-red-600">{result.error}</div> : null}
</div>
) : null}
<ActionLog steps={steps} result={result} running={running} />
{/* Uninstall keeps every table and row the plugin owns, so this is worth saying rather than
leaving someone to guess whether the button destroys their data. */}
@@ -16,7 +16,7 @@ const Status = ({ plugin }: { plugin: PluginItem }) => {
};
export const PluginsList = () => {
const { plugins, broken, isLoading } = usePlugins();
const { plugins, available, broken, isLoading } = usePlugins();
const [params] = useSearchParams();
const selected = params.get('selected');
@@ -24,7 +24,7 @@ export const PluginsList = () => {
return (
<div className="h-full overflow-auto">
{plugins.length === 0 && broken.length === 0 ? (
{plugins.length === 0 && available.length === 0 && broken.length === 0 ? (
<div className="p-4 text-sm text-duck-dark/50">
No plugins in <code>plugins/</code> yet.
</div>
@@ -53,6 +53,39 @@ export const PluginsList = () => {
</Link>
))}
{/* Offered by the marketplace, absent from this machine.
Visually separated rather than mixed in: "you have this" and "you could have this" are
different answers, and a single list sorted by name would make the owner read a badge to tell
them apart. Installing one moves it into the list above, because the clone gives it a
directory and the next read discovers it like any other. */}
{available.length ? (
<div className="border-b border-duck-dark/5 bg-duck-dark/[0.02] px-3 py-1.5">
<span className="text-xs font-medium uppercase tracking-wide text-duck-dark/40">Available</span>
</div>
) : null}
{available.map((plugin) => (
<Link
key={`available-${plugin.appName}`}
to={`/plugins?selected=${encodeURIComponent(plugin.appName)}`}
className={`flex items-center gap-3 border-b border-duck-dark/5 px-3 py-2.5 transition-colors ${
selected === plugin.appName ? 'bg-duck-teal/10' : 'hover:bg-duck-dark/5'
}`}
>
{/* The marketplace's own copy, fetched straight from the catalogue URL — the one thing a
local not-yet-installed plugin cannot show, because its assets are published at install. */}
{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 }} />
)}
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium text-duck-dark">{plugin.label}</div>
<div className="truncate text-xs text-duck-dark/50">{plugin.publisher}</div>
</div>
<span className="text-xs text-duck-dark/40">available</span>
</Link>
))}
{/* A directory that could not be read is shown rather than swallowed — otherwise a malformed
manifest looks exactly like a plugin nobody wrote. */}
{broken.map((b) => (
@@ -79,6 +79,33 @@ export type PluginVerb = 'install' | 'uninstall' | 'enable' | 'disable';
export type BrokenPlugin = { appName: string; error: string };
/**
* A plugin the marketplace offers that this machine does not have.
*
* Deliberately NOT a `PluginItem`. A local plugin's shape is read from its directory; this is what a
* remote catalogue claims about something with no directory to read, and blurring the two would make
* "installed" a field that is sometimes false and sometimes meaningless. Installing turns one into the
* other — the clone creates the directory, and the next read finds it in `plugins` instead.
*/
export type AvailablePlugin = {
appName: string;
prefix: string;
label: string;
summary: string;
/** An absolute URL to the marketplace's copy — there is no local asset until it is installed. */
image?: string;
tile: 'badge' | 'bare';
color: string;
publisher: string;
version: string;
platform: string;
permissions: PluginPermission[];
has: { api: boolean; schema: boolean; sidecar: boolean; web: boolean };
/** Where it will be cloned from. Shown, because an install that fetches code should say from where. */
source: string;
dependencies: PluginDependencies;
};
const PLUGINS_KEY = ['plugins'];
export function usePlugins() {
@@ -87,7 +114,8 @@ export function usePlugins() {
const { data, isLoading, error } = useQuery({
queryKey: PLUGINS_KEY,
queryFn: () => client.get<{ plugins: PluginItem[]; broken: BrokenPlugin[] }>('/plugins'),
queryFn: () =>
client.get<{ plugins: PluginItem[]; available: AvailablePlugin[]; broken: BrokenPlugin[] }>('/plugins'),
});
// Every verb invalidates the plugin list AND self-permissions: installing a plugin can add a dock tile
@@ -171,6 +199,8 @@ export function usePlugins() {
return {
plugins: data?.plugins ?? [],
/** Offered by the marketplace, absent from this machine. Empty when the catalogue cannot be reached. */
available: data?.available ?? [],
broken: data?.broken ?? [],
isLoading,
error,