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
+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.
//