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,