"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:
2026-08-28 23:27:16 +01:00
co-authored by Claude Opus 5
parent d273dc6a14
commit 5c6c5b31f8
4 changed files with 221 additions and 29 deletions
+99 -19
View File
@@ -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');