493 lines
15 KiB
JavaScript
493 lines
15 KiB
JavaScript
|
|
#!/usr/bin/env node
|
||
|
|
|
||
|
|
const https = require('https');
|
||
|
|
const fs = require('fs');
|
||
|
|
const path = require('path');
|
||
|
|
|
||
|
|
let WebSocket;
|
||
|
|
try {
|
||
|
|
WebSocket = require('ws');
|
||
|
|
} catch (_) {
|
||
|
|
try {
|
||
|
|
WebSocket = require('../backend/node_modules/ws');
|
||
|
|
} catch (_2) {
|
||
|
|
console.error('Error: WebSocket library (ws) not found.');
|
||
|
|
console.error('Run: cd backend && npm install');
|
||
|
|
process.exit(1);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
// ANSI helpers
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
const C = {
|
||
|
|
reset: '\x1b[0m',
|
||
|
|
bold: '\x1b[1m',
|
||
|
|
dim: '\x1b[2m',
|
||
|
|
red: '\x1b[31m',
|
||
|
|
green: '\x1b[32m',
|
||
|
|
yellow: '\x1b[33m',
|
||
|
|
blue: '\x1b[34m',
|
||
|
|
magenta: '\x1b[35m',
|
||
|
|
cyan: '\x1b[36m',
|
||
|
|
white: '\x1b[37m',
|
||
|
|
};
|
||
|
|
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
// CLI argument parsing
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
function parseArgs() {
|
||
|
|
const args = process.argv.slice(2);
|
||
|
|
|
||
|
|
if (args.includes('--help') || args.includes('-h') || args.length === 0) {
|
||
|
|
console.log(`
|
||
|
|
${C.bold}Jackbox API Logger${C.reset}
|
||
|
|
Connects to a Jackbox game room and logs all WebSocket events.
|
||
|
|
|
||
|
|
${C.bold}Usage:${C.reset}
|
||
|
|
node tools/jackbox-logger.js <ROOM_CODE> [options]
|
||
|
|
|
||
|
|
${C.bold}Options:${C.reset}
|
||
|
|
--role <shard|audience|player> Connection role (default: shard)
|
||
|
|
--name <name> Display name (default: JBLogger)
|
||
|
|
--no-file Skip writing to log file
|
||
|
|
--verbose Print full JSON to console
|
||
|
|
--help, -h Show this help
|
||
|
|
|
||
|
|
${C.bold}Examples:${C.reset}
|
||
|
|
node tools/jackbox-logger.js ABCD
|
||
|
|
node tools/jackbox-logger.js ABCD --role audience
|
||
|
|
node tools/jackbox-logger.js ABCD --verbose --no-file
|
||
|
|
`);
|
||
|
|
process.exit(0);
|
||
|
|
}
|
||
|
|
|
||
|
|
const opts = {
|
||
|
|
roomCode: null,
|
||
|
|
role: 'shard',
|
||
|
|
name: 'JBLogger',
|
||
|
|
noFile: false,
|
||
|
|
verbose: false,
|
||
|
|
};
|
||
|
|
|
||
|
|
for (let i = 0; i < args.length; i++) {
|
||
|
|
const arg = args[i];
|
||
|
|
if (arg === '--role') {
|
||
|
|
opts.role = args[++i];
|
||
|
|
} else if (arg === '--name') {
|
||
|
|
opts.name = args[++i];
|
||
|
|
} else if (arg === '--no-file') {
|
||
|
|
opts.noFile = true;
|
||
|
|
} else if (arg === '--verbose') {
|
||
|
|
opts.verbose = true;
|
||
|
|
} else if (!arg.startsWith('-') && !opts.roomCode) {
|
||
|
|
opts.roomCode = arg.toUpperCase();
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
if (!opts.roomCode) {
|
||
|
|
console.error(`${C.red}Error: room code is required${C.reset}`);
|
||
|
|
process.exit(1);
|
||
|
|
}
|
||
|
|
|
||
|
|
if (!['shard', 'audience', 'player'].includes(opts.role)) {
|
||
|
|
console.error(`${C.red}Error: --role must be shard, audience, or player${C.reset}`);
|
||
|
|
process.exit(1);
|
||
|
|
}
|
||
|
|
|
||
|
|
return opts;
|
||
|
|
}
|
||
|
|
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
// REST: fetch room info
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
function getRoomInfo(roomCode) {
|
||
|
|
return new Promise((resolve, reject) => {
|
||
|
|
https.get(`https://ecast.jackboxgames.com/api/v2/rooms/${roomCode}`, (res) => {
|
||
|
|
let data = '';
|
||
|
|
res.on('data', (chunk) => (data += chunk));
|
||
|
|
res.on('end', () => {
|
||
|
|
try {
|
||
|
|
const json = JSON.parse(data);
|
||
|
|
if (json.ok) resolve(json.body);
|
||
|
|
else reject(new Error(json.error || 'Room not found'));
|
||
|
|
} catch (e) {
|
||
|
|
reject(e);
|
||
|
|
}
|
||
|
|
});
|
||
|
|
}).on('error', reject);
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
// Timestamp
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
function ts() {
|
||
|
|
return new Date().toISOString().slice(11, 23);
|
||
|
|
}
|
||
|
|
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
// Console summarizer — merges patterns from ws-probe.js and ws-lifecycle-test.js
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
function summarize(msg) {
|
||
|
|
const r = msg.result || {};
|
||
|
|
|
||
|
|
switch (msg.opcode) {
|
||
|
|
case 'client/welcome': {
|
||
|
|
const hereEntries = r.here ? Object.entries(r.here) : [];
|
||
|
|
const players = hereEntries
|
||
|
|
.filter(([, v]) => v.roles?.player)
|
||
|
|
.map(([id, v]) => `${v.roles.player.name}(${id})`);
|
||
|
|
const entityKeys = r.entities ? Object.keys(r.entities) : [];
|
||
|
|
return (
|
||
|
|
`id=${r.id} reconnect=${r.reconnect} secret=${r.secret}\n` +
|
||
|
|
` here: ${hereEntries.length} connections [${players.join(', ') || 'no players'}]\n` +
|
||
|
|
` entities: [${entityKeys.join(', ')}]`
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
case 'object': {
|
||
|
|
if (r.key === 'room' || r.key === 'bc:room') {
|
||
|
|
const v = r.val || {};
|
||
|
|
return `ROOM state=${v.state} lobby=${v.lobbyState} canStart=${v.gameCanStart} starting=${v.gameIsStarting} finished=${v.gameFinished} v${r.version}`;
|
||
|
|
}
|
||
|
|
if (r.key === 'textDescriptions') {
|
||
|
|
const latest = r.val?.latestDescriptions;
|
||
|
|
if (Array.isArray(latest) && latest.length > 0) {
|
||
|
|
const last = latest[latest.length - 1];
|
||
|
|
return `TEXT "${last.text}" (${last.category}) v${r.version}`;
|
||
|
|
}
|
||
|
|
return `textDescriptions v${r.version}`;
|
||
|
|
}
|
||
|
|
if (r.key?.startsWith('player:')) {
|
||
|
|
const v = r.val || {};
|
||
|
|
return `PLAYER ${r.key} state=${v.state || 'init'} name=${v.playerName || '?'} vip=${v.playerIsVIP} v${r.version}`;
|
||
|
|
}
|
||
|
|
const valKeys = r.val ? Object.keys(r.val).slice(0, 5).join(',') : 'null';
|
||
|
|
return `ENTITY ${r.key} v${r.version} from=${r.from} val=[${valKeys}...]`;
|
||
|
|
}
|
||
|
|
|
||
|
|
case 'client/connected': {
|
||
|
|
const roleName = r.roles ? Object.keys(r.roles)[0] : r.role;
|
||
|
|
const playerName = r.roles?.player?.name || r.name || '';
|
||
|
|
return `id=${r.id} userId=${r.userId} role=${roleName} name=${playerName}`;
|
||
|
|
}
|
||
|
|
|
||
|
|
case 'client/disconnected': {
|
||
|
|
const roleName = r.roles ? Object.keys(r.roles)[0] : r.role;
|
||
|
|
return `id=${r.id} role=${roleName}`;
|
||
|
|
}
|
||
|
|
|
||
|
|
case 'client/kicked':
|
||
|
|
return JSON.stringify(r).slice(0, 200);
|
||
|
|
|
||
|
|
case 'room/lock':
|
||
|
|
return 'room locked (game starting)';
|
||
|
|
|
||
|
|
case 'room/exit':
|
||
|
|
return 'room closed';
|
||
|
|
|
||
|
|
case 'room/get-audience':
|
||
|
|
return `audience connections=${r.connections}`;
|
||
|
|
|
||
|
|
case 'error':
|
||
|
|
return `code=${r.code}: ${r.msg}`;
|
||
|
|
|
||
|
|
case 'ok':
|
||
|
|
return `seq response`;
|
||
|
|
|
||
|
|
default:
|
||
|
|
return JSON.stringify(r).slice(0, 200);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function opcodeColor(opcode) {
|
||
|
|
if (opcode === 'client/welcome') return C.green;
|
||
|
|
if (opcode === 'error') return C.red;
|
||
|
|
if (opcode === 'client/connected') return C.cyan;
|
||
|
|
if (opcode === 'client/disconnected') return C.yellow;
|
||
|
|
if (opcode === 'room/lock' || opcode === 'room/exit') return C.magenta;
|
||
|
|
if (opcode === 'object') return C.white;
|
||
|
|
return C.dim;
|
||
|
|
}
|
||
|
|
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
// File logger
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
class FileLogger {
|
||
|
|
constructor(roomCode) {
|
||
|
|
const logsDir = path.join(__dirname, '..', 'logs');
|
||
|
|
fs.mkdirSync(logsDir, { recursive: true });
|
||
|
|
|
||
|
|
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
||
|
|
this.filePath = path.join(logsDir, `jackbox-${roomCode}-${timestamp}.jsonl`);
|
||
|
|
this.stream = fs.createWriteStream(this.filePath, { flags: 'a' });
|
||
|
|
}
|
||
|
|
|
||
|
|
write(entry) {
|
||
|
|
this.stream.write(JSON.stringify(entry) + '\n');
|
||
|
|
}
|
||
|
|
|
||
|
|
close() {
|
||
|
|
this.stream.end();
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
// Main logger
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
async function main() {
|
||
|
|
const opts = parseArgs();
|
||
|
|
const { roomCode, role, name, noFile, verbose } = opts;
|
||
|
|
|
||
|
|
console.log(`${C.bold}Jackbox API Logger${C.reset}`);
|
||
|
|
console.log(`${C.cyan}Room:${C.reset} ${roomCode} ${C.cyan}Role:${C.reset} ${role} ${C.cyan}Name:${C.reset} ${name}`);
|
||
|
|
console.log();
|
||
|
|
|
||
|
|
// Fetch room info
|
||
|
|
console.log(`${C.dim}[${ts()}]${C.reset} Fetching room info...`);
|
||
|
|
let roomInfo;
|
||
|
|
try {
|
||
|
|
roomInfo = await getRoomInfo(roomCode);
|
||
|
|
} catch (e) {
|
||
|
|
console.error(`${C.red}Failed to fetch room info: ${e.message}${C.reset}`);
|
||
|
|
process.exit(1);
|
||
|
|
}
|
||
|
|
|
||
|
|
console.log(`${C.dim}[${ts()}]${C.reset} ${C.green}Room found${C.reset}`);
|
||
|
|
console.log(` ${C.cyan}Game:${C.reset} ${roomInfo.appTag}`);
|
||
|
|
console.log(` ${C.cyan}Host:${C.reset} ${roomInfo.host}`);
|
||
|
|
console.log(` ${C.cyan}Players:${C.reset} max ${roomInfo.maxPlayers}`);
|
||
|
|
console.log(` ${C.cyan}Locked:${C.reset} ${roomInfo.locked}`);
|
||
|
|
console.log(` ${C.cyan}Full:${C.reset} ${roomInfo.full}`);
|
||
|
|
console.log(` ${C.cyan}Audience:${C.reset} ${roomInfo.audienceEnabled ? 'enabled' : 'disabled'}`);
|
||
|
|
console.log();
|
||
|
|
|
||
|
|
// File logger
|
||
|
|
let fileLogger = null;
|
||
|
|
if (!noFile) {
|
||
|
|
fileLogger = new FileLogger(roomCode);
|
||
|
|
console.log(`${C.dim}[${ts()}]${C.reset} Logging to ${C.bold}${fileLogger.filePath}${C.reset}`);
|
||
|
|
fileLogger.write({
|
||
|
|
ts: new Date().toISOString(),
|
||
|
|
direction: 'meta',
|
||
|
|
type: 'session_start',
|
||
|
|
roomCode,
|
||
|
|
role,
|
||
|
|
name,
|
||
|
|
host: roomInfo.host,
|
||
|
|
appTag: roomInfo.appTag,
|
||
|
|
maxPlayers: roomInfo.maxPlayers,
|
||
|
|
locked: roomInfo.locked,
|
||
|
|
full: roomInfo.full,
|
||
|
|
audienceEnabled: roomInfo.audienceEnabled,
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
// State for reconnection
|
||
|
|
let shardId = null;
|
||
|
|
let secret = null;
|
||
|
|
let msgCount = 0;
|
||
|
|
let reconnecting = false;
|
||
|
|
let manuallyStopped = false;
|
||
|
|
const startTime = Date.now();
|
||
|
|
|
||
|
|
function buildWsUrl(reconnect) {
|
||
|
|
if (role === 'audience') {
|
||
|
|
return `wss://${roomInfo.audienceHost || roomInfo.host}/api/v2/audience/${roomCode}/play`;
|
||
|
|
}
|
||
|
|
const base = `wss://${roomInfo.host}/api/v2/rooms/${roomCode}/play`;
|
||
|
|
if (reconnect && secret && shardId) {
|
||
|
|
return `${base}?role=${role}&name=${encodeURIComponent(name)}&format=json&secret=${secret}&id=${shardId}`;
|
||
|
|
}
|
||
|
|
return `${base}?role=${role}&name=${encodeURIComponent(name)}&userId=${name}-${Date.now()}&format=json`;
|
||
|
|
}
|
||
|
|
|
||
|
|
function connect(isReconnect) {
|
||
|
|
const url = buildWsUrl(isReconnect);
|
||
|
|
console.log(`${C.dim}[${ts()}]${C.reset} ${isReconnect ? 'Reconnecting' : 'Connecting'}: ${C.dim}${url}${C.reset}`);
|
||
|
|
|
||
|
|
const ws = new WebSocket(url, ['ecast-v0'], {
|
||
|
|
headers: {
|
||
|
|
Origin: 'https://jackbox.tv',
|
||
|
|
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36',
|
||
|
|
},
|
||
|
|
handshakeTimeout: 10000,
|
||
|
|
});
|
||
|
|
|
||
|
|
ws.on('open', () => {
|
||
|
|
console.log(`${C.dim}[${ts()}]${C.reset} ${C.green}${C.bold}CONNECTED${C.reset}`);
|
||
|
|
console.log();
|
||
|
|
});
|
||
|
|
|
||
|
|
ws.on('message', (raw) => {
|
||
|
|
msgCount++;
|
||
|
|
try {
|
||
|
|
const msg = JSON.parse(raw.toString());
|
||
|
|
|
||
|
|
// Capture credentials from welcome for reconnection
|
||
|
|
if (msg.opcode === 'client/welcome' && msg.result) {
|
||
|
|
shardId = msg.result.id;
|
||
|
|
secret = msg.result.secret;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Console output
|
||
|
|
const color = opcodeColor(msg.opcode);
|
||
|
|
const summary = summarize(msg);
|
||
|
|
console.log(
|
||
|
|
`${C.dim}[${ts()}]${C.reset} ${C.dim}#${msgCount} pc:${msg.pc ?? '-'}${C.reset} ${color}${C.bold}${msg.opcode}${C.reset} ${summary}`
|
||
|
|
);
|
||
|
|
if (verbose) {
|
||
|
|
console.log(JSON.stringify(msg, null, 2));
|
||
|
|
}
|
||
|
|
|
||
|
|
// File output
|
||
|
|
if (fileLogger) {
|
||
|
|
fileLogger.write({
|
||
|
|
ts: new Date().toISOString(),
|
||
|
|
direction: 'recv',
|
||
|
|
msgNum: msgCount,
|
||
|
|
pc: msg.pc,
|
||
|
|
opcode: msg.opcode,
|
||
|
|
raw: msg,
|
||
|
|
});
|
||
|
|
}
|
||
|
|
} catch (e) {
|
||
|
|
console.log(`${C.dim}[${ts()}]${C.reset} ${C.yellow}UNPARSEABLE${C.reset} ${raw.toString().slice(0, 200)}`);
|
||
|
|
if (fileLogger) {
|
||
|
|
fileLogger.write({
|
||
|
|
ts: new Date().toISOString(),
|
||
|
|
direction: 'recv',
|
||
|
|
msgNum: msgCount,
|
||
|
|
parseError: true,
|
||
|
|
raw: raw.toString().slice(0, 2000),
|
||
|
|
});
|
||
|
|
}
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
ws.on('close', (code, reason) => {
|
||
|
|
console.log(`${C.dim}[${ts()}]${C.reset} ${C.yellow}DISCONNECTED${C.reset} code=${code} reason=${reason}`);
|
||
|
|
if (fileLogger) {
|
||
|
|
fileLogger.write({
|
||
|
|
ts: new Date().toISOString(),
|
||
|
|
direction: 'meta',
|
||
|
|
type: 'disconnected',
|
||
|
|
code,
|
||
|
|
reason: reason?.toString(),
|
||
|
|
});
|
||
|
|
}
|
||
|
|
if (!manuallyStopped && secret != null) {
|
||
|
|
reconnectWithBackoff(ws);
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
ws.on('error', (err) => {
|
||
|
|
console.error(`${C.dim}[${ts()}]${C.reset} ${C.red}WS ERROR: ${err.message}${C.reset}`);
|
||
|
|
if (fileLogger) {
|
||
|
|
fileLogger.write({
|
||
|
|
ts: new Date().toISOString(),
|
||
|
|
direction: 'meta',
|
||
|
|
type: 'error',
|
||
|
|
message: err.message,
|
||
|
|
});
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
// SIGINT handler
|
||
|
|
const onSigint = () => {
|
||
|
|
manuallyStopped = true;
|
||
|
|
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
|
||
|
|
console.log();
|
||
|
|
console.log(`${C.bold}--- Session Summary ---${C.reset}`);
|
||
|
|
console.log(` ${C.cyan}Messages:${C.reset} ${msgCount}`);
|
||
|
|
console.log(` ${C.cyan}Duration:${C.reset} ${elapsed}s`);
|
||
|
|
if (fileLogger) {
|
||
|
|
console.log(` ${C.cyan}Log file:${C.reset} ${fileLogger.filePath}`);
|
||
|
|
fileLogger.write({
|
||
|
|
ts: new Date().toISOString(),
|
||
|
|
direction: 'meta',
|
||
|
|
type: 'session_end',
|
||
|
|
msgCount,
|
||
|
|
durationMs: Date.now() - startTime,
|
||
|
|
});
|
||
|
|
fileLogger.close();
|
||
|
|
}
|
||
|
|
console.log();
|
||
|
|
try {
|
||
|
|
ws.close(1000, 'Logger stopped');
|
||
|
|
} catch (_) {}
|
||
|
|
setTimeout(() => process.exit(0), 500);
|
||
|
|
};
|
||
|
|
|
||
|
|
process.removeAllListeners('SIGINT');
|
||
|
|
process.on('SIGINT', onSigint);
|
||
|
|
|
||
|
|
return ws;
|
||
|
|
}
|
||
|
|
|
||
|
|
async function reconnectWithBackoff() {
|
||
|
|
if (reconnecting || manuallyStopped) return;
|
||
|
|
reconnecting = true;
|
||
|
|
const delays = [2000, 4000, 8000];
|
||
|
|
|
||
|
|
for (let i = 0; i < delays.length; i++) {
|
||
|
|
console.log(`${C.dim}[${ts()}]${C.reset} ${C.yellow}Reconnect attempt ${i + 1}/${delays.length} in ${delays[i] / 1000}s...${C.reset}`);
|
||
|
|
await new Promise((r) => setTimeout(r, delays[i]));
|
||
|
|
|
||
|
|
if (manuallyStopped) {
|
||
|
|
reconnecting = false;
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
try {
|
||
|
|
const freshRoom = await getRoomInfo(roomCode);
|
||
|
|
if (!freshRoom) {
|
||
|
|
console.log(`${C.dim}[${ts()}]${C.reset} ${C.red}Room no longer exists${C.reset}`);
|
||
|
|
reconnecting = false;
|
||
|
|
shutdown();
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
roomInfo.host = freshRoom.host;
|
||
|
|
connect(true);
|
||
|
|
reconnecting = false;
|
||
|
|
return;
|
||
|
|
} catch (e) {
|
||
|
|
console.error(`${C.dim}[${ts()}]${C.reset} ${C.red}Reconnect attempt ${i + 1} failed: ${e.message}${C.reset}`);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
console.error(`${C.dim}[${ts()}]${C.reset} ${C.red}${C.bold}All reconnect attempts failed. Exiting.${C.reset}`);
|
||
|
|
reconnecting = false;
|
||
|
|
shutdown();
|
||
|
|
}
|
||
|
|
|
||
|
|
function shutdown() {
|
||
|
|
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
|
||
|
|
console.log();
|
||
|
|
console.log(`${C.bold}--- Session Summary ---${C.reset}`);
|
||
|
|
console.log(` ${C.cyan}Messages:${C.reset} ${msgCount}`);
|
||
|
|
console.log(` ${C.cyan}Duration:${C.reset} ${elapsed}s`);
|
||
|
|
if (fileLogger) {
|
||
|
|
console.log(` ${C.cyan}Log file:${C.reset} ${fileLogger.filePath}`);
|
||
|
|
fileLogger.write({
|
||
|
|
ts: new Date().toISOString(),
|
||
|
|
direction: 'meta',
|
||
|
|
type: 'session_end',
|
||
|
|
msgCount,
|
||
|
|
durationMs: Date.now() - startTime,
|
||
|
|
});
|
||
|
|
fileLogger.close();
|
||
|
|
}
|
||
|
|
console.log();
|
||
|
|
process.exit(1);
|
||
|
|
}
|
||
|
|
|
||
|
|
connect(false);
|
||
|
|
}
|
||
|
|
|
||
|
|
main().catch((e) => {
|
||
|
|
console.error(`${C.red}Fatal: ${e.message}${C.reset}`);
|
||
|
|
process.exit(1);
|
||
|
|
});
|