Terminal: colors, status emoji and a live spinner line for waits at a TTY; plain lines unchanged when piped, in CI, with NO_COLOR, TERM=dumb, --json or MCP; desktop download progress through the log

This commit is contained in:
CRTkafa 2026-10-22 02:15:03 +08:00
parent e1634f6abe
commit f7dd646e57
4 changed files with 98 additions and 5 deletions

View file

@ -4,9 +4,72 @@ const t0 = Date.now();
const stamp = (): string => new Date().toISOString().slice(11, 19);
const since = (): string => `${((Date.now() - t0) / 1000).toFixed(0)}s`;
export const log = (tag: string, msg: string): void => console.log(`${stamp()} [${tag}] ${msg}`);
export const warn = (tag: string, msg: string): void => console.warn(`${stamp()} [${tag}] WARN ${msg}`);
export const done = (tag: string, msg: string): void => console.log(`${stamp()} [${tag}] ${msg} (${since()})`);
/**
* 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<string, string | undefined> = 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<typeof setInterval> } | 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.