UNPKG

@vigilbase/cli

Version:

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

140 lines (139 loc) • 6.04 kB
/** * Prompts and confirmations of local checkups. * * Every prompt goes to stderr, so `--json` keeps stdout one JSON object. On a * terminal, answers are typed at a visible prompt. Without one, only the email * verification reads its answers (the work email without `--email`, and the * emailed code) from piped stdin, one line each, so a script can write the code * once it sees the prompt; every other question needs a flag (`--yes`, * `--account-id`). What is read is never echoed in errors. */ import { createInterface } from 'node:readline/promises'; import { LocalCheckupError } from './errors.js'; /** One piped answer is at most this long (an email address is at most 254 bytes). */ export const MAX_ANSWER_BYTES = 256; function isTty(stream) { return Boolean(stream && stream.isTTY); } export function hasInteractiveInput(stdin) { return isTty(stdin); } /** * A visible one-line prompt on a TTY (account picker, confirmations): the * trimmed answer, or null when the input ended (Ctrl-D) before one. */ export async function promptLine(io, label) { if (!isTty(io.stdin)) { throw new LocalCheckupError('CONFIRMATION_REQUIRED', 'No terminal is available for this prompt.', { hint: 'Run the command in a terminal, or pass the answer as a flag (--account-id, --yes).' }); } if (io.stdin.readableEnded) return null; const rl = createInterface({ input: io.stdin, output: io.stderr, terminal: false }); // readline's question() never settles once the input ends; the interface's 'close' does. const ended = new Promise((resolve) => rl.once('close', () => resolve(null))); try { const answer = await Promise.race([rl.question(label).catch(() => null), ended]); if (answer !== null) return answer.trim(); // Ctrl-D leaves the cursor on the prompt line. io.stderr.write('\n'); return null; } finally { rl.close(); } } /** * One line of piped input, without consuming the next one: whatever follows * the newline is pushed back onto the stream for the next prompt. Bytes past * `MAX_ANSWER_BYTES` are dropped up to the newline, so an overlong line comes * back too long and fails validation. Null at the end of the input. */ export function readPipedLine(stdin) { const stream = stdin; if (stream.readableEnded) return Promise.resolve(null); return new Promise((resolve, reject) => { let kept = Buffer.alloc(0); let settled = false; const finish = (error, line, rest) => { if (settled) return; settled = true; stream.removeListener('readable', onReadable); stream.removeListener('end', onEnd); stream.removeListener('error', onError); if (rest && rest.byteLength > 0) stream.unshift(rest); if (error) reject(error); else resolve(line === null ? null : line.toString('utf8').replace(/\r$/, '')); }; const onReadable = () => { let chunk; while (!settled && (chunk = stream.read()) !== null) { const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk)); const newline = bytes.indexOf(0x0a); const head = newline >= 0 ? bytes.subarray(0, newline) : bytes; if (kept.byteLength <= MAX_ANSWER_BYTES) kept = Buffer.concat([kept, head.subarray(0, MAX_ANSWER_BYTES + 1 - kept.byteLength)]); if (newline >= 0) finish(null, kept, bytes.subarray(newline + 1)); } }; const onEnd = () => finish(null, kept.byteLength > 0 ? kept : null); const onError = (error) => finish(error, null); stream.on('readable', onReadable); stream.on('end', onEnd); stream.on('error', onError); onReadable(); }); } /** * A visible prompt for the email verification. On a terminal it is * `promptLine`; without one the label still goes to stderr and the answer is * one line of piped stdin. Null when the input ended without an answer. */ export async function promptAnswer(io, label) { if (isTty(io.stdin)) return promptLine(io, label); io.stderr.write(label); const line = io.stdin ? await readPipedLine(io.stdin) : null; // A piped answer is not echoed by a terminal; end the prompt line. io.stderr.write('\n'); return line === null ? null : line.trim(); } /** * `[y/N]` upload confirmation: an explicit `y` is required. `--yes` (or its * alias `--confirm`) answers yes; without a terminal and without them it is * CONFIRMATION_REQUIRED. */ export async function confirmUpload(io, question, options) { if (options.isConfirmed) { io.stderr.write(`${question} [y/N] y (${options.flag ?? '--yes'})\n`); return true; } if (!isTty(io.stdin)) { throw new LocalCheckupError('CONFIRMATION_REQUIRED', 'Uploading needs confirmation, and no terminal is available to ask.'); } const answer = await promptLine(io, `${question} [y/N] `); if (answer === null) throw inputEnded('Uploading needs confirmation, and the input ended before an answer.'); return /^y(?:es)?$/i.test(answer); } /** * `[Y/n]` question where Enter means yes (installing cf). The caller handles * `--yes` and the no-terminal case; an input that ends before an answer is * CONFIRMATION_REQUIRED. */ export async function confirmDefaultYes(io, question) { const answer = await promptLine(io, `${question} [Y/n] `); if (answer === null) throw inputEnded('The question needs an answer, and the input ended before one.'); return answer === '' || /^y(?:es)?$/i.test(answer); } /** Ctrl-D at a confirmation: nothing was confirmed. */ function inputEnded(message) { return new LocalCheckupError('CONFIRMATION_REQUIRED', message, { hint: 'Run the same command again and answer the question, or add --yes.' }); }