"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');
|
||||
|
||||
Reference in New Issue
Block a user