the git-repo button FETCHES, and the card does the installing
it installed, and using it showed why that was wrong: the button is on the grid, the progress log is in the detail panel — so a URL install ran to completion with nothing on screen and the plugin simply appeared, already installed. no feedback, and no moment to change your mind about running somebody's code. cloning alone is enough to make a plugin real. `/api/plugins` lists what is ON DISK, so a fetched plugin shows up with `installed: false` — Available, exactly like a catalogue entry — and the existing card, its Install button and its streamed progress do the rest. one install path, and it is the one people have already used. so `install-from-url` and its streaming twin are gone, replaced by `fetch-from-url`, which clones and stops. the streaming route existed only to carry progress into a view that could not render it. the outcome is REPORTED, since that was the complaint: "fofos-admin added — click it to install", naming what the URL resolved to. the name is derived rather than typed — appName IS the directory, deciding the route, the table prefix and the process name — so being told what it became is the only way to know which card is yours. it stays on screen until the next attempt rather than being a toast that is gone before you look up. discovery is re-run after the clone: fetchPlugin checks the manifest EXISTS, discovery checks it parses. a directory failing that still renders in the grid as broken, which is honest, but saying so at fetch time reaches the person while they are still looking at the field they pasted into. verified: fetch 200 with the derived name, the plugin listed installed=false, a second fetch refused with "already on disk" rather than clobbering it, and the icon present in the clone. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
This commit is contained in:
@@ -6,6 +6,8 @@ import { snapshotPlugins } from '../../plugins/mount';
|
||||
import { manualInstallHint, reportDependencies } from '../../plugins/os-deps';
|
||||
import { fetchCatalogue, fetchReadme } from '../../plugins/marketplace';
|
||||
import { findOrphanTables } from '../../plugins/orphans';
|
||||
import { fetchPlugin } from '../../plugins/fetch';
|
||||
import { discoverPlugins } from '../../plugins/discover';
|
||||
import { iconUrl } from '../../app-store/assets';
|
||||
import {
|
||||
installPlugin,
|
||||
@@ -224,29 +226,6 @@ pluginsRouter.post('/update-all', async (ctx) => {
|
||||
});
|
||||
});
|
||||
|
||||
/*
|
||||
POST /plugins/install-from-url — a plugin that is in no registry.
|
||||
|
||||
The catalogue was the only way a remote plugin could arrive, so "build one and give it to a friend"
|
||||
had no route at all. The first real case is a plugin that must never be listed: fofos-admin is one
|
||||
website's admin panel, useful to exactly one person, and putting it in a public registry to install it
|
||||
would be the wrong shape.
|
||||
|
||||
── The name comes from the URL, because the name IS the directory ──
|
||||
|
||||
`appName` is taken from the directory a plugin sits in and never declared in its manifest, so the id
|
||||
cannot disagree with where the code is. That decision is made for us here: the repository name becomes
|
||||
the directory, and therefore the id, the route segment, the table prefix and the sidecar suffix. A
|
||||
repository called something unusable as an id is refused before anything is cloned rather than after —
|
||||
a half-clone under a bad name is a plugin the platform reports as broken instead of absent.
|
||||
|
||||
── Owner-only, and it is not the same owner-only as the rest of this router ──
|
||||
|
||||
Every route here is owner-gated because installing a plugin mounts routes and starts a process. This
|
||||
one also takes an ADDRESS from the caller, so the owner is choosing what code runs, not choosing from
|
||||
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`.
|
||||
*
|
||||
@@ -255,7 +234,7 @@ pluginsRouter.post('/update-all', async (ctx) => {
|
||||
* 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 } {
|
||||
function pluginNameFromUrl(url: string, override?: string): { appName: string } | { error: string } {
|
||||
const derived = url
|
||||
.replace(/\.git$/, '')
|
||||
.replace(/\/+$/, '')
|
||||
@@ -272,7 +251,34 @@ export function pluginNameFromUrl(url: string, override?: string): { appName: st
|
||||
return { appName };
|
||||
}
|
||||
|
||||
pluginsRouter.post('/install-from-url', async (ctx) => {
|
||||
/*
|
||||
POST /plugins/fetch-from-url — put a plugin on the machine, and stop there.
|
||||
|
||||
The catalogue was the only way a remote plugin could arrive, so "build one and give it to a friend"
|
||||
had no route. The first real case is one that must never be listed: fofos-admin is a single website's
|
||||
admin panel.
|
||||
|
||||
── Why this FETCHES and does not install ──
|
||||
|
||||
It was an install first, and that was wrong in a way only using it showed: the button lives on the
|
||||
grid, the progress log lives in the detail panel, so a URL install ran to completion with nothing on
|
||||
screen and the plugin simply appeared, installed. Streaming into a view that cannot render it is worse
|
||||
than not streaming.
|
||||
|
||||
Cloning alone is enough to make a plugin real — `/api/plugins` lists what is ON DISK with
|
||||
`installed: false`, so it shows up as Available like any catalogue entry. From there the existing card,
|
||||
the existing Install button and the existing streamed progress do the rest. One flow, not two.
|
||||
|
||||
── The name still comes from the URL ──
|
||||
|
||||
`appName` IS the directory, so the repository name decides the route segment, the table prefix and the
|
||||
process name. Checked before cloning: a half-clone under an unusable name reads as a BROKEN plugin
|
||||
rather than an absent one.
|
||||
|
||||
Owner-only like everything else here, and worth saying plainly — this puts somebody's code on the
|
||||
machine. It does not run it, which is exactly why installing stays a separate, deliberate click.
|
||||
*/
|
||||
pluginsRouter.post('/fetch-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();
|
||||
@@ -280,9 +286,30 @@ pluginsRouter.post('/install-from-url', async (ctx) => {
|
||||
|
||||
const named = pluginNameFromUrl(url, body.appName);
|
||||
if ('error' in named) throw errors.BAD_REQUEST(named.error);
|
||||
const { appName } = named;
|
||||
|
||||
const result = await installPlugin(named.appName, undefined, ctx.get('user').id, undefined, url);
|
||||
return ctx.json(result, result.ok ? 200 : 400);
|
||||
const fetched = await fetchPlugin(appName, undefined, url);
|
||||
if (!fetched.ok) throw errors.BAD_REQUEST(fetched.error ?? `could not fetch ${appName}`);
|
||||
|
||||
/*
|
||||
Re-read from disk rather than trusting the clone.
|
||||
|
||||
`fetchPlugin` checks that `manifest.ts` EXISTS; discovery checks that it PARSES and says what a
|
||||
plugin needs to say. A directory that fails the second test still renders in the grid — as broken,
|
||||
which is the honest state — but saying so here means the person hears it while they are still
|
||||
looking at the field they pasted into.
|
||||
*/
|
||||
const { plugins, broken } = await discoverPlugins();
|
||||
const found = plugins.find((entry) => entry.appName === appName);
|
||||
const bad = broken.find((entry) => entry.appName === appName);
|
||||
if (!found) {
|
||||
return ctx.json(
|
||||
{ ok: false, appName, error: bad?.error ?? `cloned "${appName}", but it does not read as a plugin` },
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
return ctx.json({ ok: true, appName, label: found.manifest?.label ?? appName });
|
||||
});
|
||||
|
||||
pluginsRouter.post('/:appName/install', async (ctx) => {
|
||||
@@ -355,77 +382,6 @@ 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