install a plugin from a git URL, with no registry involved
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 a plugin that must never be listed: fofos/fofos-admin is one website's admin panel, useful to exactly one person. MUCH smaller than the todo assumed. fetchPlugin already cloned, validated the URL, refused to overwrite an existing directory, checked for a manifest and removed a failed clone. exactly one line tied it to the marketplace — where the string came from. so this is a source parameter threaded through, and a route. THE NAME COMES FROM THE REPOSITORY, because appName is the DIRECTORY name and is never declared in a manifest — the id cannot disagree with where the code sits. that decision is inherited here rather than remade: the repo name becomes the directory, the route segment, the table prefix and the sidecar suffix. so it is checked against ^[a-z0-9]+(-[a-z0-9]+)*$ BEFORE anything is cloned; a half-clone under an unusable name reads as a broken plugin rather than an absent one. the URL is validated identically whether it came from the catalogue or from a person, because the catalogue is not a trust boundary — it is just where a string came from, and the same string reaches the same argv. what differs is who may ask: this route is owner-only, and the owner is choosing what code runs rather than choosing from a list somebody curated. validation is not review, and nothing here reads what it cloned. caught by testing: the source reached installPlugin and stopped there — the fetchPlugin call still had the old two arguments, so a URL install fell back to the catalogue and failed with "no catalogue entry". the guard tests are what surfaced it, because the error named the wrong cause. verified end to end against fofos-admin — cloned, schema applied, mounted, dock pinned — then purged, since 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:
@@ -40,7 +40,7 @@ const gitAvailable = (): boolean => !!Bun.which('git');
|
||||
* verb with different questions — what happens to uncommitted changes, does the version go backwards —
|
||||
* and it does not exist yet.
|
||||
*/
|
||||
export async function fetchPlugin(appName: string, onLog?: OnLog): Promise<FetchResult> {
|
||||
export async function fetchPlugin(appName: string, onLog?: OnLog, source?: string): Promise<FetchResult> {
|
||||
const dir = join(PLUGINS_DIR, appName);
|
||||
|
||||
if (existsSync(dir)) {
|
||||
@@ -50,8 +50,20 @@ export async function fetchPlugin(appName: string, onLog?: OnLog): Promise<Fetch
|
||||
return { ok: false, dir, error: 'git is not installed on this machine' };
|
||||
}
|
||||
|
||||
const entry = await catalogueEntry(appName);
|
||||
if (!entry) return { ok: false, dir, error: `no catalogue entry for "${appName}"` };
|
||||
/*
|
||||
A URL the caller supplied, or the catalogue's.
|
||||
|
||||
The catalogue is not the only way a plugin should be able to arrive. "Build one and give it to a
|
||||
friend" had no route at all, and the first real case is a plugin that must NOT be listed: fofos-admin
|
||||
is one website's admin panel and belongs in nobody's registry.
|
||||
|
||||
The URL is checked identically either way, below. It has to be — the catalogue is not a trust
|
||||
boundary here, it is just where a string came from, and the same string reaches the same command
|
||||
line. What DOES differ is who is asking: an install from a URL is somebody typing an address, so the
|
||||
route that accepts one is owner-only.
|
||||
*/
|
||||
const url = source?.trim() || (await catalogueEntry(appName))?.source;
|
||||
if (!url) return { ok: false, dir, error: `no catalogue entry for "${appName}", and no source URL given` };
|
||||
|
||||
// The URL comes from the catalogue and reaches a command line, so it is checked rather than trusted.
|
||||
// Deliberately narrow: https only, no credentials in the URL, no shell metacharacters. A private
|
||||
@@ -63,8 +75,8 @@ export async function fetchPlugin(appName: string, onLog?: OnLog): Promise<Fetch
|
||||
// refused a completely valid, credential-free, https clone URL with a message ("https URLs only, no
|
||||
// credentials") that describes neither actual reason. Row fixed for consistency with its siblings, but
|
||||
// the regex should never have depended on it.
|
||||
if (!/^https:\/\/[A-Za-z0-9._~\-/]+(\.git)?$/.test(entry.source)) {
|
||||
return { ok: false, dir, error: `refusing to clone from "${entry.source}" — https URLs only, no credentials` };
|
||||
if (!/^https:\/\/[A-Za-z0-9._~\-/]+(\.git)?$/.test(url)) {
|
||||
return { ok: false, dir, error: `refusing to clone from "${url}" — https URLs only, no credentials` };
|
||||
}
|
||||
|
||||
const result = await runLogged(
|
||||
@@ -74,7 +86,7 @@ export async function fetchPlugin(appName: string, onLog?: OnLog): Promise<Fetch
|
||||
// `--progress` instead of `--quiet`: git only reports progress when it believes it has a terminal, and
|
||||
// this is the step where a slow link looks identical to a hang. `runLogged` collapses the per-percent
|
||||
// rewrites, so the counter is readable rather than a flood.
|
||||
['git', 'clone', '--depth', '1', '--progress', entry.source, dir],
|
||||
['git', 'clone', '--depth', '1', '--progress', url, dir],
|
||||
{
|
||||
// No terminal to answer a credential prompt: without this, a repository that turned private hangs
|
||||
// the install forever rather than failing it. Same reasoning as `sudo -n` in os-deps.ts.
|
||||
|
||||
@@ -145,6 +145,14 @@ export async function installPlugin(
|
||||
onStep?: OnStep,
|
||||
forUserId?: number,
|
||||
onLog?: OnLog,
|
||||
/**
|
||||
* Clone from HERE instead of from the catalogue.
|
||||
*
|
||||
* For a plugin that is not in any registry and should not be — one website's admin panel, something
|
||||
* built for one person. Ignored when the plugin is already on disk, because then there is nothing to
|
||||
* fetch and a URL would be describing a decision already made.
|
||||
*/
|
||||
source?: string,
|
||||
): Promise<PluginActionResult> {
|
||||
const steps: string[] = [];
|
||||
|
||||
@@ -160,7 +168,7 @@ export async function installPlugin(
|
||||
// as it was.
|
||||
let plugin = await findPlugin(appName);
|
||||
if (!plugin) {
|
||||
const fetched = await fetchPlugin(appName, onLog);
|
||||
const fetched = await fetchPlugin(appName, onLog, source);
|
||||
if (!fetched.ok) {
|
||||
return { ok: false, appName, steps, error: fetched.error ?? `cannot fetch "${appName}"` };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user