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:
@@ -4,6 +4,7 @@ import { isSuperAdmin } from '../../super-admin';
|
|||||||
import { mountPrefix } from '../../plugins/manifest';
|
import { mountPrefix } from '../../plugins/manifest';
|
||||||
import { snapshotPlugins } from '../../plugins/mount';
|
import { snapshotPlugins } from '../../plugins/mount';
|
||||||
import { manualInstallHint, reportDependencies } from '../../plugins/os-deps';
|
import { manualInstallHint, reportDependencies } from '../../plugins/os-deps';
|
||||||
|
import { fetchCatalogue } from '../../plugins/marketplace';
|
||||||
import { iconUrl } from '../../app-store/assets';
|
import { iconUrl } from '../../app-store/assets';
|
||||||
import {
|
import {
|
||||||
installPlugin,
|
installPlugin,
|
||||||
@@ -59,7 +60,48 @@ pluginsRouter.get('/', async (ctx) => {
|
|||||||
manualHint: manualInstallHint(report.manager, report.packagesToInstall),
|
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({
|
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) => ({
|
plugins: states.map(({ plugin, install, outdated }, i) => ({
|
||||||
appName: plugin.appName,
|
appName: plugin.appName,
|
||||||
prefix: mountPrefix(plugin),
|
prefix: mountPrefix(plugin),
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import { PLATFORM_DIR } from '../data-path';
|
|||||||
import { deleteProcess, processStatus, startProcess, stopProcess } from '../app-store/pm2';
|
import { deleteProcess, processStatus, startProcess, stopProcess } from '../app-store/pm2';
|
||||||
import { addPluginToEcosystem, pluginProcessName, removePluginFromEcosystem } from './ecosystem';
|
import { addPluginToEcosystem, pluginProcessName, removePluginFromEcosystem } from './ecosystem';
|
||||||
import { discoverPlugins } from './discover';
|
import { discoverPlugins } from './discover';
|
||||||
|
import { fetchPlugin } from './fetch';
|
||||||
import { publishAssets, unpublishAssets } from '../app-store/assets';
|
import { publishAssets, unpublishAssets } from '../app-store/assets';
|
||||||
import { refreshPluginMounts, snapshotPlugins } from './mount';
|
import { refreshPluginMounts, snapshotPlugins } from './mount';
|
||||||
import { generatePluginSchemas, pushSchema } from './schema';
|
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.
|
* switched off must not switch it back on.
|
||||||
*/
|
*/
|
||||||
export async function installPlugin(appName: string, onStep?: OnStep): Promise<PluginActionResult> {
|
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[] = [];
|
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 {
|
try {
|
||||||
// HOST BINARIES FIRST, before a table is created or a row is written.
|
// HOST BINARIES FIRST, before a table is created or a row is written.
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -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 { 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.
|
// 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 = () => {
|
export const PluginDetail = () => {
|
||||||
const { plugins, steps, result, running, run } = usePlugins();
|
const { plugins, available, steps, result, running, run } = usePlugins();
|
||||||
const [params] = useSearchParams();
|
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) {
|
if (!plugin) {
|
||||||
return <div className="p-6 text-sm text-duck-dark/50">Select a plugin.</div>;
|
return <div className="p-6 text-sm text-duck-dark/50">Select a plugin.</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const busy = running !== null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full overflow-auto p-6">
|
<div className="h-full overflow-auto p-6">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
@@ -217,29 +356,7 @@ export const PluginDetail = () => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* The log, as it arrives. `steps` fills while the stream is open and `result` lands when it
|
<ActionLog steps={steps} result={result} running={running} />
|
||||||
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}
|
|
||||||
|
|
||||||
{/* Uninstall keeps every table and row the plugin owns, so this is worth saying rather than
|
{/* 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. */}
|
leaving someone to guess whether the button destroys their data. */}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ const Status = ({ plugin }: { plugin: PluginItem }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const PluginsList = () => {
|
export const PluginsList = () => {
|
||||||
const { plugins, broken, isLoading } = usePlugins();
|
const { plugins, available, broken, isLoading } = usePlugins();
|
||||||
const [params] = useSearchParams();
|
const [params] = useSearchParams();
|
||||||
const selected = params.get('selected');
|
const selected = params.get('selected');
|
||||||
|
|
||||||
@@ -24,7 +24,7 @@ export const PluginsList = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full overflow-auto">
|
<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">
|
<div className="p-4 text-sm text-duck-dark/50">
|
||||||
No plugins in <code>plugins/</code> yet.
|
No plugins in <code>plugins/</code> yet.
|
||||||
</div>
|
</div>
|
||||||
@@ -53,6 +53,39 @@ export const PluginsList = () => {
|
|||||||
</Link>
|
</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
|
{/* A directory that could not be read is shown rather than swallowed — otherwise a malformed
|
||||||
manifest looks exactly like a plugin nobody wrote. */}
|
manifest looks exactly like a plugin nobody wrote. */}
|
||||||
{broken.map((b) => (
|
{broken.map((b) => (
|
||||||
|
|||||||
@@ -79,6 +79,33 @@ export type PluginVerb = 'install' | 'uninstall' | 'enable' | 'disable';
|
|||||||
|
|
||||||
export type BrokenPlugin = { appName: string; error: string };
|
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'];
|
const PLUGINS_KEY = ['plugins'];
|
||||||
|
|
||||||
export function usePlugins() {
|
export function usePlugins() {
|
||||||
@@ -87,7 +114,8 @@ export function usePlugins() {
|
|||||||
|
|
||||||
const { data, isLoading, error } = useQuery({
|
const { data, isLoading, error } = useQuery({
|
||||||
queryKey: PLUGINS_KEY,
|
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
|
// 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 {
|
return {
|
||||||
plugins: data?.plugins ?? [],
|
plugins: data?.plugins ?? [],
|
||||||
|
/** Offered by the marketplace, absent from this machine. Empty when the catalogue cannot be reached. */
|
||||||
|
available: data?.available ?? [],
|
||||||
broken: data?.broken ?? [],
|
broken: data?.broken ?? [],
|
||||||
isLoading,
|
isLoading,
|
||||||
error,
|
error,
|
||||||
|
|||||||
Reference in New Issue
Block a user