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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user