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),