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