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
128 lines
6.6 KiB
TypeScript
128 lines
6.6 KiB
TypeScript
import { existsSync } from 'node:fs';
|
|
import { rm } from 'node:fs/promises';
|
|
import { join } from 'node:path';
|
|
import { PLUGINS_DIR } from './discover';
|
|
import { catalogueEntry } from './marketplace';
|
|
import { runLogged, type OnLog } from './run-logged';
|
|
|
|
// Getting a plugin's source onto the machine.
|
|
//
|
|
// ── Why a clone into the platform's own tree ──
|
|
//
|
|
// `plugins/<app-name>/` is INSIDE the platform repository and that is load-bearing, not incidental. Bun
|
|
// resolves `hooks/useClient`, `officerdev`, `officerdb/db` and the `@@/*` alias through the workspace
|
|
// links in the platform's node_modules, and resolution walks UP from the importing file. Measured:
|
|
//
|
|
// platform/plugins/music hooks/useClient OK officerdev OK officerdb/db OK
|
|
// ../plugins/music FAIL FAIL FAIL
|
|
//
|
|
// So a plugin cloned beside the platform cannot import anything the platform provides. Until the host
|
|
// API is a package a plugin can depend on, the clone destination is not a preference.
|
|
//
|
|
// ── What this deliberately does not do ──
|
|
//
|
|
// No build, no install of the plugin's own dependencies, no lockfile. A plugin today is source that
|
|
// compiles inside the platform's workspace, so there is nothing to resolve. The day a plugin carries its
|
|
// own package.json is the day this needs to think about it, and pretending otherwise now would be
|
|
// machinery guarding an empty case.
|
|
|
|
export type FetchResult = { ok: boolean; dir: string; error?: string };
|
|
|
|
/** `git` has to exist. It is not in `osDependencies` anywhere because it is the platform's need, not a plugin's. */
|
|
const gitAvailable = (): boolean => !!Bun.which('git');
|
|
|
|
/**
|
|
* Clone a catalogue plugin into `plugins/<appName>/`.
|
|
*
|
|
* Refuses rather than overwrites when the directory already exists. That is not caution for its own
|
|
* sake: the directory may be a plugin somebody is developing, and `git clone` over the top of local work
|
|
* is the kind of loss no undo covers. Re-fetching an existing plugin is an update, which is a different
|
|
* 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, source?: string): Promise<FetchResult> {
|
|
const dir = join(PLUGINS_DIR, appName);
|
|
|
|
if (existsSync(dir)) {
|
|
return { ok: false, dir, error: `${appName} is already on disk at plugins/${appName} — nothing to fetch` };
|
|
}
|
|
if (!gitAvailable()) {
|
|
return { ok: false, dir, error: 'git is not installed on this machine' };
|
|
}
|
|
|
|
/*
|
|
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
|
|
// plugin needing auth is a separate feature and must not arrive by someone putting a token in here.
|
|
//
|
|
// `.git` is OPTIONAL, not required — Gitea and GitHub both clone identically with or without it, so
|
|
// requiring the suffix enforces a naming convention, not a security property. Found the hard way:
|
|
// music's own marketplace row was hand-entered as `.../plugins/music`, no suffix, and this regex
|
|
// 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(url)) {
|
|
return { ok: false, dir, error: `refusing to clone from "${url}" — https URLs only, no credentials` };
|
|
}
|
|
|
|
const result = await runLogged(
|
|
// argv, never a shell. `--depth 1` because the platform wants the source, not the history — and on a
|
|
// plugin with a long history the difference is seconds against minutes on a slow link.
|
|
//
|
|
// `--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', 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.
|
|
env: { GIT_TERMINAL_PROMPT: '0' },
|
|
onLog,
|
|
},
|
|
);
|
|
const code = result.code;
|
|
|
|
if (code !== 0) {
|
|
// A half-written directory is worse than none: discovery would find it, fail to read a manifest, and
|
|
// report the plugin as broken rather than as absent.
|
|
await rm(dir, { recursive: true, force: true }).catch(() => {});
|
|
return { ok: false, dir, error: `git clone failed: ${result.output.slice(-300) || `exit ${code}`}` };
|
|
}
|
|
|
|
if (!existsSync(join(dir, 'manifest.ts'))) {
|
|
await rm(dir, { recursive: true, force: true }).catch(() => {});
|
|
return { ok: false, dir, error: `cloned, but ${appName} has no manifest.ts — not a plugin` };
|
|
}
|
|
|
|
return { ok: true, dir };
|
|
}
|
|
|
|
/**
|
|
* Remove a plugin's source from the tree.
|
|
*
|
|
* NOT called by uninstall, and that separation is the whole safety property. Uninstall stops a plugin;
|
|
* deleting its source is what makes `db:push` drop its tables, because the generated schema barrel
|
|
* follows directories rather than the install table. Observed on 2026-08-15: uninstalling music kept
|
|
* every row, and removing the directory dropped four tables on the next push.
|
|
*
|
|
* So this exists for a future "remove completely" verb that says out loud what it costs. Nothing calls
|
|
* it yet.
|
|
*/
|
|
export async function removePluginSource(appName: string): Promise<void> {
|
|
await rm(join(PLUGINS_DIR, appName), { recursive: true, force: true });
|
|
}
|