UNPKG

@vigilbase/cli

Version:

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

404 lines (403 loc) • 19.6 kB
/** * 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(); } }