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:
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user