plugins page becomes a marketplace: catalogue, clone-on-install
The /plugins route showed only what was already on disk, so a plugin nobody had copied in by hand did not exist. It now shows a third list — what a remote catalogue offers — and installing one fetches its source. - plugins/marketplace.ts — a stand-in for the remote store, a JSON array in process. One entry: music, pointing at gitea.officer.dev/plugins/music. It is deliberately a module and not a fetch, so the shape of a catalogue entry gets settled before anything depends on a server answering. - plugins/fetch.ts — git clone --depth 1 into plugins/<appName>/, INSIDE the platform repo because bun resolves the workspace links by walking up and a plugin cloned beside it cannot import anything the platform provides (measured, not assumed). https only, no credentials, GIT_TERMINAL_PROMPT=0 so a repo that turned private fails instead of hanging. A failed clone removes the directory: a half-written one reads as a broken plugin rather than an absent one, which is the worse of the two. - install.ts fetches first when the directory is missing, then RE-DISCOVERS rather than assuming what it cloned. Everything after is the existing path, including the frontend rebuild — so a cloned plugin's panels reach the SPA without a restart. - GET /api/plugins returns `available`, catalogue minus what is on disk. Each entry gets the same live PATH probe a local plugin gets, so music's ffmpeg and ffprobe are answered for this machine BEFORE the clone rather than after. - The detail panel is a separate component for a catalogue entry, not a mode of the installed one: there is no sidecar status, no enabled state and no route to open, and what there IS — the source URL — the installed panel has no reason to show. The streaming log is shared, because the marketplace install is the case that needs it most: it clones before it does anything, and a slow clone behind a blank panel looks like a hang. The catalogue's icon is an absolute URL to the store's copy, which is the [open] question the previous commit left: assets only publish at install, so a plugin you have never installed had no artwork to draw. Not done: no update verb, no signature, no private repos, and removePluginSource has no caller — deleting source drops tables on the next push, and that wants a verb that says so. tsgo clean. 767 pass / 6 fail, all 6 identical on HEAD without this change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,105 @@
|
||||
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';
|
||||
|
||||
// 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): 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' };
|
||||
}
|
||||
|
||||
const entry = await catalogueEntry(appName);
|
||||
if (!entry) return { ok: false, dir, error: `no catalogue entry for "${appName}"` };
|
||||
|
||||
// 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.
|
||||
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` };
|
||||
}
|
||||
|
||||
const proc = Bun.spawn(
|
||||
// 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.
|
||||
['git', 'clone', '--depth', '1', '--quiet', entry.source, dir],
|
||||
{
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
// 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: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
|
||||
},
|
||||
);
|
||||
const [out, err] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text()]);
|
||||
const code = await proc.exited;
|
||||
|
||||
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: ${`${err}${out}`.trim().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 });
|
||||
}
|
||||
Reference in New Issue
Block a user