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:
2026-08-28 23:44:43 +01:00
co-authored by Claude Opus 5
parent 5c6c5b31f8
commit cac58ea044
4 changed files with 139 additions and 137 deletions
+54 -98
View File
@@ -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');