@vigilbase/cli
Version:
Vigilbase CLI — security scanners, agent tools, and Vigilbase One from the terminal.
137 lines (136 loc) • 6.01 kB
JavaScript
export class NetworkError extends Error {
code;
status = 0;
constructor(message, code, cause) {
super(message, { cause });
this.code = code;
this.name = 'NetworkError';
}
}
function errorCodes(error, seen = new Set()) {
if (!error || typeof error !== 'object' || seen.has(error))
return [];
seen.add(error);
const item = error;
return [
...(typeof item.code === 'string' ? [item.code] : []),
...errorCodes(item.cause, seen),
...(Array.isArray(item.errors) ? item.errors.flatMap((e) => errorCodes(e, seen)) : []),
];
}
export function networkError(error, url, timeoutMs, timedOut) {
// Never echo query strings, URL credentials, request bodies, or raw causes.
const endpoint = new URL(url).origin;
const codes = errorCodes(error);
const code = codes[0] ?? 'NETWORK_ERROR';
if (timedOut || codes.some((c) => /TIMEOUT|ETIMEDOUT/.test(c))) {
return new NetworkError(`Request to ${endpoint} timed out after ${timeoutMs}ms. Check your connection or increase --timeout.`, 'TIMEOUT', error);
}
if (codes.some((c) => /CERT|SELF_SIGNED|UNABLE_TO_VERIFY|UNABLE_TO_GET_ISSUER/.test(c))) {
return new NetworkError(`TLS certificate verification failed for ${endpoint} (${code}). Use Node 22.19+ or Node 24.5+ for automatic system certificate trust. If your organization uses a private CA, configure NODE_EXTRA_CA_CERTS with its trusted PEM certificate.`, code, error);
}
if (codes.some((c) => c === 'ENOTFOUND' || c === 'EAI_AGAIN')) {
return new NetworkError(`Cannot resolve ${endpoint} (${code}). Check DNS, your VPN, and the configured API URL.`, code, error);
}
return new NetworkError(`Cannot connect to ${endpoint} (${code}). Check your connection, VPN, proxy settings, and API URL. Run \`vigilbase doctor\` for connectivity checks.`, code, error);
}
export const MAX_RESPONSE_BYTES = 8 * 1024 * 1024;
/** Bound decoded bytes even when Content-Length is absent, compressed, or dishonest. */
export async function readBoundedBody(response, signal, maxBytes = MAX_RESPONSE_BYTES) {
if (Number(response.headers.get('content-length')) > maxBytes) {
await response.body?.cancel();
throw new NetworkError('Response body exceeds the size limit.', 'RESPONSE_TOO_LARGE');
}
if (!response.body)
return '';
const reader = response.body.getReader();
const abort = () => { void reader.cancel().catch(() => undefined); };
signal.addEventListener('abort', abort, { once: true });
let size = 0;
const chunks = [];
try {
while (true) {
if (signal.aborted)
throw new Error('Aborted');
const { done, value } = await reader.read();
if (signal.aborted)
throw new Error('Aborted');
if (done)
return Buffer.concat(chunks).toString('utf8');
size += value.byteLength;
if (size > maxBytes) {
await reader.cancel();
throw new NetworkError('Response body exceeds the size limit.', 'RESPONSE_TOO_LARGE');
}
chunks.push(value);
}
}
finally {
signal.removeEventListener('abort', abort);
reader.releaseLock();
}
}
/**
* A signal that aborts with the reason of the first of `signals` to abort.
* Uses `AbortSignal.any` (Node 20.3+) and combines them by hand on older
* Node 20; `dispose` then removes the listeners so a long-lived signal does
* not collect one per request.
*/
export function anySignal(signals) {
if (typeof AbortSignal.any === 'function')
return { signal: AbortSignal.any(signals), dispose: () => undefined };
const controller = new AbortController();
const dispose = () => {
for (const signal of signals)
signal.removeEventListener('abort', onAbort);
};
function onAbort(event) {
dispose();
controller.abort(event.target.reason);
}
const aborted = signals.find((signal) => signal.aborted);
if (aborted) {
controller.abort(aborted.reason);
return { signal: controller.signal, dispose: () => undefined };
}
for (const signal of signals)
signal.addEventListener('abort', onAbort, { once: true });
return { signal: controller.signal, dispose };
}
/** Keep the timeout active until the complete response body has been read. */
export async function requestText(config, url, init = {}) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), config.timeoutMs);
const { signal, dispose } = init.signal
? anySignal([init.signal, controller.signal])
: { signal: controller.signal, dispose: () => undefined };
try {
// Never forward token-bearing POST bodies (or credentials) through redirects.
// 'manual' is honoured because it surfaces the 3xx without following it.
const response = await fetch(url, { ...init, signal, redirect: init.redirect === 'manual' ? 'manual' : 'error' });
return { response, text: await readBoundedBody(response, signal) };
}
catch (error) {
if (error instanceof NetworkError)
throw error;
if (init.signal?.aborted) {
throw new NetworkError('Request cancelled.', 'CANCELLED', error);
}
throw networkError(error, url, config.timeoutMs, controller.signal.aborted);
}
finally {
clearTimeout(timer);
dispose();
}
}
export function parseJsonResponse(response, text) {
try {
// Empty successful responses are valid for some mutation endpoints.
return text ? JSON.parse(text) : null;
}
catch {
if (!response.ok)
return null;
throw new NetworkError(`Expected JSON but received a non-JSON response (HTTP ${response.status}). Check the configured API URL and any proxy or sign-in page.`, 'INVALID_RESPONSE');
}
}