export type OnLog = (line: string) => 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, }; }