UNPKG

@vigilbase/cli

Version:

Vigilbase CLI — security scanners, agent tools, and Vigilbase One from the terminal.

142 lines (141 loc) • 5.21 kB
import { sanitizeTerminal } from './format.js'; import { isCi } from './platform.js'; const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']; const BAR_WIDTH = 24; /** Back to the start of the line and erase it. */ const CLEAR_LINE = '\r\x1b[2K'; function renderBar(ratio) { const clamped = Math.max(0, Math.min(1, ratio)); const filled = Math.round(clamped * BAR_WIDTH); const empty = BAR_WIDTH - filled; return `${'█'.repeat(filled)}${'░'.repeat(empty)}`; } const ZERO_WIDTH = /[\p{M}\u200b-\u200f\u2060-\u2064\ufe00-\ufe0f\ufeff]/u; const WIDE = /[\u1100-\u115f\u2e80-\u303e\u3041-\ua4cf\uac00-\ud7a3\uf900-\ufaff\ufe30-\ufe4f\uff00-\uff60\uffe0-\uffe6\u{20000}-\u{3fffd}\p{Extended_Pictographic}]/u; /** Terminal columns of one code point: CJK and emoji take two, combining marks none. */ function charWidth(char) { if (ZERO_WIDTH.test(char)) return 0; return WIDE.test(char) ? 2 : 1; } function displayWidth(text) { let width = 0; for (const char of text) width += charWidth(char); return width; } /** * One frame within `columns - 1` terminal columns, so the terminal never wraps it: the bar * goes first, then the label is cut with an ellipsis. */ function fitFrame(spin, label, bar, elapsedSec, columns) { const width = Math.max(1, columns - 1); const suffix = ` ${elapsedSec}s`; const full = `${spin} ${label} ${bar}${suffix}`; if (displayWidth(full) <= width) return full; const room = width - 2 - suffix.length; if (displayWidth(label) <= room) return `${spin} ${label}${suffix}`; if (room < 2) return spin; let cut = ''; let used = 0; for (const char of label) { const next = charWidth(char); if (used + next > room - 1) break; cut += char; used += next; } return `${spin} ${cut.trimEnd()}…${suffix}`; } /** * Live indeterminate progress on stderr while a scan/request is in flight; see * shouldShowProgress for when it is on. Ctrl-C clears the line before the process exits. */ export function startProgress(label, options) { label = sanitizeTerminal(label).replace(/[\n\t]/g, ' '); const stream = options.stderr ?? process.stderr; if (!options.isEnabled) { return { stop: () => undefined }; } const signals = options.signals ?? process; const exit = options.exit ?? ((code) => process.exit(code)); const expectedMs = options.expectedMs ?? 8_000; const started = Date.now(); let frame = 0; let closed = false; const draw = () => { if (closed) return; const elapsedMs = Date.now() - started; const elapsedSec = (elapsedMs / 1000).toFixed(1); // Ease toward ~90% at expectedMs, then creep slowly so we never hit 100% early. const ratio = 1 - Math.exp(-elapsedMs / expectedMs); const soft = Math.min(0.92, ratio); const spin = SPINNER[frame % SPINNER.length]; frame += 1; // Read the width every frame: the terminal may be resized. stream.write(`${CLEAR_LINE}${fitFrame(spin, label, renderBar(soft), elapsedSec, stream.columns || 80)}`); }; const close = () => { closed = true; clearInterval(timer); signals.removeListener('SIGINT', onSigint); stream.write(CLEAR_LINE); }; // Without this, Ctrl-C kills the process mid-frame and the prompt lands on the spinner line. // Another SIGINT listener (the checkup's cleanup) exits itself; alone, exit as Ctrl-C would. const onSigint = () => { if (closed) return; close(); if (signals.listenerCount('SIGINT') === 0) exit(130); }; draw(); const timer = setInterval(draw, 80); timer.unref?.(); signals.once('SIGINT', onSigint); return { stop: (finalMessage) => { if (closed) return; close(); if (finalMessage) { const elapsedSec = ((Date.now() - started) / 1000).toFixed(1); stream.write(`${sanitizeTerminal(finalMessage).replace(/[\n\t]/g, ' ')} (${elapsedSec}s)\n`); } }, }; } /** * Progress shows for text output on a terminal stderr, except in CI, with TERM=dumb or with * VIGILBASE_CLI_NO_PROGRESS=1. NO_COLOR does not turn it off: the spinner has no colour. */ export function shouldShowProgress(mode, env = process.env, stderr = process.stderr) { if (mode === 'json') return false; if (isCi(env) || (env.TERM ?? '').toLowerCase() === 'dumb') return false; if (env.VIGILBASE_CLI_NO_PROGRESS === '1') return false; return Boolean(stderr.isTTY); } /** Tiny helper so call sites stay readable. */ export async function withProgress(label, mode, work, writer) { const progress = startProgress(label, { isEnabled: shouldShowProgress(mode), stderr: writer ? undefined : process.stderr, }); try { const result = await work(); progress.stop(`✓ ${label}`); return result; } catch (error) { progress.stop(`✗ ${label}`); throw error; } }