diff --git a/src/servers/api/plugins/router.ts b/src/servers/api/plugins/router.ts index cf891c77..98344ddc 100644 --- a/src/servers/api/plugins/router.ts +++ b/src/servers/api/plugins/router.ts @@ -205,14 +205,19 @@ pluginsRouter.post('/:appName/disable', async (ctx) => { // `userId` is the caller, threaded through so install can pin the new tile to THEIR dock rather than // guessing at the owner. Only install uses it; the others take it and ignore it so the map stays one shape. -type VerbFn = (appName: string, onStep: (s: string) => Promise, userId: number) => Promise; +type VerbFn = ( + appName: string, + onStep: (s: string) => Promise, + userId: number, + onLog: (line: string) => Promise, +) => Promise; const VERBS = { - install: (appName, onStep, userId) => installPlugin(appName, onStep, userId), - uninstall: (appName, onStep) => uninstallPlugin(appName, onStep), + install: (appName, onStep, userId, onLog) => installPlugin(appName, onStep, userId, onLog), + uninstall: (appName, onStep, _userId, onLog) => uninstallPlugin(appName, onStep, onLog), enable: (appName, onStep) => setPluginRunning(appName, true, onStep), disable: (appName, onStep) => setPluginRunning(appName, false, onStep), - purge: (appName, onStep) => purgePlugin(appName, onStep), + purge: (appName, onStep, _userId, onLog) => purgePlugin(appName, onStep, onLog), // `satisfies` rather than an annotation: an annotation would widen the keys to `string`, which turns // `isVerb` into a guard that proves nothing and lets an unknown verb index the map. } satisfies Record; @@ -244,7 +249,15 @@ pluginsRouter.post('/:appName/:verb/stream', async (ctx) => { let result: PluginActionResult; try { - result = await VERBS[verb](appName, async (step) => send('step', { step }), userId); + result = await VERBS[verb]( + appName, + async (step) => send('step', { step }), + userId, + // Raw output from whatever the step is running — `git clone`, `db:push`, a package manager. + // A separate event so the client can render it as what it is: the command talking, not the + // platform reporting. + async (line) => send('log', { line }), + ); } catch (err) { result = { ok: false, appName, steps: [], error: err instanceof Error ? err.message : String(err) }; } diff --git a/src/servers/plugins/fetch.ts b/src/servers/plugins/fetch.ts index 1ae1f014..35713b6f 100644 --- a/src/servers/plugins/fetch.ts +++ b/src/servers/plugins/fetch.ts @@ -3,6 +3,7 @@ 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. // @@ -39,7 +40,7 @@ const gitAvailable = (): boolean => !!Bun.which('git'); * 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 { +export async function fetchPlugin(appName: string, onLog?: OnLog): Promise { const dir = join(PLUGINS_DIR, appName); if (existsSync(dir)) { @@ -59,26 +60,28 @@ export async function fetchPlugin(appName: string): Promise { return { ok: false, dir, error: `refusing to clone from "${entry.source}" — https URLs only, no credentials` }; } - const proc = Bun.spawn( + 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. - ['git', 'clone', '--depth', '1', '--quiet', entry.source, dir], + // + // `--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], { - 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' }, + env: { GIT_TERMINAL_PROMPT: '0' }, + onLog, }, ); - const [out, err] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text()]); - const code = await proc.exited; + 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: ${`${err}${out}`.trim().slice(-300) || `exit ${code}`}` }; + return { ok: false, dir, error: `git clone failed: ${result.output.slice(-300) || `exit ${code}`}` }; } if (!existsSync(join(dir, 'manifest.ts'))) { diff --git a/src/servers/plugins/install.ts b/src/servers/plugins/install.ts index 25a4de3f..8b248f41 100644 --- a/src/servers/plugins/install.ts +++ b/src/servers/plugins/install.ts @@ -16,6 +16,7 @@ import { publishAssets, unpublishAssets } from '../app-store/assets'; import { refreshPluginMounts, snapshotPlugins } from './mount'; import { generatePluginSchemas, pushSchema } from './schema'; import { declaredTables, dropTables, existingTables } from './tables'; +import type { OnLog } from './run-logged'; import { installDependencies, manualInstallHint, reportDependencies, stillMissing } from './os-deps'; import { mountPrefix, type DiscoveredPlugin } from './manifest'; @@ -132,7 +133,12 @@ async function addToDock(userId: number, route: string): Promise { * `enabled` is deliberately untouched on the upgrade path — re-installing something the owner had * switched off must not switch it back on. */ -export async function installPlugin(appName: string, onStep?: OnStep, forUserId?: number): Promise { +export async function installPlugin( + appName: string, + onStep?: OnStep, + forUserId?: number, + onLog?: OnLog, +): Promise { const steps: string[] = []; // FETCH FIRST, when the plugin is not here yet. @@ -147,7 +153,7 @@ export async function installPlugin(appName: string, onStep?: OnStep, forUserId? // as it was. let plugin = await findPlugin(appName); if (!plugin) { - const fetched = await fetchPlugin(appName); + const fetched = await fetchPlugin(appName, onLog); if (!fetched.ok) { return { ok: false, appName, steps, error: fetched.error ?? `cannot fetch "${appName}"` }; } @@ -222,7 +228,7 @@ export async function installPlugin(appName: string, onStep?: OnStep, forUserId? const { plugins } = await discoverPlugins(); generatePluginSchemas(plugins); - const pushed = await pushSchema(); + const pushed = await pushSchema(onLog); if (!pushed.ok) { return { ok: false, appName, steps, error: `schema push failed: ${pushed.output}` }; } @@ -321,7 +327,7 @@ export async function installPlugin(appName: string, onStep?: OnStep, forUserId? * Keeps every table and row the plugin owns, and keeps the directory. Reinstalling is therefore a restore * rather than a fresh start, which is the whole reason not to drop anything here. */ -export async function uninstallPlugin(appName: string, onStep?: OnStep): Promise { +export async function uninstallPlugin(appName: string, onStep?: OnStep, onLog?: OnLog): Promise { const steps: string[] = []; const plugin = await findPlugin(appName); @@ -372,7 +378,7 @@ export async function uninstallPlugin(appName: string, onStep?: OnStep): Promise * longer exists is exactly the failure that stopped a server booting on 2026-08-16. Leaving it stale here * would break the next `db:push` rather than this command. */ -export async function purgePlugin(appName: string, onStep?: OnStep): Promise { +export async function purgePlugin(appName: string, onStep?: OnStep, onLog?: OnLog): Promise { const steps: string[] = []; const plugin = await findPlugin(appName); if (!plugin) return { ok: false, appName, steps, error: `"${appName}" is not on this machine` }; @@ -387,7 +393,7 @@ export async function purgePlugin(appName: string, onStep?: OnStep): Promise void | Promise; + +/** + * Run a command and stream what it says, line by line, while it says it. + * + * ── Why the output has to be live rather than kept for the error path ── + * + * Every spawn in the install path used to capture stdout and stderr, hold them, and surface 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 actually want during an install: knowing a slow step is still moving, and seeing the one + * warning that 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. + * + * ── Both streams, in arrival order ── + * + * stdout and stderr are read concurrently and emitted as they arrive, which means they interleave the way + * they do in a terminal rather than appearing as two blocks. drizzle-kit writes its progress to stderr and + * its results to stdout, and git writes everything to stderr, so a version that only followed stdout would + * be silent for exactly the commands worth watching. + * + * The full text is still returned. Live output is for the person watching; the returned string is what an + * error message quotes, and those are different jobs. + */ +export type RunResult = { ok: boolean; code: number; output: string; ms: number }; + +const ANSI = /\[[0-9;]*[A-Za-z]/g; + +/** + * Progress lines that would otherwise arrive hundreds at a time. + * + * `git clone` writes `Receiving objects: 4% (91/2143)` with a carriage return, once per percent — as + * SSE frames that is a flood of near-identical lines that pushes everything else off the screen. Splitting + * on `\r` as well as `\n` and dropping all but the last fragment of a chunk keeps the counter useful and + * stops it dominating the log. + */ +const lastFragment = (chunk: string): string[] => { + const lines: string[] = []; + for (const line of chunk.split('\n')) { + const parts = line.split('\r').filter((p) => p.trim().length > 0); + if (parts.length === 0) continue; + lines.push(parts[parts.length - 1]!); + } + return lines; +}; + +export async function runLogged( + command: string[], + options: { cwd?: string; env?: Record; onLog?: OnLog } = {}, +): Promise { + const started = Date.now(); + const proc = Bun.spawn(command, { + cwd: options.cwd, + env: options.env ? { ...process.env, ...options.env } : undefined, + stdout: 'pipe', + stderr: 'pipe', + }); + + const collected: string[] = []; + + // Spinner frames are one line each. + // + // drizzle-kit writes `[⣷] Pulling schema from database...` and then rewrites it seven more times with a + // different braille glyph. In a terminal that is one animated line; as a log it is eight lines saying + // the same thing, and the step that MATTERS ("Changes applied") scrolls past under them. Consecutive + // lines that differ only by the spinner glyph collapse to the first. + const spinner = /^\[[\u2800-\u28ff]\]\s*/; + let previous = ''; + + const pump = async (stream: ReadableStream) => { + const decoder = new TextDecoder(); + for await (const chunk of stream) { + const text = decoder.decode(chunk, { stream: true }).replace(ANSI, ''); + collected.push(text); + if (!options.onLog) continue; + for (const line of lastFragment(text)) { + const normalised = line.replace(spinner, ''); + if (normalised && normalised === previous) continue; + previous = normalised; + + // A failure to deliver a log line must never fail the command that produced it — the client may + // have navigated away mid-install, and the work continues without it. + try { + await options.onLog(line); + } catch { + /* nobody listening */ + } + } + } + }; + + await Promise.all([pump(proc.stdout), pump(proc.stderr)]); + const code = await proc.exited; + + return { + ok: code === 0, + code, + output: collected.join('').replace(ANSI, '').trim(), + ms: Date.now() - started, + }; +} diff --git a/src/servers/plugins/schema.ts b/src/servers/plugins/schema.ts index accd6f58..de422cac 100644 --- a/src/servers/plugins/schema.ts +++ b/src/servers/plugins/schema.ts @@ -1,6 +1,7 @@ import { existsSync, readFileSync, writeFileSync } from 'node:fs'; import { dirname, join, relative } from 'node:path'; import { PLATFORM_DIR } from '../data-path'; +import { runLogged, type OnLog } from './run-logged'; import type { DiscoveredPlugin } from './manifest'; // Creating a plugin's tables. @@ -61,16 +62,10 @@ export type PushResult = { ok: boolean; ms: number; output: string }; * unless a plugin's source is deleted — and a prompt with no terminal attached would hang the install * forever rather than fail it. */ -export async function pushSchema(): Promise { - const started = Date.now(); - const proc = Bun.spawn(['bun', 'run', 'db:push', '--force'], { - cwd: PLATFORM_DIR, - stdout: 'pipe', - stderr: 'pipe', - }); - const [out, err] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text()]); - const code = await proc.exited; - // drizzle writes its progress to stderr, so a non-empty stderr is not a failure — only the exit code is. - const output = `${out}${err}`.replace(/\u001b\[[0-9;]*[A-Za-z]/g, '').trim(); - return { ok: code === 0, ms: Date.now() - started, output: output.slice(-600) }; +export async function pushSchema(onLog?: OnLog): Promise { + // Streamed rather than captured. drizzle writes progress to stderr and results to stdout, and this is + // the command that reported success while creating nothing — so what it says is worth showing at the + // time it says it, not summarised into a exit code afterwards. + const result = await runLogged(['bun', 'run', 'db:push', '--force'], { cwd: PLATFORM_DIR, onLog }); + return { ok: result.ok, ms: result.ms, output: result.output.slice(-600) }; } diff --git a/src/workspaces/officerdev/src/apps/Plugins/PluginDetail.tsx b/src/workspaces/officerdev/src/apps/Plugins/PluginDetail.tsx index 02171c54..9ea4431d 100644 --- a/src/workspaces/officerdev/src/apps/Plugins/PluginDetail.tsx +++ b/src/workspaces/officerdev/src/apps/Plugins/PluginDetail.tsx @@ -10,6 +10,7 @@ import { type AvailablePlugin, type PluginActionResult, type PluginDependencies, + type ActionEntry, type PluginItem, type PluginVerb, } from './usePlugins'; @@ -136,7 +137,7 @@ const ActionLog = ({ result, running, }: { - steps: string[]; + steps: ActionEntry[]; result: PluginActionResult | null; running: PluginVerb | null; }) => { @@ -154,9 +155,14 @@ const ActionLog = ({ {result?.ok ? 'Done' : 'Failed'} )} -
    - {steps.map((s, i) => ( -
  • · {s}
  • + {/* Raw output is dimmer and indented under the step that produced it — present when you want it, + not competing with the narration when you do not. `break-all` because a clone URL or a drizzle + error is one long token and would otherwise stretch the panel. */} +
      + {steps.map((entry, i) => ( +
    • + {entry.kind === 'step' ? `· ${entry.text}` : entry.text} +
    • ))}
    {result?.error ?
    {result.error}
    : null} @@ -190,7 +196,7 @@ const AvailableDetail = ({ plugin: AvailablePlugin; busy: boolean; onInstall: () => void; - steps: string[]; + steps: ActionEntry[]; result: PluginActionResult | null; running: PluginVerb | null; }) => ( diff --git a/src/workspaces/officerdev/src/apps/Plugins/usePlugins.ts b/src/workspaces/officerdev/src/apps/Plugins/usePlugins.ts index 2522f39e..aa43df6d 100644 --- a/src/workspaces/officerdev/src/apps/Plugins/usePlugins.ts +++ b/src/workspaces/officerdev/src/apps/Plugins/usePlugins.ts @@ -77,6 +77,15 @@ export type PluginActionResult = { ok: boolean; appName: string; steps: string[] export type PluginVerb = 'install' | 'uninstall' | 'enable' | 'disable' | 'purge'; +/** + * One line in the action log. + * + * `step` is the platform narrating what it is doing. `log` is the raw output of whatever that step + * spawned — git, drizzle-kit, a package manager. Kept apart because they deserve different weight on + * screen, and kept in ONE list because their order relative to each other is the information. + */ +export type ActionEntry = { kind: 'step' | 'log'; text: string }; + export type BrokenPlugin = { appName: string; error: string }; /** @@ -133,7 +142,7 @@ export function usePlugins() { queryClient.invalidateQueries({ queryKey: ['DOCK'] }); }, [queryClient]); - const [steps, setSteps] = useState([]); + const [steps, setSteps] = useState([]); const [result, setResult] = useState(null); const [running, setRunning] = useState(null); const abort = useRef(null); @@ -188,8 +197,12 @@ export function usePlugins() { else if (line.startsWith('data:')) data += line.slice(5).trim(); } if (!data) continue; - const parsed = JSON.parse(data) as { step?: string } & PluginActionResult; - if (event === 'step' && parsed.step) setSteps((prev) => [...prev, parsed.step!]); + const parsed = JSON.parse(data) as { step?: string; line?: string } & PluginActionResult; + if (event === 'step' && parsed.step) setSteps((prev) => [...prev, { kind: 'step', text: parsed.step! }]); + // One list, in arrival order. Two lists would be re-interleaved by render order rather than by + // when things happened, and "which step was this output from" is the only question the log + // exists to answer. + else if (event === 'log' && parsed.line) setSteps((prev) => [...prev, { kind: 'log', text: parsed.line! }]); else if (event === 'done') setResult(parsed); } }