From 5c6c5b31f841ff34e173c10fc9dbd7212d47fb8c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Fri, 28 Aug 2026 23:27:16 +0100 Subject: [PATCH] "Install from Git repository", in the marketplace MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit a button beside the plugin count that becomes a URL field. everything after it is the machinery that was already there: same installPlugin, same step and log frames, same progress panel, same result. STREAMED, because that is what "works exactly like it already does" means here. an install writes an ecosystem entry, starts a process and rebuilds the router; answering once at the end is a spinner that stops, with no way to tell "started the sidecar" from "could not". the frame reader in usePlugins was split out rather than copied — it never cared which URL it read from, and two copies of an incomplete-frame parser is two places to get it subtly wrong. A TWIN ROUTE, not a case inside the verb map. every other verb names an INSTALLED plugin in its path; this one names something the machine has never seen, and its subject arrives in the body. folding it in would mean a route whose `:appName` is ignored for exactly one value of `:verb`. NO NAME FIELD, deliberately. appName IS the directory a plugin sits in — never declared in a manifest — so it decides the route segment, the table prefix and the pm2 process name. a box for it would invite a name that disagrees with the address. it is derived server-side and reported as the FIRST streamed step, which is the moment it can still be cancelled. collapsed until asked for: a permanent URL box at the top of a catalogue reads as the primary way in, and it is the exception — for somebody who already has an address. verified end to end against fofos/fofos-admin: 30 frames, every step from clone to mount, `assets: published to /plugins/fofos-admin/` now that it carries an icon, and the icon served at 256x256 with alpha intact. purged afterwards — officer-dev is not that website's machine. Co-Authored-By: Claude Opus 5 Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 --- src/servers/api/plugins/router.ts | 118 +++++++++++++++--- .../src/apps/Plugins/InstallFromUrl.tsx | 78 ++++++++++++ .../src/apps/Plugins/PluginGrid.tsx | 16 ++- .../officerdev/src/apps/Plugins/usePlugins.ts | 38 +++++- 4 files changed, 221 insertions(+), 29 deletions(-) create mode 100644 src/workspaces/officerdev/src/apps/Plugins/InstallFromUrl.tsx diff --git a/src/servers/api/plugins/router.ts b/src/servers/api/plugins/router.ts index 37f89e08..30ed29aa 100644 --- a/src/servers/api/plugins/router.ts +++ b/src/servers/api/plugins/router.ts @@ -247,32 +247,41 @@ pluginsRouter.post('/update-all', async (ctx) => { a catalogue somebody curated. The URL is validated in `fetch.ts` — https, no credentials, no shell — but validation is not review: nothing here reads what it cloned. */ +/** + * `https://host/owner/repo` or `…/repo.git` → `repo`. + * + * Derived rather than asked for, so the name and the address cannot disagree. `appName` IS the directory + * a plugin sits in — never declared in a manifest — so this string becomes the route segment, the table + * prefix and the sidecar suffix. Checked before anything is cloned: a half-clone under an unusable name + * reads as a BROKEN plugin rather than an absent one, which is much harder to get out of. + */ +export function pluginNameFromUrl(url: string, override?: string): { appName: string } | { error: string } { + const derived = url + .replace(/\.git$/, '') + .replace(/\/+$/, '') + .split('/') + .pop(); + const appName = override?.trim() || derived; + if (!appName || !/^[a-z0-9]+(-[a-z0-9]+)*$/.test(appName)) { + return { + error: + `"${appName ?? ''}" is not a usable plugin name. Lowercase letters, digits and single hyphens — ` + + 'it becomes the directory, the route and the table prefix.', + }; + } + return { appName }; +} + pluginsRouter.post('/install-from-url', async (ctx) => { type FromUrl = { url?: string; appName?: string }; const body = await ctx.req.json().catch(() => ({}) as FromUrl); const url = body.url?.trim(); if (!url) throw errors.BAD_REQUEST('url is required'); - // `https://host/owner/repo` or `…/repo.git` → `repo`. Derived rather than asked for, so the name and - // the address cannot disagree; `appName` in the body only OVERRIDES it, for a repository whose name is - // not the id you want. - const derived = url - .replace(/\.git$/, '') - .replace(/\/+$/, '') - .split('/') - .pop(); - const appName = body.appName?.trim() || derived; + const named = pluginNameFromUrl(url, body.appName); + if ('error' in named) throw errors.BAD_REQUEST(named.error); - // The same shape every other plugin id has. Checked here because everything downstream — the route - // segment, the table prefix, the pm2 process name — inherits it, and each of those fails differently. - if (!appName || !/^[a-z0-9]+(-[a-z0-9]+)*$/.test(appName)) { - throw errors.BAD_REQUEST( - `"${appName ?? ''}" is not a usable plugin name. Lowercase letters, digits and single hyphens — ` + - 'it becomes the directory, the route and the table prefix.', - ); - } - - const result = await installPlugin(appName, undefined, ctx.get('user').id, undefined, url); + const result = await installPlugin(named.appName, undefined, ctx.get('user').id, undefined, url); return ctx.json(result, result.ok ? 200 : 400); }); @@ -346,6 +355,77 @@ const VERBS = { type Verb = keyof typeof VERBS; const isVerb = (v: string): v is Verb => v in VERBS; +/* + The same install, streamed — so the marketplace's "Install from Git repository" watches it happen. + + A twin of `/:appName/:verb/stream` below rather than a case inside it, because the URL install is the + one verb whose SUBJECT arrives in the body: every other verb names an installed plugin in its path, and + this one is naming something the machine has never seen. Folding it into the verb map would mean a + route whose `:appName` is ignored for exactly one value of `:verb`. + + Everything after the name is resolved is identical — same `installPlugin`, same step and log events, + same `done` frame — so the client renders it with the machinery it already has. +*/ +pluginsRouter.post('/install-from-url/stream', async (ctx) => { + type FromUrl = { url?: string; appName?: string }; + const body = await ctx.req.json().catch(() => ({}) as FromUrl); + const url = body.url?.trim(); + if (!url) throw errors.BAD_REQUEST('url is required'); + + const named = pluginNameFromUrl(url, body.appName); + if ('error' in named) throw errors.BAD_REQUEST(named.error); + const { appName } = named; + + // Read before the stream opens: once inside ReadableStream.start the request context is gone. + const userId = ctx.get('user').id; + + const encoder = new TextEncoder(); + const frame = (event: string, data: unknown) => encoder.encode(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`); + + const stream = new ReadableStream({ + async start(controller) { + const send = (event: string, data: unknown) => { + try { + controller.enqueue(frame(event, data)); + } catch { + /* client gone — the install continues, it is the server's job now */ + } + }; + + // Said before the clone starts, because the name is derived rather than typed and this is the + // only place anyone can see what it became. + send('step', { step: `installing "${appName}" from ${url}` }); + + let result: PluginActionResult; + try { + result = await installPlugin( + appName, + async (step) => send('step', { step }), + userId, + async (line) => send('log', { line }), + url, + ); + } catch (err) { + result = { ok: false, appName, steps: [], error: err instanceof Error ? err.message : String(err) }; + } + send('done', result); + try { + controller.close(); + } catch { + /* already closed */ + } + }, + }); + + return new Response(stream, { + headers: { + 'content-type': 'text/event-stream', + 'cache-control': 'no-cache', + 'x-accel-buffering': 'no', + }, + }); +}); + pluginsRouter.post('/:appName/:verb/stream', async (ctx) => { const appName = ctx.req.param('appName'); const verb = ctx.req.param('verb'); diff --git a/src/workspaces/officerdev/src/apps/Plugins/InstallFromUrl.tsx b/src/workspaces/officerdev/src/apps/Plugins/InstallFromUrl.tsx new file mode 100644 index 00000000..c87137d7 --- /dev/null +++ b/src/workspaces/officerdev/src/apps/Plugins/InstallFromUrl.tsx @@ -0,0 +1,78 @@ +import { useState } from 'react'; +import { GitBranch, X } from 'lucide-react'; + +/** + * "Install from Git repository" — for a plugin that is in no catalogue and should not be. + * + * The marketplace was the only way a remote plugin could arrive, so "build one and give it to a friend" + * had no affordance at all. It is also the wrong shape for a plugin built for one person: fofos-admin is + * one website's admin panel, and listing it publicly to install it would be a lie about who it is for. + * + * ── Collapsed until asked for ── + * + * A permanent URL box at the top of a catalogue reads as the primary way in, and it is not — it is the + * exception, for somebody who already has an address. So it is a button that becomes a field, and folds + * away again when it is done. + * + * ── No name field ── + * + * The name is derived from the repository server-side, because `appName` IS the directory a plugin sits + * in and decides its route, its table prefix and its process name. Offering a box for it would invite + * somebody to type one that disagrees with the address. The first streamed step reports what it became, + * which is the moment it can still be cancelled. + */ +export const InstallFromUrl = ({ onInstall, busy }: { onInstall: (url: string) => void; busy: boolean }) => { + const [open, setOpen] = useState(false); + const [url, setUrl] = useState(''); + + if (!open) { + return ( + + ); + } + + const submit = () => { + const trimmed = url.trim(); + if (!trimmed || busy) return; + onInstall(trimmed); + setOpen(false); + setUrl(''); + }; + + return ( +
+ + setUrl(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') submit(); + if (e.key === 'Escape') setOpen(false); + }} + placeholder="https://gitea.officer.dev/owner/plugin.git" + className="min-w-0 flex-1 rounded-md border border-duck-dark/10 bg-transparent px-2 py-1 font-mono text-xs outline-none focus:border-duck-dark/30" + /> + + +
+ ); +}; diff --git a/src/workspaces/officerdev/src/apps/Plugins/PluginGrid.tsx b/src/workspaces/officerdev/src/apps/Plugins/PluginGrid.tsx index a4c81e09..548fc8ff 100644 --- a/src/workspaces/officerdev/src/apps/Plugins/PluginGrid.tsx +++ b/src/workspaces/officerdev/src/apps/Plugins/PluginGrid.tsx @@ -2,6 +2,7 @@ import { Link, useSearchParams } from 'react-router'; import { Check, Download } from 'lucide-react'; import { usePlugins, type AvailablePlugin, type PluginItem } from './usePlugins'; import { resolveIcon } from '../../utils/resolve-icon'; +import { InstallFromUrl } from './InstallFromUrl'; /** * The store: cards in a grid, filtered by the tag on the left. @@ -80,7 +81,7 @@ const Card = ({ entry, search }: { entry: Entry; search: string }) => { }; export const PluginGrid = () => { - const { plugins, available, isLoading } = usePlugins(); + const { plugins, available, isLoading, installFromUrl, running } = usePlugins(); const [params] = useSearchParams(); const tag = params.get('tag'); @@ -106,9 +107,16 @@ export const PluginGrid = () => { return (
-
- {shown.length} plugin{shown.length === 1 ? '' : 's'} - {tag && tag !== '__untagged' ? ` in ${tag}` : ''} + {/* The count and the escape hatch share a row: the catalogue is the way in, and this is the + exception beside it rather than an equal option above it. */} +
+ + {shown.length} plugin{shown.length === 1 ? '' : 's'} + {tag && tag !== '__untagged' ? ` in ${tag}` : ''} + +
+ +
{shown.map((entry) => ( diff --git a/src/workspaces/officerdev/src/apps/Plugins/usePlugins.ts b/src/workspaces/officerdev/src/apps/Plugins/usePlugins.ts index a5b9d701..5e8969ea 100644 --- a/src/workspaces/officerdev/src/apps/Plugins/usePlugins.ts +++ b/src/workspaces/officerdev/src/apps/Plugins/usePlugins.ts @@ -204,23 +204,31 @@ export function usePlugins() { * The frame parser is deliberately small — split on blank lines, read `event:` and `data:`. It only has * to understand what our own endpoint emits. */ - const run = useCallback( - async (verb: PluginVerb, appName: string) => { + /** + * The frame reader, which does not care where the stream came from. + * + * Split out when "Install from Git repository" arrived: it posts to a different path with a body, and + * everything after the first byte is identical — same step and log events, same `done`. Two copies of + * this parser would be two places to get an incomplete frame wrong. + */ + const stream = useCallback( + async (path: string, label: PluginVerb, appName: string, body?: unknown) => { abort.current?.abort(); const controller = new AbortController(); abort.current = controller; setSteps([]); setResult(null); - setRunning(verb); + setRunning(label); try { - const res = await fetch(`/api/plugins/${appName}/${verb}/stream`, { + const res = await fetch(path, { method: 'POST', - headers: getHeaders(), + headers: body ? { ...getHeaders(), 'content-type': 'application/json' } : getHeaders(), + body: body ? JSON.stringify(body) : undefined, signal: controller.signal, }); - if (!res.body) throw new Error(`${verb} failed: no response body`); + if (!res.body) throw new Error(`${label} failed: no response body`); const reader = res.body.getReader(); const decoder = new TextDecoder(); @@ -265,6 +273,23 @@ export function usePlugins() { [invalidate], ); + const run = useCallback( + (verb: PluginVerb, appName: string) => stream(`/api/plugins/${appName}/${verb}/stream`, verb, appName), + [stream], + ); + + /** + * Install something that is in no catalogue, from its repository. + * + * The name is derived server-side from the URL and reported back in the first step, so the person sees + * what it became before the clone starts — it is not a field they filled in, and it decides the route, + * the table prefix and the process name. + */ + const installFromUrl = useCallback( + (url: string) => stream('/api/plugins/install-from-url/stream', 'install', url, { url }), + [stream], + ); + // Update-all is a PLAIN POST, not the streaming door the single verbs use. Twelve interleaved step // streams is not a readable log, and the useful answer here is the summary: how many moved, and which // refused. A dirty checkout is the expected refusal — every plugin directory on a developer's machine @@ -301,6 +326,7 @@ export function usePlugins() { /** Which verb is in flight, or null. */ running, run, + installFromUrl, updateAll, updatingAll, updateAllResult,