estados-homeserve/index.js

747 lines
24 KiB
JavaScript

'use strict';
/**
* estados-hs-direct (HomeServe)
* - API: POST /api/homeserve/change-status
* - Health: GET /health
* - Test UI: GET /test
*
* Lee credenciales:
* 1) env HOMESERVE_USER/HOMESERVE_PASS (si existen)
* 2) Firestore doc HS_CRED_DOC_PATH (por defecto: providerCredentials/homeserve) con { user, pass, baseUrl? }
*
* Navegación:
* - Abre servicio: https://www.clientes.homeserve.es/cgi-bin/fccgi.exe?w3exec=ver_servicioencurso&Servicio=XXXX&Pag=1
* - Click botón cambio estado: input[type="image"][name="repaso"] (o title contiene "Cambiar el Estado del Servicio")
* - En formulario: select estado (normalmente name="D1" o similar) -> selecciona por value (código)
* - Checkbox "ya informado al Cliente" (si informoCliente=true)
* - Guardar/Aceptar/Actualizar
*/
const express = require('express');
const cors = require('cors');
const { chromium } = require('playwright');
const admin = require('firebase-admin');
// --------------------- Utils ---------------------
function mustEnv(name) {
const v = process.env[name];
if (!v) throw new Error(`Missing env: ${name}`);
return v;
}
function toBool(v) {
if (v === true || v === false) return v;
if (v == null) return false;
const s = String(v).trim().toLowerCase();
return s === '1' || s === 'true' || s === 'yes' || s === 'y' || s === 'on';
}
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
function safeStr(x) {
return (x == null) ? '' : String(x);
}
function isoNow() {
return new Date().toISOString();
}
function pickPort() {
// CapRover normalmente usa process.env.PORT
const p = process.env.PORT || process.env.CAPROVER_PORT || '3000';
const n = parseInt(p, 10);
return Number.isFinite(n) ? n : 3000;
}
// --------------------- Firebase ---------------------
function initFirebase() {
if (!process.env.FIREBASE_PRIVATE_KEY) throw new Error('Missing env: FIREBASE_PRIVATE_KEY');
if (!admin.apps.length) {
admin.initializeApp({
credential: admin.credential.cert({
projectId: mustEnv('FIREBASE_PROJECT_ID'),
clientEmail: mustEnv('FIREBASE_CLIENT_EMAIL'),
privateKey: mustEnv('FIREBASE_PRIVATE_KEY').replace(/\\n/g, '\n'),
}),
});
}
return admin.firestore();
}
// --------------------- Config ---------------------
const CONFIG = {
REQUIRE_AUTH: toBool(process.env.REQUIRE_AUTH || '0'),
API_TOKEN: process.env.API_TOKEN || '',
HS_CRED_DOC_PATH: process.env.HS_CRED_DOC_PATH || 'providerCredentials/homeserve',
// Si NO viene baseUrl en Firestore ni env, usamos esta
HOMESERVE_BASE_URL: process.env.HOMESERVE_BASE_URL || 'https://www.clientes.homeserve.es/',
// Selectores HomeServe (con fallback)
SEL: {
// Login (muy genérico, porque HomeServe cambia formularios)
loginUserCandidates: [
'input[name="user"]',
'input[name="usuario"]',
'input[name="username"]',
'input[id*="user" i]',
'input[id*="usu" i]',
'input[type="text"]',
],
loginPassCandidates: [
'input[name="pass"]',
'input[name="password"]',
'input[name="clave"]',
'input[id*="pass" i]',
'input[id*="clave" i]',
'input[type="password"]',
],
loginSubmitCandidates: [
'button[type="submit"]',
'input[type="submit"]',
'input[type="image"]',
'button:has-text("Entrar")',
'button:has-text("Acceder")',
'input[value*="Entrar" i]',
'input[value*="Acceder" i]',
],
// Servicio -> botón cambio estado (repaso)
changeStateBtnCandidates: [
'input[type="image"][name="repaso"]',
'input[type="image"][title*="Cambiar el Estado del Servicio" i]',
'input[type="image"][title*="Cambiar el Estado" i]',
'input[name="repaso"]',
],
// Form cambio de estado
statusSelectCandidates: [
'select[name="D1"]',
'select[id="D1"]',
'select[name*="estado" i]',
'select[id*="estado" i]',
'select',
],
dateInputCandidates: [
'input[name="D2"]',
'input[id="D2"]',
'input[name*="fecha" i]',
'input[id*="fecha" i]',
'input[type="text"]',
],
obsCandidates: [
'textarea[name="D3"]',
'textarea[id="D3"]',
'textarea[name*="obs" i]',
'textarea[id*="obs" i]',
'textarea[name*="nota" i]',
'textarea[id*="nota" i]',
'textarea',
],
saveBtnCandidates: [
'input[type="submit"]',
'button[type="submit"]',
'input[type="image"][name*="grabar" i]',
'input[type="image"][title*="Guardar" i]',
'button:has-text("Guardar")',
'button:has-text("Aceptar")',
'button:has-text("Actualizar")',
'input[value*="Guardar" i]',
'input[value*="Aceptar" i]',
'input[value*="Actualizar" i]',
],
},
// Estados permitidos (los de tu Swift)
STATUS_OPTIONS: [
{ code: '303', title: 'En espera de Cliente por aceptación Presupuesto' },
{ code: '307', title: 'En espera de Profesional por fecha de inicio de trabajos' },
{ code: '313', title: 'En espera de Profesional por secado de cala, pintura o parquet' },
{ code: '318', title: 'En espera de Profesional por confirmación del Siniestro' },
{ code: '319', title: 'En espera de Profesional por material' },
{ code: '320', title: 'En espera de Profesional por espera de otro gremio' },
{ code: '321', title: 'En espera de Profesional por presupuesto/valoración' },
{ code: '323', title: 'En espera de Profesional por mejora del tiempo' },
{ code: '326', title: 'En espera de Cliente por pago de Factura Contado/Franquicia' },
{ code: '336', title: 'En espera de Profesional por avería en observación' },
{ code: '342', title: 'En espera de Profesional pendiente cobro franquicia' },
{ code: '345', title: 'En espera de Profesional en realización pendiente Terminar' },
{ code: '348', title: 'En espera de Cliente por indicaciones' },
{ code: '352', title: 'En espera de Perjudicado por indicaciones' },
],
};
// --------------------- Creds ---------------------
async function getHomeServeCreds(db) {
// 1) env manda
const envUser = process.env.HOMESERVE_USER;
const envPass = process.env.HOMESERVE_PASS;
if (envUser && envPass) {
return {
user: String(envUser),
pass: String(envPass),
baseUrl: process.env.HOMESERVE_BASE_URL || CONFIG.HOMESERVE_BASE_URL,
source: 'env',
};
}
// 2) Firestore doc
const ref = db.doc(CONFIG.HS_CRED_DOC_PATH);
const snap = await ref.get();
const d = snap.exists ? (snap.data() || {}) : null;
const user = d?.user || d?.username || d?.usuario;
const pass = d?.pass || d?.password || d?.clave;
const baseUrl = d?.baseUrl || d?.baseURL || d?.url || CONFIG.HOMESERVE_BASE_URL;
if (!user || !pass) {
throw new Error(
`HomeServe creds missing. Revisa Firestore doc "${CONFIG.HS_CRED_DOC_PATH}" con { user, pass, baseUrl? } o usa env HOMESERVE_USER/HOMESERVE_PASS`
);
}
return { user: String(user), pass: String(pass), baseUrl: String(baseUrl), source: CONFIG.HS_CRED_DOC_PATH };
}
// --------------------- Browser helpers ---------------------
async function withBrowser(fn) {
const browser = await chromium.launch({
headless: true,
args: ['--no-sandbox', '--disable-setuid-sandbox'],
});
const context = await browser.newContext({
viewport: { width: 1365, height: 768 },
});
const page = await context.newPage();
page.setDefaultTimeout(60000);
try {
return await fn(page);
} finally {
await browser.close().catch(() => {});
}
}
async function findFirstHandle(page, selectors) {
for (const sel of selectors) {
try {
const h = await page.$(sel);
if (h) return { handle: h, selector: sel };
} catch (_) {}
}
return null;
}
async function fillFirst(page, selectors, value) {
const found = await findFirstHandle(page, selectors);
if (!found) return false;
await found.handle.fill(String(value));
return true;
}
async function clickFirst(page, selectors) {
const found = await findFirstHandle(page, selectors);
if (!found) return false;
await found.handle.click();
return true;
}
function buildServiceUrl(baseUrl, serviceNumber) {
// baseUrl puede venir como dominio o con path; garantizamos el CGI correcto
const origin = new URL(baseUrl).origin;
const u = new URL(origin + '/cgi-bin/fccgi.exe');
u.searchParams.set('w3exec', 'ver_servicioencurso');
u.searchParams.set('Servicio', String(serviceNumber));
u.searchParams.set('Pag', '1');
return u.toString();
}
async function maybeLogin(page, creds) {
// Intentamos abrir baseUrl. Si ya está logueado, perfecto.
const base = creds.baseUrl || CONFIG.HOMESERVE_BASE_URL;
await page.goto(base, { waitUntil: 'domcontentloaded', timeout: 120000 });
await sleep(700);
// Si vemos algo típico del portal ya logueado, salimos (heurístico)
const alreadyOk = await page.$('input[type="image"][name="repaso"], a[href*="ver_servicioencurso" i], form');
if (!alreadyOk) {
// Igual es una landing rara, seguimos.
}
// Si NO existe password input, probablemente no es login o ya está logueado
const passHandle = await findFirstHandle(page, CONFIG.SEL.loginPassCandidates);
if (!passHandle) return;
// Usuario
const userOk = await fillFirst(page, CONFIG.SEL.loginUserCandidates, creds.user);
// Pass
const passOk = await fillFirst(page, CONFIG.SEL.loginPassCandidates, creds.pass);
if (!userOk || !passOk) {
// Si no pudimos, que no reviente aquí: lo intentaremos al entrar al servicio
return;
}
// Submit
const clicked = await clickFirst(page, CONFIG.SEL.loginSubmitCandidates);
if (!clicked) {
// fallback: enter
await page.keyboard.press('Enter').catch(() => {});
}
await page.waitForLoadState('domcontentloaded', { timeout: 120000 }).catch(() => {});
await page.waitForLoadState('networkidle', { timeout: 120000 }).catch(() => {});
await sleep(900);
}
async function goToService(page, serviceNumber, creds) {
const url = buildServiceUrl(creds.baseUrl || CONFIG.HOMESERVE_BASE_URL, serviceNumber);
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 120000 });
await page.waitForLoadState('networkidle', { timeout: 120000 }).catch(() => {});
await sleep(700);
// Si te manda a login, intentamos login y volvemos a ir
const hasPassword = await page.$('input[type="password"]');
if (hasPassword) {
await maybeLogin(page, creds);
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 120000 });
await page.waitForLoadState('networkidle', { timeout: 120000 }).catch(() => {});
await sleep(700);
}
return url;
}
async function clickChangeState(page) {
const ok = await clickFirst(page, CONFIG.SEL.changeStateBtnCandidates);
if (!ok) {
// fallback por texto/atributos
const ok2 = await page.evaluate(() => {
const inputs = Array.from(document.querySelectorAll('input[type="image"], input[type="submit"], button'));
const hit = inputs.find((el) => {
const t = (el.getAttribute('title') || el.getAttribute('name') || el.getAttribute('value') || '').toLowerCase();
return t.includes('cambiar') && t.includes('estado');
});
if (!hit) return false;
hit.click();
return true;
});
if (!ok2) throw new Error('No encuentro el botón de "Cambiar estado" (repaso).');
}
await page.waitForLoadState('domcontentloaded', { timeout: 120000 }).catch(() => {});
await page.waitForLoadState('networkidle', { timeout: 120000 }).catch(() => {});
await sleep(900);
}
async function selectStatusCode(page, code) {
// Preferimos seleccionar por value (porque tus códigos son los valores)
const found = await findFirstHandle(page, CONFIG.SEL.statusSelectCandidates);
if (!found) throw new Error('No encuentro el desplegable de estado (select).');
const sel = found.selector;
// Primero por value exacto
try {
await page.selectOption(sel, { value: String(code) });
return;
} catch (_) {
// Luego intentamos por label que contenga el código
}
const ok = await page.evaluate(({ sel, code }) => {
const s = document.querySelector(sel);
if (!s) return false;
const opts = Array.from(s.querySelectorAll('option'));
const hit =
opts.find(o => (o.getAttribute('value') || '').trim() === String(code).trim()) ||
opts.find(o => (o.textContent || '').includes(String(code)));
if (!hit) return false;
s.value = hit.value;
s.dispatchEvent(new Event('change', { bubbles: true }));
return true;
}, { sel, code });
if (!ok) throw new Error(`No encuentro opción para código de estado "${code}".`);
}
async function fillDate(page, dateString) {
if (!dateString) return;
// En HomeServe suele ser input text DD/MM/AAAA
// Buscamos uno "razonable" y lo rellenamos.
const found = await findFirstHandle(page, CONFIG.SEL.dateInputCandidates);
if (!found) return;
// Intento simple: fill
try {
await found.handle.fill(String(dateString));
} catch (_) {}
}
async function fillObservation(page, observation) {
if (!observation) return;
const found = await findFirstHandle(page, CONFIG.SEL.obsCandidates);
if (!found) return;
try {
await found.handle.fill(String(observation));
} catch (_) {}
}
async function setInformoCliente(page, informoCliente) {
if (!informoCliente) return;
// 1) getByLabel (si existe)
try {
const locator = page.getByLabel(/Marque esta casilla.*informado al Cliente/i);
if (await locator.count()) {
await locator.first().check({ force: true });
return;
}
} catch (_) {}
// 2) buscar checkbox por texto alrededor
const ok = await page.evaluate(() => {
const checkboxes = Array.from(document.querySelectorAll('input[type="checkbox"]'));
const hit = checkboxes.find((cb) => {
const rowText =
(cb.closest('tr')?.innerText || cb.parentElement?.innerText || '').toLowerCase();
return rowText.includes('informado') && rowText.includes('cliente');
});
if (!hit) return false;
hit.checked = true;
hit.dispatchEvent(new Event('change', { bubbles: true }));
hit.dispatchEvent(new Event('click', { bubbles: true }));
return true;
});
if (!ok) {
// No lo hacemos fatal: mejor seguir que romper el cambio por una casilla
console.warn('[estados-hs] ⚠️ No encontré la casilla "ya informado al Cliente".');
}
}
async function clickSave(page) {
const ok = await clickFirst(page, CONFIG.SEL.saveBtnCandidates);
if (!ok) {
const ok2 = await page.evaluate(() => {
const els = Array.from(document.querySelectorAll('button,input'));
const hit = els.find((el) => {
const t = (el.getAttribute('value') || el.textContent || el.getAttribute('title') || '').toLowerCase();
return t.includes('guardar') || t.includes('aceptar') || t.includes('actualizar') || t.includes('grabar');
});
if (!hit) return false;
hit.click();
return true;
});
if (!ok2) throw new Error('No encuentro el botón de Guardar/Aceptar/Actualizar del cambio de estado.');
}
await page.waitForLoadState('domcontentloaded', { timeout: 120000 }).catch(() => {});
await page.waitForLoadState('networkidle', { timeout: 120000 }).catch(() => {});
await sleep(1200);
}
// --------------------- Main action ---------------------
async function changeStatusViaClientesPortal(db, payload) {
const creds = await getHomeServeCreds(db);
const serviceNumber = String(payload.serviceNumber || '').trim();
const newStatusValue = String(payload.newStatusValue || '').trim();
const dateString = String(payload.dateString || '').trim();
const observation = String(payload.observation || '').trim();
const informoCliente = toBool(payload.informoCliente);
const startedAtISO = isoNow();
const result = await withBrowser(async (page) => {
// Login “suave”
await maybeLogin(page, creds);
// Ir al servicio (si no hay sesión, reintenta login)
const serviceUrl = await goToService(page, serviceNumber, creds);
// Click botón repaso (cambio estado)
await clickChangeState(page);
// Set campos
await selectStatusCode(page, newStatusValue);
await fillDate(page, dateString);
await fillObservation(page, observation);
await setInformoCliente(page, informoCliente);
// Guardar
await clickSave(page);
return { serviceUrl };
});
return {
ok: true,
serviceNumber,
newStatusValue,
dateString,
observation,
informoCliente,
startedAtISO,
finishedAtISO: isoNow(),
...result,
credsSource: creds.source,
baseUrl: creds.baseUrl,
};
}
// --------------------- Express app ---------------------
function requireAuthIfNeeded(req, res) {
if (!CONFIG.REQUIRE_AUTH) return true;
const auth = (req.headers.authorization || '').trim();
const x = (req.headers['x-auth'] || '').toString().trim();
const token = CONFIG.API_TOKEN.trim();
const got =
(auth.toLowerCase().startsWith('bearer ') ? auth.slice(7).trim() : '') ||
x;
if (!token) {
// Si REQUIRE_AUTH=1 pero no hay token configurado, mejor bloquear.
res.status(500).json({ ok: false, error: { message: 'REQUIRE_AUTH=1 pero falta API_TOKEN en env.' } });
return false;
}
if (got !== token) {
res.status(401).json({ ok: false, error: { message: 'Unauthorized' } });
return false;
}
return true;
}
function makeTestHtml(defaultBase) {
const options = CONFIG.STATUS_OPTIONS.map((o) =>
`<option value="${o.code}">${o.code} · ${escapeHtml(o.title)}</option>`
).join('\n');
return `<!doctype html>
<html lang="es">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>estados-hs · tester</title>
<style>
body{font-family:system-ui,-apple-system,Segoe UI,Roboto,Arial;margin:0;padding:24px;background:#0b1220;color:#e8eefc}
.card{max-width:860px;margin:0 auto;background:#111a2e;border:1px solid rgba(255,255,255,.08);border-radius:16px;padding:18px}
label{display:block;margin:12px 0 6px;font-size:13px;opacity:.85}
input,select,textarea,button{width:100%;padding:12px;border-radius:12px;border:1px solid rgba(255,255,255,.12);background:#0b1220;color:#e8eefc;box-sizing:border-box}
textarea{min-height:90px}
button{cursor:pointer;font-weight:800}
.row{display:grid;grid-template-columns:1fr 1fr;gap:12px}
.row3{display:grid;grid-template-columns:1fr 1fr 1fr;gap:12px}
.log{white-space:pre-wrap;background:#0b1220;border-radius:12px;border:1px solid rgba(255,255,255,.12);padding:12px;margin-top:12px;font-size:12px}
.muted{opacity:.7;font-size:12px}
.btnrow{display:grid;grid-template-columns:1fr 1fr 1fr;gap:10px;margin-top:10px}
.pill{display:inline-flex;gap:8px;align-items:center}
.pill input{width:auto}
</style>
</head>
<body>
<div class="card">
<h2 style="margin:0 0 8px">estados-hs · tester</h2>
<div class="muted">Consejo rápido: si ves <b>ok</b> en <code>/</code> y JSON en <code>/health</code>, el servicio está vivo. Luego ya nos peleamos con HomeServe 😅</div>
<label>Base URL (solo para probar endpoints desde aquí)</label>
<input id="base" value="${escapeHtml(defaultBase)}" />
<div class="btnrow">
<button onclick="probe('/')">Probar /</button>
<button onclick="probe('/health')">Probar /health</button>
<button onclick="probe('/test')">Probar /test</button>
</div>
<div style="height:14px"></div>
<div class="muted">POST <code>/api/homeserve/change-status</code></div>
<label>Service Number</label>
<input id="serviceNumber" placeholder="15251178" />
<div class="row">
<div>
<label>Código estado</label>
<select id="statusCode">${options}</select>
</div>
<div>
<label>Fecha (DD/MM/AAAA) (opcional)</label>
<input id="dateString" placeholder="05/01/2026" />
</div>
</div>
<label>Observación (opcional)</label>
<textarea id="observation" placeholder="Texto..."></textarea>
<div class="row3">
<div class="pill">
<input id="informoCliente" type="checkbox" />
<label for="informoCliente" style="margin:0;opacity:.9">Ya he informado al cliente</label>
</div>
<div>
<label>Auth token (si REQUIRE_AUTH=1)</label>
<input id="token" placeholder="Bearer o X-Auth..." />
</div>
<div>
<label>&nbsp;</label>
<button id="btn">Enviar</button>
</div>
</div>
<div class="log" id="log">Listo.</div>
</div>
<script>
const $ = (id)=>document.getElementById(id);
const log = (x)=>{ $('log').textContent = (typeof x==='string'?x:JSON.stringify(x,null,2)); };
async function probe(path){
const base = $('base').value.trim().replace(/\\/+$/,'');
try{
const r = await fetch(base + path, { method:'GET' });
const ct = r.headers.get('content-type') || '';
let body = null;
if(ct.includes('application/json')) body = await r.json().catch(()=>({}));
else body = await r.text().catch(()=> '');
log({ http: r.status, base, path, response: body });
}catch(e){
log({ http: 0, base, path, error: String(e) });
}
}
$('btn').addEventListener('click', async () => {
try{
const base = $('base').value.trim().replace(/\\/+$/,'');
const url = base + '/api/homeserve/change-status';
log('Enviando...');
const body = {
serviceNumber: $('serviceNumber').value.trim(),
newStatusValue: $('statusCode').value.trim(),
dateString: $('dateString').value.trim(),
observation: $('observation').value.trim(),
informoCliente: $('informoCliente').checked
};
const token = $('token').value.trim();
const headers = { 'Content-Type':'application/json' };
if(token){
if(token.toLowerCase().startsWith('bearer ')) headers['Authorization'] = token;
else headers['X-Auth'] = token;
}
const r = await fetch(url, { method:'POST', headers, body: JSON.stringify(body) });
const j = await r.json().catch(()=>({}));
log({ http: r.status, base, url, request: body, response: j });
}catch(e){
log(String(e));
}
});
</script>
</body>
</html>`;
}
function escapeHtml(s) {
return String(s)
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
}
// --------------------- Boot ---------------------
async function main() {
const db = initFirebase();
const app = express();
app.use(cors());
app.use(express.json({ limit: '1mb' }));
app.get('/', (req, res) => res.status(200).send('ok'));
app.get('/health', (req, res) => {
res.json({
ok: true,
service: 'estados-hs',
port: pickPort(),
requireAuth: CONFIG.REQUIRE_AUTH,
hsCredDocPath: CONFIG.HS_CRED_DOC_PATH,
ts: isoNow(),
});
});
app.get('/test', (req, res) => {
// Sirve el tester embebido
const base = `${req.protocol}://${req.get('host')}`;
res.status(200).type('text/html').send(makeTestHtml(base));
});
app.post('/api/homeserve/change-status', async (req, res) => {
if (!requireAuthIfNeeded(req, res)) return;
const startedAtISO = isoNow();
try {
const body = req.body || {};
const serviceNumber = String(body.serviceNumber || '').trim();
const newStatusValue = String(body.newStatusValue || '').trim();
if (!serviceNumber) {
return res.status(400).json({ ok: false, error: { message: 'Missing serviceNumber' } });
}
if (!newStatusValue) {
return res.status(400).json({ ok: false, error: { message: 'Missing newStatusValue' } });
}
// Validación ligera: que sea uno de tus códigos
const allowed = new Set(CONFIG.STATUS_OPTIONS.map(o => o.code));
if (!allowed.has(newStatusValue)) {
return res.status(400).json({
ok: false,
error: { message: `Estado no permitido: ${newStatusValue}` },
allowed: Array.from(allowed),
});
}
const out = await changeStatusViaClientesPortal(db, body);
return res.status(200).json(out);
} catch (e) {
return res.status(500).json({
ok: false,
startedAtISO,
finishedAtISO: isoNow(),
error: {
message: String(e?.message || e),
stack: String(e?.stack || ''),
},
});
}
});
const port = pickPort();
app.listen(port, '0.0.0.0', () => {
console.log(`[estados-hs] listening on :${port}`);
console.log(`[estados-hs] HS_CRED_DOC_PATH=${CONFIG.HS_CRED_DOC_PATH}`);
console.log(`[estados-hs] REQUIRE_AUTH=${CONFIG.REQUIRE_AUTH ? '1' : '0'}`);
console.log(`[estados-hs] ENV PORT=${process.env.PORT || '(unset)'} CAPROVER_PORT=${process.env.CAPROVER_PORT || '(unset)'}`);
});
}
main().catch((e) => {
console.error(e);
process.exit(1);
});