latinfo 0.9.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/dist/index.js +521 -124
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -47,7 +47,7 @@ const local_search_1 = require("./local-search");
47
47
  const client_search_1 = require("./client-search");
48
48
  const odis_search_1 = require("./odis-search");
49
49
  const mphf_search_1 = require("./mphf-search");
50
- const VERSION = '0.8.1';
50
+ const VERSION = '0.10.0';
51
51
  const API_URL = process.env.LATINFO_API_URL || 'https://api.latinfo.dev';
52
52
  const GITHUB_CLIENT_ID = process.env.GITHUB_CLIENT_ID || 'Ov23li5fcQaiCsVtaMKK';
53
53
  const CONFIG_DIR = path_1.default.join(os_1.default.homedir(), '.latinfo');
@@ -339,7 +339,9 @@ async function search(query) {
339
339
  const odisMode = allArgs.includes('--odis');
340
340
  const mphfMode = allArgs.includes('--mphf');
341
341
  // MPHF: fully autonomous client-side search (zero server for search)
342
- if (mphfMode || (!clientMode && !odisMode && (0, mphf_search_1.hasMphfData)(country))) {
342
+ // Skip auto-detection in demo mode (no config) so demo data path is used
343
+ const config = loadConfig();
344
+ if (mphfMode || (config && !clientMode && !odisMode && (0, mphf_search_1.hasMphfData)(country))) {
343
345
  const stripped = query.split(' ').filter((w, i, a) => !['--mphf', '--client', '--odis', '--json'].includes(w) &&
344
346
  !(i > 0 && ['--country'].includes(a[i - 1])) &&
345
347
  !['--country'].includes(w)).join(' ');
@@ -416,7 +418,8 @@ async function search(query) {
416
418
  return;
417
419
  }
418
420
  // Local search: if data exists in ~/.latinfo/data/, search offline
419
- if ((0, local_search_1.hasLocalData)(country)) {
421
+ // Skip in demo mode (no config) so demo data path is used
422
+ if (config && (0, local_search_1.hasLocalData)(country)) {
420
423
  const results = (0, local_search_1.localSearch)(country, query);
421
424
  if (jsonFlag) {
422
425
  console.log(JSON.stringify(results));
@@ -436,7 +439,6 @@ async function search(query) {
436
439
  return;
437
440
  }
438
441
  // Fallback: API search
439
- const config = loadConfig();
440
442
  if (!config) {
441
443
  const results = (0, demo_data_1.searchDemo)(query);
442
444
  if (jsonFlag) {
@@ -1221,108 +1223,65 @@ function help() {
1221
1223
  console.log(`latinfo v${VERSION} — Tax registry API for Latin America
1222
1224
 
1223
1225
  USAGE
1224
- latinfo <command> [args] [--json]
1226
+ latinfo <country> <institution> <dataset> <id|--search query|--dni id> [--json]
1227
+ latinfo <admin-command> [args]
1225
1228
 
1226
1229
  QUICK START
1227
1230
  npm install -g latinfo
1228
- latinfo login # GitHub OAuth, 30 seconds
1229
- latinfo ruc 20100047218 # Banco de Crédito del Perú
1230
- latinfo search "banco de credito" # search by company name
1231
- latinfo ruc 20100047218 --json # JSON output
1232
-
1233
- Works instantly with ${Object.keys(demo_data_1.DEMO_DATA).length} embedded records (no login needed).
1234
- Run 'latinfo login' for 18M+ records and DNI lookup.
1235
-
1236
- COMMANDS
1237
- login
1238
- GitHub OAuth. Opens browser, stores API key in ~/.latinfo/config.json.
1239
-
1240
- login --token <github_pat>
1241
- Login with a GitHub Personal Access Token. No browser needed.
1242
- Create a PAT at github.com/settings/tokens (scope: read:user).
1243
-
1244
- logout
1245
- Remove stored credentials.
1246
-
1247
- whoami
1248
- Show authenticated GitHub username.
1249
- --json: { username, api_key }
1250
-
1251
- ruc <ruc>
1252
- Lookup by RUC (11 digits).
1253
- --json fields: ruc, razon_social, estado, condicion, ubigeo,
1254
- tipo_via, nombre_via, numero, interior, lote, codigo_zona,
1255
- tipo_zona, departamento, manzana, kilometro
1256
-
1257
- dni <dni>
1258
- Lookup by DNI (8 digits). Converts to RUC automatically.
1259
- --json fields: same as ruc
1260
-
1261
- search <query>
1262
- Search by company name (razón social). Returns ranked results
1263
- with prefix autocomplete and abbreviation handling (S.A.C., E.I.R.L.).
1264
- --json fields: array of ruc objects
1265
-
1266
- costs <users> [avg_req/user/month] [pro_%]
1267
- Simulate Cloudflare cost vs revenue.
1268
- Defaults: 1000 req/user, 1% Pro.
1269
- --json fields: users, pro_users, requests, cf_tier, cf_cost,
1270
- revenue, margin, safe
1231
+ latinfo login
1232
+ latinfo pe sunat padron 20100047218
1233
+ latinfo pe sunat padron --search "banco de credito"
1234
+ latinfo pe sunat padron --dni 09346247
1271
1235
 
1272
- costs --live
1273
- Real-time cost report from production (admin only).
1274
- Requires LATINFO_ADMIN_SECRET env var.
1236
+ DATA SOURCES
1237
+ Peru SUNAT
1238
+ latinfo pe sunat padron <ruc> Padrón RUC (18M records)
1239
+ latinfo pe sunat padron --dni <dni> Lookup by DNI
1240
+ latinfo pe sunat padron --search <query> Search by name
1241
+ latinfo pe sunat coactiva <ruc> Tax debt (cobranza coactiva)
1242
+ latinfo pe sunat coactiva --search <query>
1275
1243
 
1276
- debtors <ruc|name> [--source <source>]
1277
- Search Peru debtors across SUNAT Coactiva, OSCE Inhabilitados, OSCE Multas.
1278
- --source: filter by sunat-coactiva, osce-inhabilitado, osce-multa
1279
- --json fields: ruc, name, source, detail, amount, date_start, date_end, resolution
1244
+ Peru OSCE
1245
+ latinfo pe osce sanctioned <ruc> Sanctioned providers
1246
+ latinfo pe osce sanctioned --search <query>
1247
+ latinfo pe osce fines <ruc> Provider fines
1248
+ latinfo pe osce fines --search <query>
1280
1249
 
1281
- licitaciones <query> [flags]
1282
- Search Peru government procurement (OECE/SEACE).
1283
- Flags: --category, --min-amount, --max-amount, --buyer, --method, --status, --limit
1284
- Run 'latinfo licitaciones help' for details.
1250
+ Peru OECE
1251
+ latinfo pe oece tenders <query> [flags] Government procurement
1252
+ Flags: --category, --min-amount, --max-amount, --buyer, --status, --limit
1285
1253
 
1286
- easypipe <command>
1287
- Generic import pipeline driven by YAML configs.
1288
- Commands: list, build <source>, sync, add <url>
1289
- Run 'latinfo easypipe help' for details.
1254
+ Colombia — RUES
1255
+ latinfo co rues registry <nit> Business registry (3.3M records)
1256
+ latinfo co rues registry --search <query>
1290
1257
 
1291
- bench [--country pe|co] [--type ruc|nit|search|licitaciones] [--count N] [--concurrency N]
1292
- Stress test the API.
1293
-
1294
- completion [bash|zsh]
1295
- Output shell completion script.
1296
- bash: eval "$(latinfo completion bash)"
1297
- zsh: eval "$(latinfo completion zsh)"
1298
-
1299
- help Show this help text.
1258
+ ADMIN
1259
+ login [--token <github_pat>] GitHub OAuth or PAT login
1260
+ logout Remove credentials
1261
+ whoami Show authenticated user
1262
+ imports Show import status
1263
+ imports run <source> Trigger import
1264
+ imports report [days] Import diagnostics
1265
+ costs <users> [avg_req] [pro_%] Cost simulation
1266
+ costs --live Production cost report
1267
+ bench [flags] Stress test API
1268
+ easypipe <command> Generic import pipeline
1269
+ completion [bash|zsh] Shell completions
1270
+ help This help text
1300
1271
 
1301
1272
  FLAGS
1302
- --json Output raw JSON. Errors → stderr as { error, message }.
1303
- --live Use production data (costs command).
1304
- --version Print version and exit.
1305
-
1306
- COUNTRIES
1307
- pe Peru (SUNAT padrón) — 18M+ records, updated daily. Active.
1308
- br, mx, co, ar, cl — in development.
1273
+ --json Raw JSON output
1274
+ --search Search by name instead of ID lookup
1275
+ --dni Lookup by DNI (Peru only)
1276
+ --version Print version
1309
1277
 
1310
1278
  PRICING
1311
- Free 100,000 requests/day — no credit card
1312
- Pro 10M requests/month — $1/month
1313
-
1314
- LINKS
1315
- latinfo.dev/docs API reference
1316
- latinfo.dev/changelog Changelog
1317
- carrera.instatus.com Status page
1279
+ Free 100,000 requests/day
1280
+ Pro 10M requests/month — $1/month
1318
1281
 
1319
1282
  CONFIG
1320
- ~/.latinfo/config.json API key (written by 'latinfo login')
1321
- LATINFO_API_URL Override API base URL
1322
-
1323
- EXIT CODES
1324
- 0 Success
1325
- 1 Error`);
1283
+ ~/.latinfo/config.json API key
1284
+ LATINFO_API_URL Override API URL`);
1326
1285
  }
1327
1286
  function printLogo() {
1328
1287
  if (!process.stdout.isTTY)
@@ -1379,15 +1338,31 @@ function completion() {
1379
1338
  if (shell === 'zsh') {
1380
1339
  console.log(`#compdef latinfo
1381
1340
  _latinfo() {
1382
- local -a commands=(login logout whoami plan costs ruc dni search debtors licitaciones lic help)
1383
- local -a lic_flags=(--category --min-amount --max-amount --buyer --method --status --limit --json)
1384
- local -a global_flags=(--json --live --version --token)
1341
+ local -a countries=(pe co)
1342
+ local -a admin=(login logout whoami imports plan costs bench easypipe completion help)
1343
+ local -a pe_inst=(sunat osce oece)
1344
+ local -a co_inst=(rues)
1345
+ local -a sunat_ds=(padron coactiva)
1346
+ local -a osce_ds=(sanctioned fines)
1347
+ local -a oece_ds=(tenders)
1348
+ local -a rues_ds=(registry)
1349
+ local -a flags=(--json --search --dni --version)
1385
1350
  if (( CURRENT == 2 )); then
1386
- _describe 'command' commands
1387
- elif [[ "\${words[2]}" == "licitaciones" || "\${words[2]}" == "lic" ]]; then
1388
- _describe 'flag' lic_flags
1351
+ _describe 'command' countries -- admin
1352
+ elif (( CURRENT == 3 )); then
1353
+ case "\${words[2]}" in
1354
+ pe) _describe 'institution' pe_inst;;
1355
+ co) _describe 'institution' co_inst;;
1356
+ esac
1357
+ elif (( CURRENT == 4 )); then
1358
+ case "\${words[3]}" in
1359
+ sunat) _describe 'dataset' sunat_ds;;
1360
+ osce) _describe 'dataset' osce_ds;;
1361
+ oece) _describe 'dataset' oece_ds;;
1362
+ rues) _describe 'dataset' rues_ds;;
1363
+ esac
1389
1364
  else
1390
- _describe 'flag' global_flags
1365
+ _describe 'flag' flags
1391
1366
  fi
1392
1367
  }
1393
1368
  compdef _latinfo latinfo`);
@@ -1395,26 +1370,444 @@ compdef _latinfo latinfo`);
1395
1370
  else {
1396
1371
  console.log(`_latinfo_completions() {
1397
1372
  local cur="\${COMP_WORDS[COMP_CWORD]}"
1398
- local prev="\${COMP_WORDS[1]}"
1399
- if [[ \${COMP_CWORD} -eq 1 ]]; then
1400
- COMPREPLY=( $(compgen -W "login logout whoami plan costs ruc dni search debtors licitaciones lic help" -- "$cur") )
1401
- elif [[ "$prev" == "licitaciones" || "$prev" == "lic" ]]; then
1402
- COMPREPLY=( $(compgen -W "--category --min-amount --max-amount --buyer --method --status --limit --json info help" -- "$cur") )
1403
- elif [[ "$prev" == "--category" || "$prev" == "-c" ]]; then
1404
- COMPREPLY=( $(compgen -W "goods services works" -- "$cur") )
1405
- elif [[ "$prev" == "--status" || "$prev" == "-s" ]]; then
1406
- COMPREPLY=( $(compgen -W "CONVOCADO CONTRATADO DESIERTO NULO CONSENTIDO" -- "$cur") )
1373
+ local lvl=\${COMP_CWORD}
1374
+ local w1="\${COMP_WORDS[1]}" w2="\${COMP_WORDS[2]}" w3="\${COMP_WORDS[3]}"
1375
+ if [[ \$lvl -eq 1 ]]; then
1376
+ COMPREPLY=( $(compgen -W "pe co login logout whoami imports plan costs bench easypipe completion help" -- "$cur") )
1377
+ elif [[ \$lvl -eq 2 ]]; then
1378
+ case "$w1" in
1379
+ pe) COMPREPLY=( $(compgen -W "sunat osce oece" -- "$cur") );;
1380
+ co) COMPREPLY=( $(compgen -W "rues" -- "$cur") );;
1381
+ esac
1382
+ elif [[ \$lvl -eq 3 ]]; then
1383
+ case "$w2" in
1384
+ sunat) COMPREPLY=( $(compgen -W "padron coactiva" -- "$cur") );;
1385
+ osce) COMPREPLY=( $(compgen -W "sanctioned fines" -- "$cur") );;
1386
+ oece) COMPREPLY=( $(compgen -W "tenders" -- "$cur") );;
1387
+ rues) COMPREPLY=( $(compgen -W "registry" -- "$cur") );;
1388
+ esac
1389
+ else
1390
+ COMPREPLY=( $(compgen -W "--json --search --dni" -- "$cur") )
1407
1391
  fi
1408
1392
  }
1409
1393
  complete -F _latinfo_completions latinfo`);
1410
1394
  }
1411
1395
  }
1396
+ // --- Generic source query ---
1397
+ async function sourceQuery(routePath, datasetArgs) {
1398
+ const searchFlag = datasetArgs.includes('--search');
1399
+ const dniFlag = datasetArgs.includes('--dni');
1400
+ if (searchFlag) {
1401
+ const query = datasetArgs.filter(a => a !== '--search').join(' ');
1402
+ if (!query) {
1403
+ if (jsonFlag)
1404
+ jsonError('invalid_input', 'Search query is required.');
1405
+ console.error('Search query is required.');
1406
+ process.exit(1);
1407
+ }
1408
+ const config = requireAuth();
1409
+ const res = await apiRequest(config, `${routePath}/search?q=${encodeURIComponent(query)}`);
1410
+ const data = await res.json();
1411
+ if (jsonFlag) {
1412
+ console.log(JSON.stringify(data));
1413
+ return;
1414
+ }
1415
+ const results = Array.isArray(data) ? data : [];
1416
+ if (results.length === 0) {
1417
+ console.log('No results found.');
1418
+ return;
1419
+ }
1420
+ for (const r of results) {
1421
+ const fields = Object.values(r);
1422
+ console.log(` ${fields.slice(0, 3).join(' ')}`);
1423
+ }
1424
+ console.log(`\n${results.length} result(s)`);
1425
+ return;
1426
+ }
1427
+ if (dniFlag) {
1428
+ const dniVal = datasetArgs.find(a => a !== '--dni' && !a.startsWith('--'));
1429
+ if (!dniVal || !/^\d{8}$/.test(dniVal)) {
1430
+ if (jsonFlag)
1431
+ jsonError('invalid_input', 'Invalid DNI. Must be 8 digits.');
1432
+ console.error('Invalid DNI. Must be 8 digits.');
1433
+ process.exit(1);
1434
+ }
1435
+ const config = requireAuth();
1436
+ const res = await apiRequest(config, `${routePath}/dni/${dniVal}`);
1437
+ const data = await res.json();
1438
+ if (jsonFlag) {
1439
+ console.log(JSON.stringify(data));
1440
+ return;
1441
+ }
1442
+ for (const [k, v] of Object.entries(data)) {
1443
+ if (v && v !== '-')
1444
+ console.log(` ${k}: ${v}`);
1445
+ }
1446
+ return;
1447
+ }
1448
+ // Direct ID lookup
1449
+ const id = datasetArgs.find(a => !a.startsWith('--'));
1450
+ if (!id) {
1451
+ if (jsonFlag)
1452
+ jsonError('invalid_input', 'ID or --search <query> required.');
1453
+ console.error('ID or --search <query> required.');
1454
+ process.exit(1);
1455
+ }
1456
+ const config = loadConfig();
1457
+ // Demo mode for pe/sunat/padron
1458
+ if (!config && routePath === '/pe/sunat/padron' && /^\d{11}$/.test(id)) {
1459
+ const demo = demo_data_1.DEMO_DATA[id];
1460
+ if (demo) {
1461
+ if (jsonFlag) {
1462
+ console.log(JSON.stringify({ ...demo, _demo: true }));
1463
+ }
1464
+ else {
1465
+ for (const [k, v] of Object.entries(demo)) {
1466
+ if (v && v !== '-')
1467
+ console.log(` ${k}: ${v}`);
1468
+ }
1469
+ }
1470
+ process.stderr.write(`Demo data (${Object.keys(demo_data_1.DEMO_DATA).length} records). Run 'latinfo login' for full access.\n`);
1471
+ return;
1472
+ }
1473
+ if (jsonFlag)
1474
+ jsonError('not_found', "Not in demo data. Run 'latinfo login' for full access.");
1475
+ console.error("Not in demo data. Run 'latinfo login' for full access.");
1476
+ process.exit(1);
1477
+ }
1478
+ if (!config) {
1479
+ if (jsonFlag)
1480
+ jsonError('auth_required', "Lookup requires login. Run 'latinfo login'");
1481
+ console.error("Lookup requires login. Run 'latinfo login'");
1482
+ process.exit(1);
1483
+ }
1484
+ // Detect primary ID name from route path
1485
+ const idNames = {
1486
+ '/pe/sunat/padron': 'ruc', '/pe/sunat/coactiva': 'ruc',
1487
+ '/pe/osce/sanctioned': 'ruc', '/pe/osce/fines': 'ruc',
1488
+ '/co/rues/registry': 'nit',
1489
+ };
1490
+ const idName = idNames[routePath] || 'id';
1491
+ const res = await apiRequest(config, `${routePath}/${idName}/${id}`);
1492
+ const data = await res.json();
1493
+ if (jsonFlag) {
1494
+ console.log(JSON.stringify(data));
1495
+ return;
1496
+ }
1497
+ for (const [k, v] of Object.entries(data)) {
1498
+ if (v && v !== '-')
1499
+ console.log(` ${k}: ${v}`);
1500
+ }
1501
+ }
1502
+ // --- Admin: source management ---
1503
+ function getRepoPath() {
1504
+ const envPath = process.env.LATINFO_REPO_PATH;
1505
+ if (envPath)
1506
+ return envPath;
1507
+ // Try to detect from cwd
1508
+ const candidates = [
1509
+ process.cwd(),
1510
+ path_1.default.join(os_1.default.homedir(), 'Documents/Github/carrerahaus/latinfo-api'),
1511
+ path_1.default.join(os_1.default.homedir(), 'latinfo-api'),
1512
+ ];
1513
+ for (const p of candidates) {
1514
+ if (fs_1.default.existsSync(path_1.default.join(p, 'sources')) && fs_1.default.existsSync(path_1.default.join(p, 'src/imports')))
1515
+ return p;
1516
+ }
1517
+ console.error('Cannot find latinfo-api repo. Set LATINFO_REPO_PATH or run from repo dir.');
1518
+ process.exit(1);
1519
+ }
1520
+ function requireAdmin() {
1521
+ // 1. Env var
1522
+ if (process.env.LATINFO_ADMIN_SECRET)
1523
+ return process.env.LATINFO_ADMIN_SECRET;
1524
+ // 2. ~/.latinfo/admin.secret
1525
+ const secretFile = path_1.default.join(CONFIG_DIR, 'admin.secret');
1526
+ if (fs_1.default.existsSync(secretFile))
1527
+ return fs_1.default.readFileSync(secretFile, 'utf-8').trim();
1528
+ // 3. .dev.vars in repo
1529
+ try {
1530
+ const repo = getRepoPath();
1531
+ const devVars = path_1.default.join(repo, '.dev.vars');
1532
+ if (fs_1.default.existsSync(devVars)) {
1533
+ const match = fs_1.default.readFileSync(devVars, 'utf-8').match(/ADMIN_SECRET=(.+)/);
1534
+ if (match)
1535
+ return match[1].trim();
1536
+ }
1537
+ }
1538
+ catch { }
1539
+ console.error('Admin access not found. Create ~/.latinfo/admin.secret or set LATINFO_ADMIN_SECRET.');
1540
+ process.exit(1);
1541
+ }
1542
+ async function adminCreate(args) {
1543
+ const [country, institution, dataset, ...flags] = args;
1544
+ if (!country || !institution || !dataset) {
1545
+ console.error('Usage: latinfo admin create <country> <institution> <dataset> [--url URL] [--id-name ruc] [--id-length 11] [--encoding utf-8] [--delimiter ","]');
1546
+ process.exit(1);
1547
+ }
1548
+ const name = `${country}-${institution}-${dataset}`;
1549
+ const repo = getRepoPath();
1550
+ const yamlPath = path_1.default.join(repo, 'sources', `${name}.yaml`);
1551
+ if (fs_1.default.existsSync(yamlPath)) {
1552
+ console.error(`Source ${name} already exists: ${yamlPath}`);
1553
+ process.exit(1);
1554
+ }
1555
+ const getFlag = (flag) => {
1556
+ const idx = flags.indexOf(flag);
1557
+ return idx !== -1 ? flags[idx + 1] : undefined;
1558
+ };
1559
+ const url = getFlag('--url') || 'https://example.com/data.csv';
1560
+ const idName = getFlag('--id-name') || 'id';
1561
+ const idLength = getFlag('--id-length') || '11';
1562
+ const encoding = getFlag('--encoding') || 'utf-8';
1563
+ const delimiter = getFlag('--delimiter') || ',';
1564
+ const format = getFlag('--format') || 'csv';
1565
+ const yaml = `name: ${name}
1566
+ country: ${country}
1567
+ institution: ${institution}
1568
+ dataset: ${dataset}
1569
+ source: ${institution}-${dataset}
1570
+
1571
+ url: ${url}
1572
+ format: ${format}
1573
+ encoding: ${encoding}
1574
+ delimiter: "${delimiter}"
1575
+ skip_header: true
1576
+
1577
+ primary_id:
1578
+ name: ${idName}
1579
+ column: 0
1580
+ length: ${idLength}
1581
+ regex: "^\\\\d{${idLength}}$"
1582
+ prefix_length: 5
1583
+
1584
+ alternate_ids: []
1585
+
1586
+ fields:
1587
+ - name: name
1588
+ column: 1
1589
+ search: true
1590
+ - name: status
1591
+ column: 2
1592
+
1593
+ import: custom
1594
+ import_script: src/imports/${name}.ts
1595
+
1596
+ schedule: manual
1597
+ change_detection: none
1598
+ min_rows: 100
1599
+
1600
+ smoke_test:
1601
+ id: ""
1602
+ expect_field: name
1603
+ `;
1604
+ fs_1.default.writeFileSync(yamlPath, yaml);
1605
+ console.log(`Created: ${yamlPath}`);
1606
+ console.log(`\nNext steps:`);
1607
+ console.log(` 1. Edit ${yamlPath} to match your data source`);
1608
+ console.log(` 2. Write import script: latinfo admin upload-script ${name} ./my-import.ts`);
1609
+ console.log(` 3. Test: latinfo admin test ${name}`);
1610
+ console.log(` 4. Publish: latinfo admin publish ${name}`);
1611
+ }
1612
+ async function adminUploadScript(args) {
1613
+ const [sourceName, scriptPath] = args;
1614
+ if (!sourceName || !scriptPath) {
1615
+ console.error('Usage: latinfo admin upload-script <source-name> <script-path>');
1616
+ process.exit(1);
1617
+ }
1618
+ const repo = getRepoPath();
1619
+ const dest = path_1.default.join(repo, 'src', 'imports', `${sourceName}.ts`);
1620
+ const src = path_1.default.resolve(scriptPath);
1621
+ if (!fs_1.default.existsSync(src)) {
1622
+ console.error(`Script not found: ${src}`);
1623
+ process.exit(1);
1624
+ }
1625
+ fs_1.default.copyFileSync(src, dest);
1626
+ console.log(`Copied: ${src} → ${dest}`);
1627
+ }
1628
+ async function adminTest(args) {
1629
+ const [sourceName] = args;
1630
+ if (!sourceName) {
1631
+ console.error('Usage: latinfo admin test <source-name>');
1632
+ process.exit(1);
1633
+ }
1634
+ const repo = getRepoPath();
1635
+ const yamlPath = path_1.default.join(repo, 'sources', `${sourceName}.yaml`);
1636
+ if (!fs_1.default.existsSync(yamlPath)) {
1637
+ console.error(`Source not found: ${yamlPath}`);
1638
+ process.exit(1);
1639
+ }
1640
+ // Check if import script exists
1641
+ const scriptPath = path_1.default.join(repo, 'src', 'imports', `${sourceName}.ts`);
1642
+ const easypipePath = path_1.default.join(repo, 'src', 'imports', 'easypipe.ts');
1643
+ const useEasypipe = !fs_1.default.existsSync(scriptPath);
1644
+ const cmd = useEasypipe
1645
+ ? `npx tsx ${easypipePath} ${yamlPath} --limit 100 --local`
1646
+ : `npx tsx ${scriptPath} --limit 100`;
1647
+ console.log(`Testing ${sourceName}...`);
1648
+ console.log(`Running: ${cmd}\n`);
1649
+ try {
1650
+ const { execSync: run } = await Promise.resolve().then(() => __importStar(require('child_process')));
1651
+ run(cmd, { stdio: 'inherit', cwd: repo });
1652
+ console.log(`\n[test] ${sourceName}: PASSED`);
1653
+ }
1654
+ catch {
1655
+ console.error(`\n[test] ${sourceName}: FAILED`);
1656
+ process.exit(1);
1657
+ }
1658
+ }
1659
+ async function adminPublish(args) {
1660
+ const [sourceName] = args;
1661
+ if (!sourceName) {
1662
+ console.error('Usage: latinfo admin publish <source-name>');
1663
+ process.exit(1);
1664
+ }
1665
+ const repo = getRepoPath();
1666
+ const yamlPath = path_1.default.join(repo, 'sources', `${sourceName}.yaml`);
1667
+ if (!fs_1.default.existsSync(yamlPath)) {
1668
+ console.error(`Source not found: ${yamlPath}`);
1669
+ process.exit(1);
1670
+ }
1671
+ const { execSync: run } = await Promise.resolve().then(() => __importStar(require('child_process')));
1672
+ // 1. Add source config to sources.ts
1673
+ console.log(`[publish] Adding ${sourceName} to source registry...`);
1674
+ // TODO: auto-generate sources.ts from YAMLs
1675
+ // 2. Git add + commit + push
1676
+ console.log(`[publish] Committing...`);
1677
+ const files = [`sources/${sourceName}.yaml`];
1678
+ const scriptPath = path_1.default.join(repo, 'src', 'imports', `${sourceName}.ts`);
1679
+ if (fs_1.default.existsSync(scriptPath))
1680
+ files.push(`src/imports/${sourceName}.ts`);
1681
+ try {
1682
+ run(`git add ${files.join(' ')}`, { cwd: repo, stdio: 'pipe' });
1683
+ run(`git commit -m "Add data source: ${sourceName}"`, { cwd: repo, stdio: 'pipe' });
1684
+ run(`git push`, { cwd: repo, stdio: 'pipe' });
1685
+ console.log(`[publish] Pushed to remote.`);
1686
+ }
1687
+ catch (e) {
1688
+ console.error(`[publish] Git error: ${e.message}`);
1689
+ process.exit(1);
1690
+ }
1691
+ // 3. Trigger import
1692
+ console.log(`[publish] Triggering import...`);
1693
+ try {
1694
+ run(`gh workflow run import.yml -f source=${sourceName}`, { cwd: repo, stdio: 'inherit' });
1695
+ console.log(`[publish] Import triggered. Check: gh run list --workflow=import.yml`);
1696
+ }
1697
+ catch {
1698
+ console.log(`[publish] Could not trigger workflow. Run manually: latinfo imports run ${sourceName}`);
1699
+ }
1700
+ console.log(`\n[publish] ${sourceName}: PUBLISHED`);
1701
+ }
1702
+ async function admin(args) {
1703
+ requireAdmin();
1704
+ const [subcommand, ...subArgs] = args;
1705
+ switch (subcommand) {
1706
+ case 'create':
1707
+ await adminCreate(subArgs);
1708
+ break;
1709
+ case 'upload-script':
1710
+ await adminUploadScript(subArgs);
1711
+ break;
1712
+ case 'test':
1713
+ await adminTest(subArgs);
1714
+ break;
1715
+ case 'publish':
1716
+ await adminPublish(subArgs);
1717
+ break;
1718
+ default:
1719
+ console.log(`latinfo admin — data pipeline for public government records
1720
+
1721
+ DO NOT use SQLite, Supabase, Postgres, or any other database.
1722
+ latinfo handles storage, indexing, full-text search, and API serving automatically.
1723
+ Your only job is to download and parse the source data into a TSV file.
1724
+
1725
+ WORKFLOW
1726
+ Step 1: Create a source definition
1727
+ latinfo admin create <country> <institution> <dataset> [flags]
1728
+
1729
+ Example:
1730
+ latinfo admin create pe redam debtors \\
1731
+ --url "https://redam.pj.gob.pe/data.csv" \\
1732
+ --id-name dni --id-length 8 --encoding utf-8
1733
+
1734
+ This generates a YAML config in sources/{name}.yaml
1735
+ Edit the YAML to match your data: column indexes, field names, delimiter.
1736
+
1737
+ Step 2: Write and upload your import script
1738
+ latinfo admin upload-script <source-name> <script.ts>
1739
+
1740
+ Your script must:
1741
+ 1. Download the source data (HTTP, crawler, API, etc.)
1742
+ 2. Parse it into a TSV file: ID\\tfield1\\tfield2\\t...
1743
+ 3. Sort by ID: LC_ALL=C sort -t'\\t' -k1,1
1744
+ 4. Call buildBinaryFiles() and buildSearchIndex() from ./build-binary and ./build-search-index
1745
+ 5. Call uploadToR2() for each output file
1746
+ 6. Call saveImportMeta() at the end
1747
+
1748
+ See SOURCES.md in the repo for the full template and common errors.
1749
+ See src/imports/pe-osce-sanctioned.ts for a clean working example.
1750
+
1751
+ Step 3: Test locally
1752
+ latinfo admin test <source-name>
1753
+
1754
+ Runs your import with --limit 100 and validates the output.
1755
+ Must pass before publishing.
1756
+
1757
+ Step 4: Publish to production
1758
+ latinfo admin publish <source-name>
1759
+
1760
+ Commits your YAML + script, pushes to GitHub, triggers the import workflow.
1761
+ After import completes, the data is live at:
1762
+ API: https://api.latinfo.dev/{country}/{institution}/{dataset}/...
1763
+ CLI: latinfo {country} {institution} {dataset} <id|--search query>
1764
+
1765
+ FLAGS FOR CREATE
1766
+ --url <url> Source data download URL
1767
+ --id-name <name> Primary ID field name (default: id)
1768
+ --id-length <n> Primary ID length in digits (default: 11)
1769
+ --encoding <enc> Source file encoding: utf-8 | iso-8859-1 (default: utf-8)
1770
+ --delimiter <d> Field delimiter (default: ,)
1771
+ --format <fmt> Source format: csv | tsv | txt | xlsm (default: csv)
1772
+
1773
+ NAMING CONVENTION
1774
+ Source name: {country}-{institution}-{dataset}
1775
+ Country: ISO 3166-1 alpha-2 lowercase (pe, co, br, mx, ec, ar, cl)
1776
+ Institution: government agency abbreviation, lowercase
1777
+ Dataset: what the data contains, english, lowercase
1778
+
1779
+ Examples: pe-sunat-padron, pe-osce-sanctioned, co-rues-registry
1780
+
1781
+ ENVIRONMENT
1782
+ LATINFO_ADMIN_SECRET Auto-detected from ~/.latinfo/admin.secret or .dev.vars
1783
+ LATINFO_REPO_PATH Auto-detected from cwd or ~/Documents/Github/carrerahaus/latinfo-api`);
1784
+ }
1785
+ }
1412
1786
  // --- Main ---
1413
1787
  const [command, ...args] = rawArgs;
1788
+ const COUNTRIES = ['pe', 'co', 'br', 'mx', 'ar', 'cl', 'ec'];
1414
1789
  if (rawArgs.includes('--version') || rawArgs.includes('-v')) {
1415
1790
  version();
1416
1791
  }
1792
+ else if (COUNTRIES.includes(command)) {
1793
+ // New structure: latinfo <country> <institution> <dataset> [args]
1794
+ const [institution, dataset, ...datasetArgs] = args;
1795
+ if (!institution || !dataset) {
1796
+ console.error(`Usage: latinfo ${command} <institution> <dataset> <id|--search query|--dni id>`);
1797
+ console.error(`Example: latinfo ${command} sunat padron 20100047218`);
1798
+ process.exit(1);
1799
+ }
1800
+ // Special case: pe oece tenders → licitaciones (custom routes)
1801
+ if (command === 'pe' && institution === 'oece' && dataset === 'tenders') {
1802
+ licitaciones(datasetArgs).catch(e => { console.error(e); process.exit(1); });
1803
+ }
1804
+ else {
1805
+ const routePath = `/${command}/${institution}/${dataset}`;
1806
+ sourceQuery(routePath, datasetArgs).catch(e => { console.error(e); process.exit(1); });
1807
+ }
1808
+ }
1417
1809
  else {
1810
+ // Admin commands (flat)
1418
1811
  switch (command) {
1419
1812
  case 'login':
1420
1813
  login(tokenFlag).catch(e => { console.error(e); process.exit(1); });
@@ -1442,25 +1835,12 @@ else {
1442
1835
  case 'costs':
1443
1836
  (liveFlag ? costsLive() : Promise.resolve(costsSimulate(args[0], args[1], args[2]))).catch(e => { console.error(e); process.exit(1); });
1444
1837
  break;
1445
- case 'ruc':
1446
- ruc(args[0]).catch(e => { console.error(e); process.exit(1); });
1447
- break;
1448
- case 'dni':
1449
- dni(args[0]).catch(e => { console.error(e); process.exit(1); });
1450
- break;
1451
- case 'search':
1452
- search(args.join(' ')).catch(e => { console.error(e); process.exit(1); });
1453
- break;
1454
- case 'debtors':
1455
- debtors(args).catch(e => { console.error(e); process.exit(1); });
1456
- break;
1457
- case 'licitaciones':
1458
- case 'lic':
1459
- licitaciones(args).catch(e => { console.error(e); process.exit(1); });
1460
- break;
1461
1838
  case 'bench':
1462
1839
  bench(args).catch(e => { console.error(e); process.exit(1); });
1463
1840
  break;
1841
+ case 'admin':
1842
+ admin(args).catch(e => { console.error(e); process.exit(1); });
1843
+ break;
1464
1844
  case 'easypipe':
1465
1845
  case 'ep':
1466
1846
  easypipe(args).catch(e => { console.error(e); process.exit(1); });
@@ -1471,6 +1851,23 @@ else {
1471
1851
  case 'help':
1472
1852
  help();
1473
1853
  break;
1854
+ // Backward compat: old flat commands redirect
1855
+ case 'ruc':
1856
+ sourceQuery('/pe/sunat/padron', args).catch(e => { console.error(e); process.exit(1); });
1857
+ break;
1858
+ case 'dni':
1859
+ sourceQuery('/pe/sunat/padron', ['--dni', ...args]).catch(e => { console.error(e); process.exit(1); });
1860
+ break;
1861
+ case 'search':
1862
+ sourceQuery('/pe/sunat/padron', ['--search', ...args]).catch(e => { console.error(e); process.exit(1); });
1863
+ break;
1864
+ case 'debtors':
1865
+ sourceQuery('/pe/sunat/coactiva', args).catch(e => { console.error(e); process.exit(1); });
1866
+ break;
1867
+ case 'licitaciones':
1868
+ case 'lic':
1869
+ licitaciones(args).catch(e => { console.error(e); process.exit(1); });
1870
+ break;
1474
1871
  default:
1475
1872
  printLogo();
1476
1873
  help();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "latinfo",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "description": "Tax registry & procurement API for Latin America. Query RUC, DNI, NIT, licitaciones from Peru & Colombia. Offline MPHF search, full OCDS data, updated daily.",
5
5
  "homepage": "https://latinfo.dev",
6
6
  "repository": {