Install, uninstall and purge narrated what the platform was doing and hid what it was running. Every spawn captured stdout and stderr, held them, and surfaced the last 600 characters only if the exit code was non-zero. That is enough to explain a crash and useless for the two things people want while watching an install: knowing a slow step is still moving, and seeing the warning a command prints on its way to exiting 0. The second is not hypothetical — `db:push` reported success on a machine where it created no tables. Its output was captured, discarded because the exit code was fine, and the failure surfaced minutes later as 42P01 in a sidecar log. Whatever drizzle said about that push, nobody ever read it. `runLogged` streams both pipes as they arrive, so they interleave the way they do in a terminal. Following only stdout would have been silent for exactly the commands worth watching: git writes everything to stderr, drizzle-kit writes progress there and results to stdout. Two pieces of noise control, because a log nobody can read is not an improvement: `git clone` gets `--progress` instead of `--quiet` (git says nothing when it thinks it has no terminal, and that is the step where a slow link looks like a hang) with per-percent rewrites collapsed to the last fragment per chunk; and drizzle's spinner, which is eight lines saying the same thing with a different braille glyph, collapses to the first — otherwise "Changes applied" scrolls past underneath it. Steps and output share ONE ordered list rather than two. Their order relative to each other is the whole information: "which step was this output from" is the only question the log exists to answer. Output renders dimmer and indented under the step that produced it. Verified by running a real purge and reinstall: the clone's object counts, the barrel generation naming both plugins, drizzle's push, then "schema: applied in 2029ms (1 table verified)". tsgo clean, frontend builds, 873 pass / 7 fail unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
109 lines
5.3 KiB
TypeScript
109 lines
5.3 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.
|
|
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 });
|
|
}
|