#!/usr/bin/env tsx /** * CancerIndex operator CLI. * * pnpm cix connectors list connectors + health * pnpm cix run [--mode full|incremental|dry_run] [--max-records N] [--max-minutes M] [--reset-cursor] * pnpm cix run-all [--max-minutes M] run every active connector in registry order * pnpm cix health source liveness probe * pnpm cix sources:sync manifests → sources table * pnpm cix counters recompute entity counters * pnpm cix rank [--scope …] recompute ranking snapshots * pnpm cix stats table counts * pnpm cix trace lineage of a value (rankings/epidemiology/frequency) * pnpm cix doctor [--json] [--no-disk] readiness report (env, DB, migrations, connectors, alerts…); exit 1 on hard failure * pnpm cix alerts [--status open|acknowledged|resolved|active] [--connector id] [--json] * pnpm cix alerts ack|resolve acknowledge / resolve one alert */ import { existsSync } from 'node:fs'; import path from 'node:path'; import { sql } from 'drizzle-orm'; for (const candidate of [path.resolve(process.cwd(), '.env')]) { if (existsSync(candidate)) { try { process.loadEnvFile(candidate); } catch { /* ignore */ } } } const [, , cmd, ...rest] = process.argv; const flags: Record = {}; const positional: string[] = []; for (let i = 0; i < rest.length; i++) { const a = rest[i]!; if (a.startsWith('--')) { const k = a.slice(2); const next = rest[i + 1]; if (next && !next.startsWith('--')) { flags[k] = next; i++; } else flags[k] = true; } else positional.push(a); } async function main() { const { getDb, closeDb } = await import('@cancerindex/database'); const db = getDb(); try { switch (cmd) { case 'connectors': { const { CONNECTORS } = await import('@cancerindex/connectors'); const rows = await db.execute<{ connector_id: string; health: string; last_success_at: string | null; cursor: unknown }>(sql`SELECT connector_id, health, last_success_at, cursor FROM connector_cursors`); const byId = new Map(rows.map((r) => [r.connector_id, r])); for (const c of CONNECTORS) { const h = byId.get(c.manifest.id); console.log(`${c.manifest.id.padEnd(18)} tier${c.manifest.tier} ${c.manifest.status.padEnd(20)} license=${c.manifest.licenseStatus.padEnd(10)} health=${(h?.health ?? 'never-run').padEnd(12)} last_ok=${h?.last_success_at ?? '-'}`); } break; } case 'sources:sync': { const { syncSources, PLANNED_MANIFESTS } = await import('@cancerindex/connectors'); const r = await syncSources(db, PLANNED_MANIFESTS); console.log(`[sources] created=${r.created} updated=${r.updated}`); break; } case 'health': { const { getConnector, RunContext } = await import('@cancerindex/connectors'); const c = getConnector(positional[0] ?? ''); if (!c) throw new Error(`unknown connector ${positional[0]}`); const [src] = await db.execute<{ id: string }>(sql`SELECT id FROM sources WHERE slug = ${c.manifest.id}`); const ctx = new RunContext(db, c.manifest, src?.id ?? 'CI-SOURCE-00000000', 'probe', { maxMinutes: 1 }, 0); console.log(await c.healthCheck(ctx)); break; } case 'run': { const { getConnector, runConnector } = await import('@cancerindex/connectors'); const c = getConnector(positional[0] ?? ''); if (!c) throw new Error(`unknown connector ${positional[0]}`); const r = await runConnector(db, c, { mode: (flags.mode as never) ?? undefined, maxRecords: flags['max-records'] ? Number(flags['max-records']) : undefined, maxMinutes: flags['max-minutes'] ? Number(flags['max-minutes']) : undefined, resetCursor: !!flags['reset-cursor'], }); console.log(`[run] ${r.runId} ${r.status}`, r.counters); if (r.status === 'failed') process.exitCode = 1; break; } case 'run-all': { const { CONNECTORS, runConnector } = await import('@cancerindex/connectors'); for (const c of CONNECTORS) { if (c.manifest.status !== 'active') { console.log(`[skip] ${c.manifest.id} (${c.manifest.status})`); continue; } const r = await runConnector(db, c, { maxMinutes: flags['max-minutes'] ? Number(flags['max-minutes']) : undefined }); console.log(`[run] ${r.runId} ${r.status}`, r.counters); } break; } case 'counters': { const { refreshCounters } = await import('@cancerindex/ranking'); const n = await refreshCounters(db); console.log(`[counters] refreshed ${n} entities`); break; } case 'intel': { const { computeIntelligence } = await import('@cancerindex/ranking'); const r = await computeIntelligence(db, (m, extra) => console.log(`[intel] ${m}`, extra ?? '')); console.log(`[intel] trial_intelligence=${r.trialIntelligenceRows} site_countries=${r.trialSiteCountryRows} drug_pipeline=${r.drugPipelineRows} research_gap=${r.researchGapRows} (${r.ms} ms)`); break; } case 'reconcile-drugs': { const { reconcileInterventionDrugs } = await import('@cancerindex/connectors'); const r = await reconcileInterventionDrugs(db, { remap: !!flags.remap }); console.log('[reconcile-drugs]', r); break; } case 'rank': { const { computeAllRankings } = await import('@cancerindex/ranking'); const res = await computeAllRankings(db); for (const r of res) console.log(`[rank] ${r.metricSlug} ${r.scopeKey} eligible=${r.eligible}`); break; } case 'stats': { const tables = ['sources', 'ingest_runs', 'source_records', 'provenance', 'cancers', 'cancer_aliases', 'cancer_hierarchy', 'cancer_codes', 'genes', 'variants', 'drugs', 'clinical_trials', 'trial_conditions', 'publications', 'literature_counts', 'civic_evidence_items', 'genomic_cohorts', 'cancer_gene_frequencies', 'epidemiology_observations', 'survival_observations', 'knowledge_edges', 'unresolved_labels', 'rankings', 'ranking_snapshots']; for (const t of tables) { const [r] = await db.execute<{ n: string }>(sql.raw(`SELECT count(*)::text AS n FROM ${t}`)); console.log(`${t.padEnd(28)} ${r?.n}`); } break; } case 'trace': { const { traceValue } = await import('@cancerindex/ranking'); console.log(JSON.stringify(await traceValue(db, positional[0] ?? '', positional[1] ?? ''), null, 2)); break; } case 'doctor': { const { runDoctor, formatDoctorReport } = await import('@cancerindex/connectors'); const report = await runDoctor(db, { skipDisk: !!flags['no-disk'] }); console.log(flags.json ? JSON.stringify(report, null, 2) : formatDoctorReport(report)); if (report.hardFailures > 0) process.exitCode = 1; break; } case 'alerts': { const { listAlerts } = await import('@cancerindex/database'); const { formatAlerts } = await import('@cancerindex/connectors'); const sub = positional[0]; if ((sub === 'ack' || sub === 'resolve') && positional[1]) { const status = sub === 'ack' ? 'acknowledged' : 'resolved'; const rows = await db.execute<{ id: number }>(sql`UPDATE system_alerts SET status = ${status}, resolved_at = CASE WHEN ${status} = 'resolved' THEN now() ELSE resolved_at END WHERE id = ${Number(positional[1])} AND status <> 'resolved' RETURNING id`); console.log(rows.length ? `[alerts] #${positional[1]} → ${status}` : `[alerts] #${positional[1]} not found or already resolved`); break; } const status = (flags.status as string | undefined) ?? 'active'; const alerts = await listAlerts(db, { status: status as never, connectorId: flags.connector as string | undefined, limit: flags.limit ? Number(flags.limit) : 200 }); console.log(flags.json ? JSON.stringify(alerts, null, 2) : formatAlerts(alerts)); break; } default: console.log('usage: pnpm cix '); } } finally { await closeDb(); } } main().catch((e) => { console.error(e); process.exit(1); });