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
+42
View File
@@ -4,6 +4,7 @@ import { isSuperAdmin } from '../../super-admin';
import { mountPrefix } from '../../plugins/manifest';
import { snapshotPlugins } from '../../plugins/mount';
import { manualInstallHint, reportDependencies } from '../../plugins/os-deps';
import { fetchCatalogue } from '../../plugins/marketplace';
import { iconUrl } from '../../app-store/assets';
import {
installPlugin,
@@ -59,7 +60,48 @@ pluginsRouter.get('/', async (ctx) => {
manualHint: manualInstallHint(report.manager, report.packagesToInstall),
};
});
// What the marketplace offers that this machine does not have.
//
// Merged here rather than in `snapshotPlugins`, which answers "what is on disk joined to what is
// installed" and should keep answering exactly that — the catalogue is a third, remote source and
// giving discovery a network dependency would make the plugins page fail when the store is down.
//
// A catalogue entry whose plugin is already on disk is DROPPED: the directory is the truth once it
// exists, and showing both would offer to install something already installed.
const onDisk = new Set(states.map((s) => s.plugin.appName));
const catalogue = (await fetchCatalogue().catch(() => [])).filter((e) => !onDisk.has(e.appName));
return ctx.json({
available: catalogue.map((e) => ({
appName: e.appName,
prefix: e.publisher === 'officerdev' ? `/${e.appName}` : `/p/${e.publisher}/${e.appName}`,
label: e.label,
summary: e.summary,
image: e.iconUrl,
tile: e.tile ?? 'badge',
color: e.color,
publisher: e.publisher,
version: e.version,
platform: e.platform,
permissions: e.permissions,
has: e.has,
source: e.source,
// The same live PATH probe a local plugin gets, so ffmpeg/ffprobe are visible BEFORE the clone
// rather than after. `reportDependencies` takes a DiscoveredPlugin, so the entry is adapted to
// that shape — the only field it reads is `manifest.osDependencies`.
dependencies: (() => {
const r = reportDependencies({ manifest: { osDependencies: e.osDependencies } } as never);
return {
manager: r.manager,
items: r.dependencies,
satisfied: r.missing.length === 0,
canInstall: r.canInstall,
blockedReason: r.blockedReason,
packagesToInstall: r.packagesToInstall,
manualHint: manualInstallHint(r.manager, r.packagesToInstall),
};
})(),
})),
plugins: states.map(({ plugin, install, outdated }, i) => ({
appName: plugin.appName,
prefix: mountPrefix(plugin),
+105
View File
@@ -0,0 +1,105 @@
import { existsSync } from 'node:fs';
import { rm } from 'node:fs/promises';
import { join } from 'node:path';
import { PLUGINS_DIR } from './discover';
import { catalogueEntry } from './marketplace';
// Getting a plugin's source onto the machine.
//
// ── Why a clone into the platform's own tree ──
//
// `plugins/<app-name>/` is INSIDE the platform repository and that is load-bearing, not incidental. Bun
// resolves `hooks/useClient`, `officerdev`, `officerdb/db` and the `@@/*` alias through the workspace
// links in the platform's node_modules, and resolution walks UP from the importing file. Measured:
//
// platform/plugins/music hooks/useClient OK officerdev OK officerdb/db OK
// ../plugins/music FAIL FAIL FAIL
//
// So a plugin cloned beside the platform cannot import anything the platform provides. Until the host
// API is a package a plugin can depend on, the clone destination is not a preference.
//
// ── What this deliberately does not do ──
//
// No build, no install of the plugin's own dependencies, no lockfile. A plugin today is source that
// compiles inside the platform's workspace, so there is nothing to resolve. The day a plugin carries its
// own package.json is the day this needs to think about it, and pretending otherwise now would be
// machinery guarding an empty case.
export type FetchResult = { ok: boolean; dir: string; error?: string };
/** `git` has to exist. It is not in `osDependencies` anywhere because it is the platform's need, not a plugin's. */
const gitAvailable = (): boolean => !!Bun.which('git');
/**
* Clone a catalogue plugin into `plugins/<appName>/`.
*
* Refuses rather than overwrites when the directory already exists. That is not caution for its own
* sake: the directory may be a plugin somebody is developing, and `git clone` over the top of local work
* is the kind of loss no undo covers. Re-fetching an existing plugin is an update, which is a different
* verb with different questions — what happens to uncommitted changes, does the version go backwards —
* and it does not exist yet.
*/
export async function fetchPlugin(appName: string): Promise<FetchResult> {
const dir = join(PLUGINS_DIR, appName);
if (existsSync(dir)) {
return { ok: false, dir, error: `${appName} is already on disk at plugins/${appName} — nothing to fetch` };
}
if (!gitAvailable()) {
return { ok: false, dir, error: 'git is not installed on this machine' };
}
const entry = await catalogueEntry(appName);
if (!entry) return { ok: false, dir, error: `no catalogue entry for "${appName}"` };
// The URL comes from the catalogue and reaches a command line, so it is checked rather than trusted.
// Deliberately narrow: https only, no credentials in the URL, no shell metacharacters. A private
// plugin needing auth is a separate feature and must not arrive by someone putting a token in here.
if (!/^https:\/\/[A-Za-z0-9._~\-/]+\.git$/.test(entry.source)) {
return { ok: false, dir, error: `refusing to clone from "${entry.source}" — https URLs only, no credentials` };
}
const proc = Bun.spawn(
// argv, never a shell. `--depth 1` because the platform wants the source, not the history — and on a
// plugin with a long history the difference is seconds against minutes on a slow link.
['git', 'clone', '--depth', '1', '--quiet', entry.source, dir],
{
stdout: 'pipe',
stderr: 'pipe',
// No terminal to answer a credential prompt: without this, a repository that turned private hangs
// the install forever rather than failing it. Same reasoning as `sudo -n` in os-deps.ts.
env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
},
);
const [out, err] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text()]);
const code = await proc.exited;
if (code !== 0) {
// A half-written directory is worse than none: discovery would find it, fail to read a manifest, and
// report the plugin as broken rather than as absent.
await rm(dir, { recursive: true, force: true }).catch(() => {});
return { ok: false, dir, error: `git clone failed: ${`${err}${out}`.trim().slice(-300) || `exit ${code}`}` };
}
if (!existsSync(join(dir, 'manifest.ts'))) {
await rm(dir, { recursive: true, force: true }).catch(() => {});
return { ok: false, dir, error: `cloned, but ${appName} has no manifest.ts — not a plugin` };
}
return { ok: true, dir };
}
/**
* Remove a plugin's source from the tree.
*
* NOT called by uninstall, and that separation is the whole safety property. Uninstall stops a plugin;
* deleting its source is what makes `db:push` drop its tables, because the generated schema barrel
* follows directories rather than the install table. Observed on 2026-08-15: uninstalling music kept
* every row, and removing the directory dropped four tables on the next push.
*
* So this exists for a future "remove completely" verb that says out loud what it costs. Nothing calls
* it yet.
*/
export async function removePluginSource(appName: string): Promise<void> {
await rm(join(PLUGINS_DIR, appName), { recursive: true, force: true });
}
+29 -3
View File
@@ -3,6 +3,7 @@ import { PLATFORM_DIR } from '../data-path';
import { deleteProcess, processStatus, startProcess, stopProcess } from '../app-store/pm2';
import { addPluginToEcosystem, pluginProcessName, removePluginFromEcosystem } from './ecosystem';
import { discoverPlugins } from './discover';
import { fetchPlugin } from './fetch';
import { publishAssets, unpublishAssets } from '../app-store/assets';
import { refreshPluginMounts, snapshotPlugins } from './mount';
import { generatePluginSchemas, pushSchema } from './schema';
@@ -95,10 +96,35 @@ async function findPlugin(appName: string): Promise<DiscoveredPlugin | null> {
* switched off must not switch it back on.
*/
export async function installPlugin(appName: string, onStep?: OnStep): Promise<PluginActionResult> {
const plugin = await findPlugin(appName);
if (!plugin) return { ok: false, appName, steps: [], error: `No plugin directory named "${appName}"` };
const steps: string[] = [];
// FETCH FIRST, when the plugin is not here yet.
//
// Until 2026-08-15 every plugin shipped inside the platform's repository, so "installed" and "present"
// were nearly the same thing and install could assume a directory. Music moving to its own repository
// ended that: it is real, installable, and absent. So the first question is no longer "is it enabled"
// but "is it even on this machine".
//
// Cloning before anything else keeps the same property every other step here has — it changes nothing
// that needs undoing if it fails. A failed clone removes its own directory and the machine is exactly
// as it was.
let plugin = await findPlugin(appName);
if (!plugin) {
const fetched = await fetchPlugin(appName);
if (!fetched.ok) {
return { ok: false, appName, steps, error: fetched.error ?? `cannot fetch "${appName}"` };
}
await step(steps, onStep, `fetched: cloned into plugins/${appName}`);
// Re-discovered rather than assumed. The catalogue DESCRIBED this plugin; the manifest on disk is
// what the platform actually obeys, and a catalogue that disagreed with it is a catalogue bug rather
// than a second source of truth.
plugin = await findPlugin(appName);
if (!plugin) {
return { ok: false, appName, steps, error: `cloned "${appName}" but it does not read as a plugin` };
}
}
try {
// HOST BINARIES FIRST, before a table is created or a row is written.
//
+130
View File
@@ -0,0 +1,130 @@
import type { OsDependency, PluginPermission } from './manifest';
// What the platform could install but does not have yet.
//
// ── Why this exists ──
//
// Discovery answers "what is on this machine". That was enough while every plugin shipped inside the
// platform's own repository, because being on disk and being available were the same fact. They stopped
// being the same fact on 2026-08-15, when music moved to gitea.officer.dev/plugins/music and left the
// tree entirely: it is real, installable and completely invisible to `discoverPlugins`.
//
// So there are three states now, not two:
//
// available in the catalogue, not on disk — nothing here yet; clone it first
// on disk a directory, no `plugin_installs` row — a developer working on one
// installed both — mounted, running
//
// ── Why a local constant rather than a fetch ──
//
// This is deliberately a stand-in for marketplace.officer.dev, and it is shaped as the wire format that
// marketplace would return rather than as something convenient for a TypeScript file. When the real one
// exists, `fetchCatalogue()` replaces the constant and nothing above it changes. That is the whole point
// of writing it this way now: the seam is chosen while it costs nothing.
//
// ── Why the entry repeats the manifest ──
//
// A local plugin's capabilities are read from its directory — `api/router.ts` present means it has
// routes. A remote one has no directory to read, so everything the plugins page shows BEFORE installing
// has to be declared here. That includes the two things worth seeing before consenting to an install:
// which permissions it wants, and which host binaries it will make the machine acquire.
//
// The duplication is real and it is the price of describing something you do not have. It is checked
// rather than trusted: after a clone, the plugin's own manifest is what the platform reads, and a
// catalogue that lied about it is a catalogue bug, not a new source of truth.
/** One plugin as the marketplace describes it. The manifest, plus where to get it. */
export type CatalogueEntry = {
/** The directory it will be cloned into, and its id everywhere else. */
appName: string;
publisher: string;
version: string;
platform: string;
label: string;
summary: string;
color: string;
tile?: 'badge' | 'bare';
/**
* An absolute URL, unlike a manifest's `icon`, which is a lucide NAME or an `assets/icon.png` that
* only exists once the plugin is on disk. A marketplace has to show artwork for something the machine
* has never seen, so the catalogue carries a link rather than a file.
*/
iconUrl?: string;
/**
* 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
* plugin and false everywhere else. Anonymous HTTPS also keeps credentials out of the install path
* entirely, which is the property that makes a public marketplace simple.
*
* `[open]` A private or paid plugin needs authentication here, and that is a different feature: the
* credential has to live somewhere the platform can read and must never appear in this catalogue.
*/
source: string;
/** What the owner is agreeing to, shown before the button rather than discovered afterwards. */
permissions: PluginPermission[];
/** Host binaries the install will acquire. Same reasoning — visible before consent. */
osDependencies?: OsDependency[];
/** What it ships. Read from the directory for a local plugin; declared here for one that is not. */
has: { api: boolean; schema: boolean; sidecar: boolean; web: boolean };
};
/**
* The stand-in catalogue.
*
* One entry, and it is not a fixture: `plugins/music` is a real public repository that a real
* `git clone` reaches with no credentials. The install path is therefore exercised end to end even
* though the catalogue itself is a constant.
*/
const CATALOGUE: CatalogueEntry[] = [
{
appName: 'music',
publisher: 'officerdev',
version: '1.0.0',
platform: '>=1.0.0',
label: 'Music',
summary: 'The music library — browse, play, favourites and playlists',
color: '#22c55e',
// Artwork rather than a glyph, so the tile draws no coloured square behind it.
tile: 'bare',
iconUrl: 'https://gitea.officer.dev/plugins/music/raw/branch/main/assets/icon.png',
source: 'https://gitea.officer.dev/plugins/music.git',
permissions: [
{
key: 'music',
label: 'Music',
description: 'The music library, playback, and your own favourites and playlists',
readOnlyWrites: ['/favorites', '/now-playing', '/playlists', '/queue'],
},
],
osDependencies: [
{
binary: 'ffprobe',
reason: 'Reads tags, duration and embedded lyrics. Without it every track indexes as a bare filename.',
packages: { apt: 'ffmpeg', pacman: 'ffmpeg', dnf: 'ffmpeg', brew: 'ffmpeg' },
},
{
binary: 'ffmpeg',
reason: 'Compresses cover art for phones and grabs poster frames from videos.',
packages: { apt: 'ffmpeg', pacman: 'ffmpeg', dnf: 'ffmpeg', brew: 'ffmpeg' },
},
],
has: { api: true, schema: true, sidecar: true, web: true },
},
];
/**
* Everything the marketplace offers.
*
* Async and allowed to fail from the start, because the real one will be a network call. A catalogue
* that cannot be reached must leave the plugins page working with what is on disk — an owner who cannot
* see the store should still be able to manage what they have already installed.
*/
export async function fetchCatalogue(): Promise<CatalogueEntry[]> {
return CATALOGUE;
}
/** One entry by app name, or null. Used by install to find where to clone from. */
export async function catalogueEntry(appName: string): Promise<CatalogueEntry | null> {
const all = await fetchCatalogue().catch(() => [] as CatalogueEntry[]);
return all.find((e) => e.appName === appName) ?? null;
}
@@ -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,