// Plain, line-based progress output: easy to follow live and easy to tail when a long upload runs in the background // (`storebound play images > .storebound/images.log 2>&1 &`). Every line carries a timestamp and a tag. const t0 = Date.now(); const stamp = (): string => new Date().toISOString().slice(11, 19); const since = (): string => `${((Date.now() - t0) / 1000).toFixed(0)}s`; /** * Colors, status emoji and a spinner, only for a person at a terminal: stdout is a TTY and nothing asks for plain text * (NO_COLOR, CI, TERM=dumb, a --json run, `storebound serve` / MCP children). Piped and logged output stays exactly * the plain lines above, so `tail -f` and agents read the same text as before. */ export function fancy(env: Record = process.env, tty = !!process.stdout.isTTY): boolean { return tty && !env.NO_COLOR && !env.CI && env.TERM !== 'dumb' && env.STOREBOUND_MACHINE !== '1' && env.STOREBOUND_MCP !== '1' && !jsonOutput(); } const C = { dim: '\x1b[2m', cyan: '\x1b[36m', yellow: '\x1b[33m', green: '\x1b[32m', red: '\x1b[31m', bold: '\x1b[1m', off: '\x1b[0m' }; const FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']; /** A wait line ("waiting for ...", "still processing", "3/14 ..." progress) is drawn as one live spinner line. */ const WAIT = /^(waiting|still |polling|processing|uploading|downloading|building|[ ]*\d+%|\d+\/\d+ )/i; /** Emoji for a line, by what it says. */ export function emojiFor(msg: string, level: 'log' | 'warn' | 'done'): string { if (level === 'warn') return '⚠️ '; if (level === 'done') return '✅'; if (/\b(fail|error|refus|rejected|not kept)/i.test(msg)) return '❌'; if (/^(upload|uploading)/i.test(msg)) return '📦'; if (/\b(submit(ted)?|released?|publish(ed)?|live)\b/i.test(msg)) return '🚀'; if (/^(dry run|would )/i.test(msg)) return '🧪'; return '•'; } let spin: { text: string; start: number; frame: number; timer: ReturnType } | null = null; const clearSpin = (): void => { if (spin) process.stdout.write('\r\x1b[2K'); }; function stopSpin(): void { if (spin) { clearInterval(spin.timer); clearSpin(); spin = null; } } function drawSpin(): void { if (!spin) return; const s = Math.round((Date.now() - spin.start) / 1000); const cols = (process.stdout.columns || 100) - 12; process.stdout.write(`\r\x1b[2K${C.cyan}${FRAMES[spin.frame++ % FRAMES.length]}${C.off} ${spin.text.slice(0, cols)} ${C.dim}${s}s${C.off}`); } function showSpin(text: string): void { if (spin) { spin.text = text; drawSpin(); return; } spin = { text, start: Date.now(), frame: 0, timer: setInterval(drawSpin, 100) }; spin.timer.unref?.(); drawSpin(); } let hooked = false; /** Any other output first removes the live spinner line, so lines never mix. */ function hook(): void { if (hooked) return; hooked = true; for (const k of ['log', 'info', 'warn', 'error'] as const) { const orig = console[k].bind(console); console[k] = (...a: unknown[]) => { const was = spin?.text; stopSpin(); orig(...a); if (was !== undefined && k !== 'error') showSpin(was); }; } process.on('exit', stopSpin); } function line(level: 'log' | 'warn' | 'done', tag: string, msg: string): void { const plain = `${stamp()} [${tag}] ${level === 'warn' ? 'WARN ' : ''}${msg}${level === 'done' ? ` (${since()})` : ''}`; if (!fancy()) { (level === 'warn' ? console.warn : console.log)(plain); return; } hook(); if (level === 'log' && WAIT.test(msg)) { showSpin(`${C.bold}${tag}${C.off} ${msg}`); return; } stopSpin(); const color = level === 'warn' ? C.yellow : level === 'done' ? C.green : emojiFor(msg, level) === '❌' ? C.red : ''; const text = `${C.dim}${stamp()}${C.off} ${emojiFor(msg, level)} ${C.cyan}${tag}${C.off} ${color}${msg}${color ? C.off : ''}${level === 'done' ? ` ${C.dim}(${since()})${C.off}` : ''}`; (level === 'warn' ? console.warn : console.log)(text); } export const log = (tag: string, msg: string): void => line('log', tag, msg); export const warn = (tag: string, msg: string): void => line('warn', tag, msg); export const done = (tag: string, msg: string): void => line('done', tag, msg); // The count lives on globalThis: the Node bundle (tools/build-node.ts) can carry this module twice (once for the // static imports, once inside a lazily initialised chunk), and both copies must see the same --json run. const JSON_RUNS = Symbol.for('storebound.jsonRuns'); const runs = globalThis as unknown as Record; /** Whether a `--json` run is going on: stdout carries only the JSON (printJson), everything else goes to stderr. */ export const jsonOutput = (): boolean => (runs[JSON_RUNS] ?? 0) > 0; /** Where a question / prompt is written: stderr during a --json run, so stdout stays one JSON document. */ export const promptOut = (): NodeJS.WriteStream => (jsonOutput() ? process.stderr : process.stdout); /** * Runs a command with --json: stdout is only the JSON. console.log / console.info (log, done, step lines, tables and * every other line a command prints) go to stderr for the run; printJson writes the result to stdout itself. index.ts * wraps every command run with --json in this, so no command has to remember it. */ export async function withJsonOutput(fn: () => Promise): Promise { const log = console.log, info = console.info; console.log = (...a: unknown[]) => console.error(...a); console.info = (...a: unknown[]) => console.error(...a); runs[JSON_RUNS] = (runs[JSON_RUNS] ?? 0) + 1; try { return await fn(); } finally { runs[JSON_RUNS] = runs[JSON_RUNS]! - 1; console.log = log; console.info = info; } } /** "3/14 de-DE" style counters. */ export const step = (i: number, n: number, what: string): string => `${String(i).padStart(String(n).length)}/${n} ${what}`; /** Prefix of the one machine-readable result line that `storebound serve --stdio` reads from a child command. */ export const RESULT_MARK = '\u001estorebound-result '; /** * Structured result of a `--json` run. In a terminal (or a pipe) it is pretty JSON on stdout as before; under * `storebound serve` (STOREBOUND_MACHINE=1) it is one marked line, so log lines printed before it never break parsing. */ export function printJson(data: unknown): void { // a ship step (cli/lib/ship-step.ts) keeps the data for its one final line instead of printing it const ship = (globalThis as unknown as Record)[Symbol.for('storebound.shipRun')]; if (ship) { ship.detail = data; return; } if (process.env.STOREBOUND_MACHINE === '1') process.stdout.write(RESULT_MARK + JSON.stringify(data) + '\n'); // during a --json run console.log goes to stderr, so the JSON goes to stdout itself else if (jsonOutput()) process.stdout.write(JSON.stringify(data, null, 2) + '\n'); else console.log(JSON.stringify(data, null, 2)); }