spb/cancerindex
Public
TypeScript 97.2%
SQL 1.5%
CSS 0.6%
JavaScript 0.5%
1#!/usr/bin/env tsx2/**3 * CancerIndex operator CLI.4 *5 * pnpm cix connectors list connectors + health6 * pnpm cix run <id> [--mode full|incremental|dry_run] [--max-records N] [--max-minutes M] [--reset-cursor]7 * pnpm cix run-all [--max-minutes M] run every active connector in registry order8 * pnpm cix health <id> source liveness probe9 * pnpm cix sources:sync manifests → sources table10 * pnpm cix counters recompute entity counters11 * pnpm cix rank [--scope …] recompute ranking snapshots12 * pnpm cix stats table counts13 * pnpm cix trace <table> <id> lineage of a value (rankings/epidemiology/frequency)14 * pnpm cix doctor [--json] [--no-disk] readiness report (env, DB, migrations, connectors, alerts…); exit 1 on hard failure15 * pnpm cix alerts [--status open|acknowledged|resolved|active] [--connector id] [--json]16 * pnpm cix alerts ack|resolve <id> acknowledge / resolve one alert17 */18import { existsSync } from 'node:fs';19import path from 'node:path';20import { sql } from 'drizzle-orm';2122for (const candidate of [path.resolve(process.cwd(), '.env')]) {23 if (existsSync(candidate)) {24 try {25 process.loadEnvFile(candidate);26 } catch {27 /* ignore */28 }29 }30}3132const [, , cmd, ...rest] = process.argv;33const flags: Record<string, string | boolean> = {};34const positional: string[] = [];35for (let i = 0; i < rest.length; i++) {36 const a = rest[i]!;37 if (a.startsWith('--')) {38 const k = a.slice(2);39 const next = rest[i + 1];40 if (next && !next.startsWith('--')) {41 flags[k] = next;42 i++;43 } else flags[k] = true;44 } else positional.push(a);45}4647async function main() {48 const { getDb, closeDb } = await import('@cancerindex/database');49 const db = getDb();50 try {51 switch (cmd) {52 case 'connectors': {53 const { CONNECTORS } = await import('@cancerindex/connectors');54 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`);55 const byId = new Map(rows.map((r) => [r.connector_id, r]));56 for (const c of CONNECTORS) {57 const h = byId.get(c.manifest.id);58 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 ?? '-'}`);59 }60 break;61 }62 case 'sources:sync': {63 const { syncSources, PLANNED_MANIFESTS } = await import('@cancerindex/connectors');64 const r = await syncSources(db, PLANNED_MANIFESTS);65 console.log(`[sources] created=${r.created} updated=${r.updated}`);66 break;67 }68 case 'health': {69 const { getConnector, RunContext } = await import('@cancerindex/connectors');70 const c = getConnector(positional[0] ?? '');71 if (!c) throw new Error(`unknown connector ${positional[0]}`);72 const [src] = await db.execute<{ id: string }>(sql`SELECT id FROM sources WHERE slug = ${c.manifest.id}`);73 const ctx = new RunContext(db, c.manifest, src?.id ?? 'CI-SOURCE-00000000', 'probe', { maxMinutes: 1 }, 0);74 console.log(await c.healthCheck(ctx));75 break;76 }77 case 'run': {78 const { getConnector, runConnector } = await import('@cancerindex/connectors');79 const c = getConnector(positional[0] ?? '');80 if (!c) throw new Error(`unknown connector ${positional[0]}`);81 const r = await runConnector(db, c, {82 mode: (flags.mode as never) ?? undefined,83 maxRecords: flags['max-records'] ? Number(flags['max-records']) : undefined,84 maxMinutes: flags['max-minutes'] ? Number(flags['max-minutes']) : undefined,85 resetCursor: !!flags['reset-cursor'],86 });87 console.log(`[run] ${r.runId} ${r.status}`, r.counters);88 if (r.status === 'failed') process.exitCode = 1;89 break;90 }91 case 'run-all': {92 const { CONNECTORS, runConnector } = await import('@cancerindex/connectors');93 for (const c of CONNECTORS) {94 if (c.manifest.status !== 'active') {95 console.log(`[skip] ${c.manifest.id} (${c.manifest.status})`);96 continue;97 }98 const r = await runConnector(db, c, { maxMinutes: flags['max-minutes'] ? Number(flags['max-minutes']) : undefined });99 console.log(`[run] ${r.runId} ${r.status}`, r.counters);100 }101 break;102 }103 case 'counters': {104 const { refreshCounters } = await import('@cancerindex/ranking');105 const n = await refreshCounters(db);106 console.log(`[counters] refreshed ${n} entities`);107 break;108 }109 case 'intel': {110 const { computeIntelligence } = await import('@cancerindex/ranking');111 const r = await computeIntelligence(db, (m, extra) => console.log(`[intel] ${m}`, extra ?? ''));112 console.log(`[intel] trial_intelligence=${r.trialIntelligenceRows} site_countries=${r.trialSiteCountryRows} drug_pipeline=${r.drugPipelineRows} research_gap=${r.researchGapRows} (${r.ms} ms)`);113 break;114 }115 case 'reconcile-drugs': {116 const { reconcileInterventionDrugs } = await import('@cancerindex/connectors');117 const r = await reconcileInterventionDrugs(db, { remap: !!flags.remap });118 console.log('[reconcile-drugs]', r);119 break;120 }121 case 'rank': {122 const { computeAllRankings } = await import('@cancerindex/ranking');123 const res = await computeAllRankings(db);124 for (const r of res) console.log(`[rank] ${r.metricSlug} ${r.scopeKey} eligible=${r.eligible}`);125 break;126 }127 case 'stats': {128 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'];129 for (const t of tables) {130 const [r] = await db.execute<{ n: string }>(sql.raw(`SELECT count(*)::text AS n FROM ${t}`));131 console.log(`${t.padEnd(28)} ${r?.n}`);132 }133 break;134 }135 case 'trace': {136 const { traceValue } = await import('@cancerindex/ranking');137 console.log(JSON.stringify(await traceValue(db, positional[0] ?? '', positional[1] ?? ''), null, 2));138 break;139 }140 case 'doctor': {141 const { runDoctor, formatDoctorReport } = await import('@cancerindex/connectors');142 const report = await runDoctor(db, { skipDisk: !!flags['no-disk'] });143 console.log(flags.json ? JSON.stringify(report, null, 2) : formatDoctorReport(report));144 if (report.hardFailures > 0) process.exitCode = 1;145 break;146 }147 case 'alerts': {148 const { listAlerts } = await import('@cancerindex/database');149 const { formatAlerts } = await import('@cancerindex/connectors');150 const sub = positional[0];151 if ((sub === 'ack' || sub === 'resolve') && positional[1]) {152 const status = sub === 'ack' ? 'acknowledged' : 'resolved';153 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`);154 console.log(rows.length ? `[alerts] #${positional[1]} → ${status}` : `[alerts] #${positional[1]} not found or already resolved`);155 break;156 }157 const status = (flags.status as string | undefined) ?? 'active';158 const alerts = await listAlerts(db, { status: status as never, connectorId: flags.connector as string | undefined, limit: flags.limit ? Number(flags.limit) : 200 });159 console.log(flags.json ? JSON.stringify(alerts, null, 2) : formatAlerts(alerts));160 break;161 }162 default:163 console.log('usage: pnpm cix <connectors|run|run-all|health|sources:sync|counters|intel|reconcile-drugs [--remap]|rank|stats|trace|doctor|alerts>');164 }165 } finally {166 await closeDb();167 }168}169170main().catch((e) => {171 console.error(e);172 process.exit(1);173});174