@vigilbase/cli
Version:
Vigilbase CLI — security scanners, agent tools, and Vigilbase One from the terminal.
201 lines (200 loc) • 9.51 kB
JavaScript
import { isSystemTrustConfigured } from './system-trust.js';
export class NetworkError extends Error {
code;
hint;
status = 0;
/** `hint` is printed after the error; doctor, which is the connectivity check, leaves it out. */
constructor(message, code, cause, hint) {
super(message, { cause });
this.code = code;
this.hint = hint;
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)) : []),
];
}
/** Messages along the cause chain: undici reports a blocked port or a refused redirect only by message. */
function causeMessages(error, seen = new Set()) {
if (!error || typeof error !== 'object' || seen.has(error))
return [];
seen.add(error);
const item = error;
return [...(typeof item.message === 'string' ? [item.message] : []), ...causeMessages(item.cause, seen)];
}
export function networkError(error, url, timeoutMs, timedOut) {
// Never echo query strings, URL credentials, request bodies, or raw causes.
const { origin: endpoint, port } = new URL(url);
const codes = errorCodes(error);
const code = codes[0] ?? 'NETWORK_ERROR';
const messages = causeMessages(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 (messages.includes('bad port')) {
return new NetworkError(`Node's fetch refuses port ${port} of ${endpoint} (a port the Fetch standard blocks). Use another port.`, 'BLOCKED_PORT', error);
}
if (messages.includes('unexpected redirect')) {
return new NetworkError(`${endpoint} answered with a redirect, which the CLI does not follow.`, 'UNEXPECTED_REDIRECT', error);
}
if (codes.some((c) => /CERT|SELF_SIGNED|UNABLE_TO_VERIFY|UNABLE_TO_GET_ISSUER/.test(c))) {
// Without system trust (older Node), the OS certificate store isn't used at all.
const upgrade = isSystemTrustConfigured() ? '' : ' Use Node 22.19+ or Node 24.5+ for automatic system certificate trust.';
return new NetworkError(`TLS certificate verification failed for ${endpoint} (${code}).${upgrade} 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.`, code, error, 'Run `vigilbase doctor` for connectivity checks.');
}
const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
/** The option that sets this origin, when the caller didn't name it. */
function optionFor(config, origin) {
if (origin === config.publicBaseUrl)
return '--base-url';
if (origin === config.oneBaseUrl)
return '--one-url';
if (origin === config.identityBaseUrl)
return '--auth-url';
return undefined;
}
/** A redirect the CLI refuses, naming only the target's origin: its path and query may carry tokens. */
function redirectError(config, url, response, named) {
const { origin } = new URL(url);
let target;
try {
const location = new URL(response.headers.get('location') ?? '', url);
if ((location.protocol === 'https:' || location.protocol === 'http:') && location.origin !== origin)
target = location.origin;
}
catch {
// An unparsable Location: say only that it redirected.
}
// Public, One and Identity may share an origin, so the caller's service decides the option.
const option = named ?? optionFor(config, origin);
const message = !target
? `${origin} answered with a redirect (HTTP ${response.status}), which the CLI does not follow.`
: option
? `${origin} redirected (HTTP ${response.status}) to ${target}. Set ${option} to that origin.`
: `${origin} redirected (HTTP ${response.status}) to ${target}, which the CLI does not follow.`;
return new NetworkError(message, 'UNEXPECTED_REDIRECT');
}
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. `option` names the option that sets the
* service's origin, for the message when it redirects.
*/
export async function requestText(config, url, { option, ...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 follow redirects: they could carry token-bearing POST bodies (or credentials) elsewhere.
// 'manual' surfaces the 3xx, which is refused here unless the caller asked for it.
const response = await fetch(url, { ...init, signal, redirect: 'manual' });
if (init.redirect !== 'manual' && REDIRECT_STATUSES.has(response.status) && response.headers.has('location')) {
await response.body?.cancel().catch(() => undefined);
throw redirectError(config, url, response, option);
}
return { response, text: await readBoundedBody(response, signal) };
}
catch (error) {
if (error instanceof NetworkError)
throw error;
// Fetch refuses a header value (a token with a newline) before sending anything. Its message holds the value.
if (error instanceof TypeError && error.cause === undefined && /header/i.test(error.message)) {
throw Object.assign(new Error('A request header contains characters HTTP does not allow, so nothing was sent.'), { code: 'INVALID_ARGUMENT' });
}
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');
}
}