Files
platform/src/servers/plugins/fetch.ts
T
pastilhasandClaude Opus 5 3803d42243 plugins: don't require a literal .git suffix on a catalogue clone URL
Found chasing a real install failure: music's marketplace row was hand-
entered as https://gitea.officer.dev/plugins/music with no .git suffix, and
fetchPlugin's URL check refused it outright with a message ("https URLs
only, no credentials") that named neither actual reason.

The suffix was never a security property — https-only, safe character set,
no embedded credentials are the real checks, and Gitea/GitHub both clone
identically with or without .git. Made the suffix optional rather than
required, so a future hand-entered row missing it doesn't hit the same wall.

Also fixed the row itself in officer_marketplace (added .git, matching every
other plugin's entry) for consistency, and confirmed the gitea repo itself
was never the problem — plugins/music already exists there with both
commits, unaffected by this bug.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 12:47:34 +00:00

116 lines
5.9 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): 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.
//
// `.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(entry.source)) {
return { ok: false, dir, error: `refusing to clone from "${entry.source}" — 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', entry.source, 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 });
}