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