@vigilbase/cli
Version:
Vigilbase CLI — security scanners, agent tools, and Vigilbase One from the terminal.
142 lines (141 loc) • 5.21 kB
JavaScript
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;
}
}