@vigilbase/cli
Version:
Vigilbase CLI — security scanners, agent tools, and Vigilbase One from the terminal.
404 lines (403 loc) • 19.6 kB
JavaScript
/**
* The interactive terminal UI of the one command and `submit`: a step
* timeline on stderr with inputs for the email and the code, an account
* picker, Yes/No questions, spinners and a live collection progress bar.
*
* It is used only on an interactive terminal (`terminalFeatures`: stdin and
* stderr are TTYs, TERM is not dumb, CI is not set, no `--json`); everywhere
* else the commands keep their plain lines and prompts. What it shows follows
* the same rules as the plain output: the masked address from vigilbase.com,
* never the address, the code, the token or the session code once entered;
* account names only through `displayText`; counts, never values, from the
* snapshot.
*
* Every label is built from the session's symbols (`ellipsis`, `join`), so an
* ASCII-only console (the Linux console, legacy Windows consoles) gets no
* character outside ASCII.
*/
import { sanitizeTerminal } from '../../lib/format.js';
import { Tui, displayWidth, markRendered, terminalFeatures } from '../../lib/tui.js';
import { LocalCheckupError } from './errors.js';
import { formatBytes, formatClock, formatDuration } from './output.js';
import { BUDGET_BURST, estimateBudgetSeconds } from './rate-budget.js';
const TITLE = 'Vigilbase Cloudflare Checkup';
/** Sign-in attempts offered after the first one fails. */
export const MAX_SIGN_IN_RETRIES = 2;
/** After this long, the upload spinner says vigilbase.com is scoring (the POST answers with the report). */
const SCORING_AFTER_MS = 1_500;
const plural = (count, one, many = `${one}s`) => `${count} ${count === 1 ? one : many}`;
export class CheckupTui {
tui;
writer;
now;
echoResults;
constructor(tui, writer, options) {
this.tui = tui;
this.writer = writer;
this.now = options.now ?? Date.now;
this.echoResults = !options.stdout?.isTTY;
this.tui.cancelMessage = 'Cancelled. Nothing was uploaded.';
}
/** The terminal UI, or null for the plain output. */
static open(options) {
const features = terminalFeatures({ mode: options.mode, stdin: options.stdin, stderr: options.stderr, env: options.env, ...(options.platform ? { platform: options.platform } : {}) });
if (!features)
return null;
const tui = new Tui({
stdin: options.stdin,
stderr: options.stderr,
features,
...(options.signals ? { signals: options.signals } : {}),
...(options.exit ? { exit: options.exit } : {}),
...(options.frameMs ? { frameMs: options.frameMs } : {}),
});
return new CheckupTui(tui, options.writer, options);
}
/** What Ctrl-C prints at this stage. */
set cancelMessage(message) {
this.tui.cancelMessage = message;
}
/**
* A writer for the lines the commands print outside the steps below
* (warnings, notes): `Warning: …` becomes `▲`, anything else a `│` line,
* so nothing is written through the live frame.
*/
get lines() {
const line = (text = '') => {
for (const part of this.tui.symbolText(sanitizeTerminal(text)).replace(/\n$/, '').split('\n')) {
if (part.startsWith('Warning: '))
this.tui.warn(part.slice('Warning: '.length));
else if (part.trim() === '')
this.tui.rail();
else
this.tui.info(part);
}
};
return { stdout: line, stderr: line };
}
intro() {
this.tui.print(`${this.tui.style.brand(this.tui.symbols.active)} ${this.tui.style.bold(TITLE)}`);
this.tui.rail();
}
/** `…`, or `...` on an ASCII-only console: for masked IDs and cut text built outside this class. */
get ellipsis() {
return this.tui.symbols.ellipsis;
}
/** `a · b · c`, or `a - b - c` on an ASCII-only console. */
join(parts) {
return this.tui.join(parts);
}
/** `label…` (`label...` in ASCII): an activity in progress, without its ellipsis. */
ongoing(label) {
return `${label}${this.ellipsis}`;
}
/** `◒ label…` while `work` runs; `label` has no ellipsis. */
busy(label, work) {
return this.tui.busy(this.ongoing(label), work);
}
/** `◒ label…` until stopped; `label` (also in `update`) has no ellipsis. */
spinner(label) {
const spinner = this.tui.spinner(this.ongoing(label));
return { update: (next) => spinner.update(this.ongoing(next)), stop: () => spinner.stop() };
}
/** `◇ title detail` */
step(title, detail) {
this.tui.step(title, detail);
}
warning(text) {
this.tui.warn(this.tui.symbolText(sanitizeTerminal(text)));
}
/** A Yes/No question; the answer stays on screen (`◇ question › Yes`). Ctrl-D answers no. */
async confirm(question, options) {
const answer = (await this.tui.confirm({ title: question, initial: options.initial, ...(options.detail ? { detail: options.detail } : {}) })) === true;
this.tui.answered(question, answer ? 'Yes' : 'No');
return answer;
}
/** A question a flag answered: `◇ question › Yes (--yes)`. */
answeredByFlag(question, flag) {
this.tui.answered(question, `Yes ${this.tui.style.dim(`(${flag})`)}`);
}
// --------------------------------------------------------------------------
// Email verification
// --------------------------------------------------------------------------
/**
* The verification's inputs: the address is shown while it is typed and
* sent, then replaced by the masked address vigilbase.com returns; the code
* is shown while it is typed, then masked (●●●●●●). A notice (a refused
* address, a wrong code) appears inside the next input, which is drawn in
* the same place.
*/
emailView() {
const { tui } = this;
const { style, symbols } = tui;
let notice = null;
let typed = null;
let sentTo = '';
let expiresAt = '';
const takeNotice = () => {
const pending = notice;
notice = null;
return pending;
};
const pointer = style.dim(symbols.pointer);
const masked = symbols.mask.repeat(6);
return {
askEmail: async () => {
typed = await tui.text({ title: 'Work email', notice: takeNotice(), maxLength: 254 });
return typed;
},
requesting: async (work) => {
const shown = typed;
tui.show(() => [shown === null
? `${tui.spin} ${this.ongoing('Sending a code to your work email')}`
: `${tui.spin} Work email ${pointer} ${shown} ${style.dim(this.ongoing('sending a code'))}`], { animated: true });
try {
return await work();
}
finally {
tui.clearFrame();
typed = null;
}
},
codeSent: (to, expires) => {
sentTo = sanitizeTerminal(to);
expiresAt = expires;
tui.answered('Work email', sentTo);
},
askCode: () => tui.code({
title: `Code sent to ${sentTo}`,
length: 6,
hint: this.join(['6 digits', `valid until ${formatClock(expiresAt)}`, 'check spam if it doesn\'t arrive']),
notice: takeNotice(),
}),
verifying: async (work) => {
tui.show(() => [`${tui.spin} Code sent to ${sentTo} ${pointer} ${masked} ${style.dim(this.ongoing('verifying'))}`], { animated: true });
try {
const result = await work();
tui.clearFrame();
tui.answered(`Code sent to ${sentTo}`, `${masked} ${style.green(`${symbols.check} verified`)}`);
tui.rail();
return result;
}
catch (error) {
tui.clearFrame();
throw error;
}
},
notice: (line) => {
notice = line;
},
// The refusal does not say which limit was reached (this address, its
// domain, this network or all codes), so another address is offered,
// not suggested.
rateLimited: async (error) => {
tui.warn('vigilbase.com is not sending a code right now.');
tui.note('Codes are limited per address (one checkup every 8 minutes), per domain, network and overall.');
tui.note(error.retryAfterSec !== undefined ? `Try again at ${this.retryClock(error.retryAfterSec)} (in ${formatDuration(error.retryAfterSec)}).` : 'Try again in a few minutes.');
return this.confirm('Use a different work email?', { initial: false });
},
};
}
retryClock(seconds) {
return formatClock(new Date(this.now() + seconds * 1000).toISOString());
}
// --------------------------------------------------------------------------
// Cloudflare
// --------------------------------------------------------------------------
/**
* cf's interactive sign-in with the terminal handed over (cf prints and
* reads directly); after a failed or cancelled sign-in, "Try signing in
* again?" up to MAX_SIGN_IN_RETRIES times. `check` is whoami afterwards.
*/
async signIn(run, check) {
const { style, symbols } = this.tui;
for (let attempt = 0;; attempt++) {
this.tui.print(`${style.cyan(symbols.active)} Cloudflare sign-in ${style.dim(this.ongoing('read-only; your browser will open'))}`);
const code = await this.tui.handOver(run);
let problem = 'The Cloudflare sign-in did not complete.';
if (code === 0) {
try {
return await check();
}
catch (error) {
if (!(error instanceof LocalCheckupError) || error.code !== 'CF_AUTH_REQUIRED')
throw error;
problem = 'cf finished, but the Cloudflare login cannot be used.';
}
}
const stop = new LocalCheckupError('CF_AUTH_REQUIRED', 'The Cloudflare sign-in (cf auth create) did not complete.');
if (attempt >= MAX_SIGN_IN_RETRIES)
throw stop;
this.tui.warn(problem);
if (!(await this.confirm('Try signing in again?', { initial: true })))
throw stop;
}
}
signedIn(summary) {
this.tui.step('Cloudflare sign-in', `${this.tui.style.green(this.tui.symbols.check)} ${sanitizeTerminal(summary)}`);
}
/** The account picker; `label` is already safe (`displayText`). Null on Ctrl-D. */
async chooseAccount(accounts) {
const id = await this.tui.select({
title: 'Account',
hint: `${this.tui.symbols.up}/${this.tui.symbols.down} to choose, Enter to confirm`,
items: accounts.map((account) => ({ label: account.label, detail: account.detail, value: account.id })),
});
const chosen = accounts.find((account) => account.id === id);
if (chosen)
this.account(chosen.label, chosen.detail);
return id;
}
/** `◇ Account Acme Corp a1b2…8f90`, the last line before the collection. */
account(label, detail) {
this.tui.step('Account', `${label} ${this.tui.style.dim(detail)}`);
this.tui.rail();
}
/**
* `◒ Collecting ████░░ zones 1/2 · 40/97 reads · ~20 s left`, redrawn on
* every progress event. The estimate is the larger of what the request
* budget allows for the reads left and the pace so far.
*/
collecting(plan) {
const { tui } = this;
const started = this.now();
let state = { zonesDone: 0, zonesTotal: plan.zones, requests: 0 };
const render = () => {
const finished = state.zonesDone >= state.zonesTotal;
const planned = Math.max(plan.plannedReads, state.requests + (finished ? 0 : 1));
const left = Math.max(0, planned - state.requests);
const elapsed = Math.max(0, this.now() - started) / 1000;
const budget = left === 0 ? 0 : estimateBudgetSeconds(plan.maxRequests, left + BUDGET_BURST);
const pace = state.requests > 0 ? (elapsed / state.requests) * left : 0;
const eta = left === 0 ? this.ongoing('finishing') : `${formatDuration(Math.max(budget, pace))} left`;
const head = `${tui.spin} Collecting`;
const stats = tui.style.dim(this.join([`zones ${state.zonesDone}/${state.zonesTotal}`, `${state.requests}/${planned} reads`, eta]));
const width = Math.min(24, tui.columns - 1 - displayWidth(head) - displayWidth(stats) - 4);
return [width >= 6 ? `${head} ${tui.bar(state.requests / planned, width)} ${stats}` : `${head} ${stats}`];
};
tui.show(render, { animated: true });
return {
update: (next) => {
state = next;
tui.draw();
},
stop: () => tui.clearFrame(),
};
}
/** `◇ 84 reads · 72 ok · 10 not collectable · 2 refused`, the saved file and what it contains. */
collected(summary) {
const { style } = this.tui;
const parts = [
`${summary.total} reads`,
style.green(`${summary.ok} ok`),
...(summary.notCollectable ? [style.dim(`${summary.notCollectable} not collectable`)] : []),
...(summary.denied ? [style.yellow(`${summary.denied} refused`)] : []),
...(summary.failed ? [style.red(`${summary.failed} failed`)] : []),
];
this.tui.step(this.join(parts));
this.tui.note(sanitizeTerminal(this.join([`Saved ${summary.path} (${formatBytes(summary.bytes)})`, plural(summary.zones, 'zone'), 'no credentials'])));
if (summary.contents.length)
this.tui.note(`Contains ${this.join(summary.contents)}`);
this.tui.note('Inspect it:');
this.tui.command(sanitizeTerminal(summary.inspect));
this.tui.rail();
}
// --------------------------------------------------------------------------
// Upload
// --------------------------------------------------------------------------
/** `◆ Upload 13 KB to vigilbase.com? › Yes / No`; Enter on the initial state means No. */
async confirmUpload(input) {
const question = `Upload ${formatBytes(input.bytes)} to ${sanitizeTerminal(input.where)}?`;
const detail = `Results go to ${sanitizeTerminal(input.requester)}`;
if (input.flag !== null) {
this.answeredByFlag(question, input.flag);
this.tui.note(detail);
return true;
}
return this.confirm(question, { initial: false, detail });
}
/** The upload and the scoring, which happen in one request. */
async uploading(bytes, work) {
this.cancelMessage = 'Cancelled during the upload. If it reached vigilbase.com, the results link is emailed to you.';
const spinner = this.spinner(`Uploading ${formatBytes(bytes)}`);
const timer = setTimeout(() => spinner.update('vigilbase.com is scoring the checkup'), SCORING_AFTER_MS);
timer.unref?.();
try {
return await work();
}
finally {
clearTimeout(timer);
spinner.stop();
this.cancelMessage = 'Cancelled.';
}
}
/**
* `└ ✓ Done. 10 passed · 2 failed`, the results link (underlined, an OSC 8
* link where supported) and the email note. `plainLines` are the plain
* result lines, also written to stdout when stdout is not a terminal.
*/
uploaded(summary, plainLines) {
const { style, symbols } = this.tui;
const counts = [
summary.passed !== undefined ? style.green(`${summary.passed} passed`) : null,
summary.warnings !== undefined && summary.warnings > 0 ? style.yellow(plural(summary.warnings, 'warning')) : null,
summary.failed !== undefined && summary.failed > 0 ? style.red(`${summary.failed} failed`) : null,
summary.notCollected > 0 ? style.dim(`${summary.notCollected} reads not collected locally`) : null,
].filter((part) => part !== null);
const done = summary.replayed ? 'Already uploaded (same file); nothing changed.' : 'Done.';
const lines = [`${style.green(symbols.end)} ${style.green(symbols.check)} ${done}${counts.length ? ` ${this.join(counts)}` : ''}`];
const requester = sanitizeTerminal(summary.requester);
if (summary.resultsUrl !== undefined) {
const link = style.underline(this.tui.link(summary.resultsUrl));
lines.push(` Results: ${link}${summary.opening ? ` ${style.dim('(opening in your browser)')}` : ''}`);
lines.push(` ${style.dim(`A link has also been emailed to ${requester}.`)}`);
}
else {
lines.push(` ${style.dim(`A link to the results has been emailed to ${requester}.`)}`);
}
this.tui.rail();
this.tui.print(...lines);
if (this.echoResults)
for (const line of plainLines)
this.writer.stdout(line);
}
/** Dim lines under the end of the timeline (the logout hint), then the command on a line of its own. */
footnote(text, command) {
this.tui.footnote(sanitizeTerminal(text));
if (command !== undefined)
this.tui.command(sanitizeTerminal(command));
}
/** Before an upload error is shown: the `submit` command for the saved snapshot, on a line of its own. */
uploadLater(command) {
this.tui.info('The snapshot stays saved. To upload it later without collecting again (it verifies your email again), run:');
this.tui.command(sanitizeTerminal(command));
}
// --------------------------------------------------------------------------
// The end
// --------------------------------------------------------------------------
/**
* `■ message` with the hint under it, for an error that ends the command;
* `runCli` then does not print it again. After Ctrl-C nothing more is shown.
*/
fail(error) {
// Errors carrying a server body are left to formatError, which never echoes it.
if (!(error instanceof Error) || 'body' in error)
return;
markRendered(error);
if (this.tui.isInterrupted)
return;
// Messages are written with the Unicode symbols (`0123…cdef`); an ASCII-only console gets `0123...cdef`.
const message = this.tui.symbolText(sanitizeTerminal(error.message).replace(/\s+/g, ' ').trim());
const details = [];
if (error instanceof LocalCheckupError) {
if (error.retryAfterSec !== undefined && !/Try again/.test(message))
details.push(`Try again at ${this.retryClock(error.retryAfterSec)}.`);
details.push(this.tui.symbolText(sanitizeTerminal(error.hint).replace(/\s+/g, ' ').trim().slice(0, 300)), error.code);
}
this.tui.rail();
this.tui.error(message, ...details.filter(Boolean));
}
close() {
this.tui.close();
}
}