diff --git a/src/servers/api/plugins/router.ts b/src/servers/api/plugins/router.ts index 3ae99cb0..0363d29b 100644 --- a/src/servers/api/plugins/router.ts +++ b/src/servers/api/plugins/router.ts @@ -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), diff --git a/src/servers/plugins/fetch.ts b/src/servers/plugins/fetch.ts new file mode 100644 index 00000000..1ae1f014 --- /dev/null +++ b/src/servers/plugins/fetch.ts @@ -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//` 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//`. + * + * 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 { + 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 { + await rm(join(PLUGINS_DIR, appName), { recursive: true, force: true }); +} diff --git a/src/servers/plugins/install.ts b/src/servers/plugins/install.ts index c670d451..c49f62c3 100644 --- a/src/servers/plugins/install.ts +++ b/src/servers/plugins/install.ts @@ -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 { * switched off must not switch it back on. */ export async function installPlugin(appName: string, onStep?: OnStep): Promise { - 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. // diff --git a/src/servers/plugins/marketplace.ts b/src/servers/plugins/marketplace.ts new file mode 100644 index 00000000..cdc283e8 --- /dev/null +++ b/src/servers/plugins/marketplace.ts @@ -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 { + 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 { + const all = await fetchCatalogue().catch(() => [] as CatalogueEntry[]); + return all.find((e) => e.appName === appName) ?? null; +} diff --git a/src/workspaces/officerdev/src/apps/Plugins/PluginDetail.tsx b/src/workspaces/officerdev/src/apps/Plugins/PluginDetail.tsx index acde9ad9..4b49a491 100644 --- a/src/workspaces/officerdev/src/apps/Plugins/PluginDetail.tsx +++ b/src/workspaces/officerdev/src/apps/Plugins/PluginDetail.tsx @@ -1,5 +1,12 @@ 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. // @@ -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 ( +
+
+ {running ? ( + <> + + {running}… + + ) : ( + {result?.ok ? 'Done' : 'Failed'} + )} +
+
    + {steps.map((s, i) => ( +
  • · {s}
  • + ))} +
+ {result?.error ?
{result.error}
: null} +
+ ); +}; + +/** + * 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; +}) => ( +
+
+ {plugin.image ? : null} +
+

{plugin.label}

+

{plugin.summary}

+
+
+ +
+ {plugin.publisher} + {plugin.version} + {plugin.platform} + + /api{plugin.prefix} + + + + + + {plugin.permissions.length ? plugin.permissions.map((p) => p.key).join(', ') : 'none'} + + {/* 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. */} + + {plugin.source} + +
+ + + +
+ +

+ Clones the repository into plugins/{plugin.appName}, then installs it. +

+
+ + +
+); + export const PluginDetail = () => { - const { plugins, steps, result, running, run } = usePlugins(); + const { plugins, available, steps, result, running, run } = usePlugins(); 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 ( + run('install', offer.appName)} + steps={steps} + result={result} + running={running} + /> + ); + } if (!plugin) { return
Select a plugin.
; } - const busy = running !== null; - return (
@@ -217,29 +356,7 @@ export const PluginDetail = () => { )}
- {/* 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. */} - {steps.length || result ? ( -
-
- {running ? ( - <> - - {running}… - - ) : ( - {result?.ok ? 'Done' : 'Failed'} - )} -
-
    - {steps.map((s, i) => ( -
  • · {s}
  • - ))} -
- {result?.error ?
{result.error}
: null} -
- ) : null} + {/* 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. */} diff --git a/src/workspaces/officerdev/src/apps/Plugins/PluginsList.tsx b/src/workspaces/officerdev/src/apps/Plugins/PluginsList.tsx index 41d9d4ab..ba9ae6da 100644 --- a/src/workspaces/officerdev/src/apps/Plugins/PluginsList.tsx +++ b/src/workspaces/officerdev/src/apps/Plugins/PluginsList.tsx @@ -16,7 +16,7 @@ const Status = ({ plugin }: { plugin: PluginItem }) => { }; export const PluginsList = () => { - const { plugins, broken, isLoading } = usePlugins(); + const { plugins, available, broken, isLoading } = usePlugins(); const [params] = useSearchParams(); const selected = params.get('selected'); @@ -24,7 +24,7 @@ export const PluginsList = () => { return (
- {plugins.length === 0 && broken.length === 0 ? ( + {plugins.length === 0 && available.length === 0 && broken.length === 0 ? (
No plugins in plugins/ yet.
@@ -53,6 +53,39 @@ export const PluginsList = () => { ))} + {/* 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 ? ( +
+ Available +
+ ) : null} + {available.map((plugin) => ( + + {/* 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 ? ( + + ) : ( + + )} +
+
{plugin.label}
+
{plugin.publisher}
+
+ available + + ))} + {/* A directory that could not be read is shown rather than swallowed — otherwise a malformed manifest looks exactly like a plugin nobody wrote. */} {broken.map((b) => ( diff --git a/src/workspaces/officerdev/src/apps/Plugins/usePlugins.ts b/src/workspaces/officerdev/src/apps/Plugins/usePlugins.ts index 3f09d047..4633d59a 100644 --- a/src/workspaces/officerdev/src/apps/Plugins/usePlugins.ts +++ b/src/workspaces/officerdev/src/apps/Plugins/usePlugins.ts @@ -79,6 +79,33 @@ export type PluginVerb = 'install' | 'uninstall' | 'enable' | 'disable'; 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']; export function usePlugins() { @@ -87,7 +114,8 @@ export function usePlugins() { const { data, isLoading, error } = useQuery({ 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 @@ -171,6 +199,8 @@ export function usePlugins() { return { plugins: data?.plugins ?? [], + /** Offered by the marketplace, absent from this machine. Empty when the catalogue cannot be reached. */ + available: data?.available ?? [], broken: data?.broken ?? [], isLoading, error,