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;
}