"Install from Git repository", in the marketplace
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 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
This commit is contained in:
@@ -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<FromUrl>().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<FromUrl>().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');
|
||||
|
||||
@@ -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 (
|
||||
<button
|
||||
onClick={() => setOpen(true)}
|
||||
className="flex shrink-0 items-center gap-1.5 rounded-md border border-duck-dark/10 px-2.5 py-1 text-xs text-duck-dark/60 transition-colors hover:bg-duck-dark/5 hover:text-duck-dark"
|
||||
>
|
||||
<GitBranch className="h-3.5 w-3.5" />
|
||||
Install from Git repository
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
const submit = () => {
|
||||
const trimmed = url.trim();
|
||||
if (!trimmed || busy) return;
|
||||
onInstall(trimmed);
|
||||
setOpen(false);
|
||||
setUrl('');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2">
|
||||
<GitBranch className="h-3.5 w-3.5 shrink-0 text-duck-dark/40" />
|
||||
<input
|
||||
autoFocus
|
||||
value={url}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
<button
|
||||
onClick={submit}
|
||||
disabled={!url.trim() || busy}
|
||||
className="shrink-0 rounded-md bg-duck-dark/10 px-2.5 py-1 text-xs font-medium transition-colors hover:bg-duck-dark/20 disabled:opacity-40"
|
||||
>
|
||||
Install
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setOpen(false)}
|
||||
aria-label="Cancel"
|
||||
className="shrink-0 rounded p-1 text-duck-dark/40 hover:text-duck-dark"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -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 (
|
||||
<div className="h-full overflow-auto p-4">
|
||||
<div className="mb-3 text-xs text-duck-dark/40">
|
||||
{/* 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. */}
|
||||
<div className="mb-3 flex items-center gap-3 text-xs text-duck-dark/40">
|
||||
<span className="shrink-0">
|
||||
{shown.length} plugin{shown.length === 1 ? '' : 's'}
|
||||
{tag && tag !== '__untagged' ? ` in ${tag}` : ''}
|
||||
</span>
|
||||
<div className="ml-auto flex min-w-0 flex-1 justify-end">
|
||||
<InstallFromUrl onInstall={installFromUrl} busy={!!running} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-[repeat(auto-fill,minmax(230px,1fr))] gap-3">
|
||||
{shown.map((entry) => (
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user