show what the commands are actually saying

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>
This commit is contained in:
2026-08-16 10:01:07 +00:00
co-authored by Claude Opus 5
parent 3c03e61c5e
commit c98b5643aa
7 changed files with 179 additions and 40 deletions
+12 -9
View File
@@ -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<FetchResult> {
export async function fetchPlugin(appName: string, onLog?: OnLog): Promise<FetchResult> {
const dir = join(PLUGINS_DIR, appName);
if (existsSync(dir)) {
@@ -59,26 +60,28 @@ export async function fetchPlugin(appName: string): Promise<FetchResult> {
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'))) {
+12 -6
View File
@@ -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<boolean> {
* `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<PluginActionResult> {
export async function installPlugin(
appName: string,
onStep?: OnStep,
forUserId?: number,
onLog?: OnLog,
): Promise<PluginActionResult> {
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<PluginActionResult> {
export async function uninstallPlugin(appName: string, onStep?: OnStep, onLog?: OnLog): Promise<PluginActionResult> {
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<PluginActionResult> {
export async function purgePlugin(appName: string, onStep?: OnStep, onLog?: OnLog): Promise<PluginActionResult> {
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<Plu
// Stop it first. Dropping a table under a running sidecar gives it a 42P01 instead of a shutdown.
if (await getPluginInstall(appName)) {
const uninstalled = await uninstallPlugin(appName, onStep);
const uninstalled = await uninstallPlugin(appName, onStep, onLog);
if (!uninstalled.ok) return { ...uninstalled, steps: [...steps, ...uninstalled.steps] };
steps.push(...uninstalled.steps);
} else {
+103
View File
@@ -0,0 +1,103 @@
export type OnLog = (line: string) => void | Promise<void>;
/**
* 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<string, string | undefined>; onLog?: OnLog } = {},
): Promise<RunResult> {
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<Uint8Array>) => {
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,
};
}
+7 -12
View File
@@ -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<PushResult> {
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<PushResult> {
// 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) };
}