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//` 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//`. * * 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 { 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 { await rm(join(PLUGINS_DIR, appName), { recursive: true, force: true }); }