@apitap/core 1.5.4 → 1.6.1
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.
- package/dist/cli.js +410 -21
- package/dist/cli.js.map +1 -1
- package/dist/discovery/openapi.js +23 -50
- package/dist/discovery/openapi.js.map +1 -1
- package/dist/replay/engine.d.ts +19 -0
- package/dist/replay/engine.js +46 -5
- package/dist/replay/engine.js.map +1 -1
- package/dist/skill/apis-guru.d.ts +35 -0
- package/dist/skill/apis-guru.js +136 -0
- package/dist/skill/apis-guru.js.map +1 -0
- package/dist/skill/merge.d.ts +29 -0
- package/dist/skill/merge.js +252 -0
- package/dist/skill/merge.js.map +1 -0
- package/dist/skill/openapi-converter.d.ts +31 -0
- package/dist/skill/openapi-converter.js +390 -0
- package/dist/skill/openapi-converter.js.map +1 -0
- package/dist/skill/signing.d.ts +5 -0
- package/dist/skill/signing.js +9 -0
- package/dist/skill/signing.js.map +1 -1
- package/dist/skill/store.js +9 -4
- package/dist/skill/store.js.map +1 -1
- package/dist/types.d.ts +43 -2
- package/package.json +3 -1
- package/src/cli.ts +442 -19
- package/src/discovery/openapi.ts +25 -56
- package/src/replay/engine.ts +48 -5
- package/src/skill/apis-guru.ts +169 -0
- package/src/skill/merge.ts +281 -0
- package/src/skill/openapi-converter.ts +434 -0
- package/src/skill/signing.ts +14 -0
- package/src/skill/store.ts +8 -4
- package/src/types.ts +44 -3
package/dist/cli.js
CHANGED
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
// src/cli.ts
|
|
3
3
|
import { capture } from './capture/monitor.js';
|
|
4
4
|
import { writeSkillFile, readSkillFile, listSkillFiles } from './skill/store.js';
|
|
5
|
-
import { replayEndpoint } from './replay/engine.js';
|
|
5
|
+
import { replayEndpoint, getConfidenceHint } from './replay/engine.js';
|
|
6
6
|
import { AuthManager, getMachineId } from './auth/manager.js';
|
|
7
7
|
import { deriveSigningKey } from './auth/crypto.js';
|
|
8
|
-
import { signSkillFile } from './skill/signing.js';
|
|
8
|
+
import { signSkillFile, signSkillFileAs } from './skill/signing.js';
|
|
9
9
|
import { importSkillFile } from './skill/importer.js';
|
|
10
10
|
import { resolveAndValidateUrl } from './skill/ssrf.js';
|
|
11
11
|
import { verifyEndpoints } from './capture/verifier.js';
|
|
@@ -26,6 +26,9 @@ import { stat, unlink } from 'node:fs/promises';
|
|
|
26
26
|
import { fileURLToPath } from 'node:url';
|
|
27
27
|
import { createMcpServer } from './mcp.js';
|
|
28
28
|
import { attach, parseDomainPatterns } from './capture/cdp-attach.js';
|
|
29
|
+
import { isOpenAPISpec, convertOpenAPISpec } from './skill/openapi-converter.js';
|
|
30
|
+
import { mergeSkillFile } from './skill/merge.js';
|
|
31
|
+
import { fetchApisGuruList, filterEntries, fetchSpec } from './skill/apis-guru.js';
|
|
29
32
|
const __dirname = fileURLToPath(new URL('.', import.meta.url));
|
|
30
33
|
const pkg = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf-8'));
|
|
31
34
|
const VERSION = pkg.version;
|
|
@@ -66,7 +69,9 @@ function printUsage() {
|
|
|
66
69
|
apitap show <domain> Show endpoints for a domain
|
|
67
70
|
apitap replay <domain> <endpoint-id> [key=value...]
|
|
68
71
|
Replay an API endpoint
|
|
69
|
-
apitap import <file>
|
|
72
|
+
apitap import <file|url> Import a skill file or OpenAPI spec
|
|
73
|
+
apitap import --from apis-guru
|
|
74
|
+
Bulk-import from APIs.guru directory
|
|
70
75
|
apitap refresh <domain> Refresh auth tokens via browser
|
|
71
76
|
apitap auth [domain] View or manage stored auth
|
|
72
77
|
apitap mcp Run the full ApiTap MCP server over stdio
|
|
@@ -118,6 +123,11 @@ function printUsage() {
|
|
|
118
123
|
|
|
119
124
|
Import options:
|
|
120
125
|
--yes Skip confirmation prompt
|
|
126
|
+
--dry-run Show what would change without writing
|
|
127
|
+
--json Output machine-readable JSON
|
|
128
|
+
--from apis-guru Bulk-import from APIs.guru directory
|
|
129
|
+
--search <term> Filter APIs.guru entries by name/title
|
|
130
|
+
--limit <n> Max APIs to import (default: 100)
|
|
121
131
|
|
|
122
132
|
Serve options:
|
|
123
133
|
--json Output tool list as JSON on stderr
|
|
@@ -400,6 +410,19 @@ async function handleReplay(positional, flags) {
|
|
|
400
410
|
maxBytes,
|
|
401
411
|
_skipSsrfCheck: dangerDisableSsrf,
|
|
402
412
|
});
|
|
413
|
+
// Auto-upgrade imported endpoints on successful replay
|
|
414
|
+
if (result.upgrade && endpoint) {
|
|
415
|
+
endpoint.confidence = 1.0;
|
|
416
|
+
endpoint.endpointProvenance = 'captured';
|
|
417
|
+
// Update example with the actual successful request
|
|
418
|
+
endpoint.examples.responsePreview = typeof result.data === 'object' ? result.data : null;
|
|
419
|
+
// Re-sign and write
|
|
420
|
+
const signed = signSkillFile(skill, signingKey);
|
|
421
|
+
await writeSkillFile(signed, SKILLS_DIR);
|
|
422
|
+
if (!json) {
|
|
423
|
+
console.error(' \u2713 Endpoint upgraded to captured (confidence 1.0)');
|
|
424
|
+
}
|
|
425
|
+
}
|
|
403
426
|
if (json) {
|
|
404
427
|
console.log(JSON.stringify({
|
|
405
428
|
status: result.status,
|
|
@@ -408,40 +431,142 @@ async function handleReplay(positional, flags) {
|
|
|
408
431
|
}, null, 2));
|
|
409
432
|
}
|
|
410
433
|
else {
|
|
434
|
+
const hint = endpoint ? getConfidenceHint(endpoint.confidence) : null;
|
|
435
|
+
if (hint) {
|
|
436
|
+
console.error(` Note: ${hint}`);
|
|
437
|
+
}
|
|
411
438
|
console.log(`\n Status: ${result.status}\n`);
|
|
412
439
|
console.log(JSON.stringify(result.data, null, 2));
|
|
413
440
|
console.log();
|
|
414
441
|
}
|
|
415
442
|
}
|
|
416
443
|
async function handleImport(positional, flags) {
|
|
417
|
-
|
|
418
|
-
if (
|
|
419
|
-
|
|
444
|
+
// --from apis-guru: bulk import mode
|
|
445
|
+
if (flags['from'] === 'apis-guru') {
|
|
446
|
+
await handleApisGuruImport(flags);
|
|
447
|
+
return;
|
|
448
|
+
}
|
|
449
|
+
const source = positional[0];
|
|
450
|
+
if (!source) {
|
|
451
|
+
console.error('Error: File path or URL required. Usage: apitap import <file|url>');
|
|
420
452
|
process.exit(1);
|
|
421
453
|
}
|
|
422
454
|
const json = flags.json === true;
|
|
455
|
+
// Load content — from URL or file
|
|
456
|
+
let rawText;
|
|
457
|
+
let sourceUrl = source;
|
|
458
|
+
const isUrl = source.startsWith('http://') || source.startsWith('https://');
|
|
459
|
+
if (isUrl) {
|
|
460
|
+
try {
|
|
461
|
+
const ssrfCheck = await resolveAndValidateUrl(source);
|
|
462
|
+
if (!ssrfCheck.safe) {
|
|
463
|
+
throw new Error(`SSRF check failed: ${ssrfCheck.reason}`);
|
|
464
|
+
}
|
|
465
|
+
const response = await fetch(source, { signal: AbortSignal.timeout(30_000) });
|
|
466
|
+
if (!response.ok) {
|
|
467
|
+
throw new Error(`HTTP ${response.status} ${response.statusText}`);
|
|
468
|
+
}
|
|
469
|
+
rawText = await response.text();
|
|
470
|
+
}
|
|
471
|
+
catch (err) {
|
|
472
|
+
const msg = `Failed to fetch ${source}: ${err.message}`;
|
|
473
|
+
if (json) {
|
|
474
|
+
console.log(JSON.stringify({ success: false, reason: msg }));
|
|
475
|
+
}
|
|
476
|
+
else {
|
|
477
|
+
console.error(`Error: ${msg}`);
|
|
478
|
+
}
|
|
479
|
+
process.exit(1);
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
else {
|
|
483
|
+
try {
|
|
484
|
+
const { readFile } = await import('node:fs/promises');
|
|
485
|
+
rawText = await readFile(source, 'utf-8');
|
|
486
|
+
sourceUrl = `file://${resolve(source)}`;
|
|
487
|
+
}
|
|
488
|
+
catch (err) {
|
|
489
|
+
const msg = `Failed to read ${source}: ${err.message}`;
|
|
490
|
+
if (json) {
|
|
491
|
+
console.log(JSON.stringify({ success: false, reason: msg }));
|
|
492
|
+
}
|
|
493
|
+
else {
|
|
494
|
+
console.error(`Error: ${msg}`);
|
|
495
|
+
}
|
|
496
|
+
process.exit(1);
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
// Parse JSON or YAML
|
|
500
|
+
let parsed;
|
|
501
|
+
try {
|
|
502
|
+
parsed = JSON.parse(rawText);
|
|
503
|
+
}
|
|
504
|
+
catch {
|
|
505
|
+
try {
|
|
506
|
+
const yaml = await import('js-yaml');
|
|
507
|
+
parsed = yaml.load(rawText);
|
|
508
|
+
if (typeof parsed !== 'object' || parsed === null) {
|
|
509
|
+
throw new Error('YAML parsed to non-object');
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
catch (yamlErr) {
|
|
513
|
+
if (json) {
|
|
514
|
+
console.log(JSON.stringify({ success: false, reason: `Invalid JSON/YAML: ${yamlErr.message}` }));
|
|
515
|
+
}
|
|
516
|
+
else {
|
|
517
|
+
console.error(`Error: Could not parse as JSON or YAML: ${yamlErr.message}`);
|
|
518
|
+
}
|
|
519
|
+
process.exit(1);
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
// Route: OpenAPI spec vs SkillFile
|
|
523
|
+
if (isOpenAPISpec(parsed)) {
|
|
524
|
+
await handleOpenAPIImport(parsed, sourceUrl, flags);
|
|
525
|
+
return;
|
|
526
|
+
}
|
|
527
|
+
// --- Existing SkillFile import flow (unchanged) ---
|
|
528
|
+
if (isUrl) {
|
|
529
|
+
// SkillFile imports from URLs: write to temp file first
|
|
530
|
+
const { writeFile: writeTmp } = await import('node:fs/promises');
|
|
531
|
+
const { tmpdir } = await import('node:os');
|
|
532
|
+
const tmpPath = join(tmpdir(), `apitap-import-${Date.now()}.json`);
|
|
533
|
+
await writeTmp(tmpPath, rawText);
|
|
534
|
+
try {
|
|
535
|
+
await handleSkillFileImport(tmpPath, json);
|
|
536
|
+
}
|
|
537
|
+
finally {
|
|
538
|
+
const { unlink: unlinkTmp } = await import('node:fs/promises');
|
|
539
|
+
await unlinkTmp(tmpPath).catch(() => { });
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
else {
|
|
543
|
+
await handleSkillFileImport(source, json);
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
async function handleSkillFileImport(filePath, json) {
|
|
423
547
|
// Get local key for signature verification
|
|
424
548
|
const machineId = await getMachineId();
|
|
425
549
|
const key = deriveSigningKey(machineId);
|
|
426
550
|
// DNS-resolving SSRF check before importing (prevents DNS rebinding attacks)
|
|
551
|
+
let raw;
|
|
427
552
|
try {
|
|
428
|
-
|
|
429
|
-
if (raw.baseUrl) {
|
|
430
|
-
const dnsCheck = await resolveAndValidateUrl(raw.baseUrl);
|
|
431
|
-
if (!dnsCheck.safe) {
|
|
432
|
-
const msg = `DNS rebinding risk: ${dnsCheck.reason}`;
|
|
433
|
-
if (json) {
|
|
434
|
-
console.log(JSON.stringify({ success: false, reason: msg }));
|
|
435
|
-
}
|
|
436
|
-
else {
|
|
437
|
-
console.error(`Error: ${msg}`);
|
|
438
|
-
}
|
|
439
|
-
process.exit(1);
|
|
440
|
-
}
|
|
441
|
-
}
|
|
553
|
+
raw = JSON.parse(await import('node:fs/promises').then(fs => fs.readFile(filePath, 'utf-8')));
|
|
442
554
|
}
|
|
443
555
|
catch {
|
|
444
|
-
// Parse errors will be caught by importSkillFile
|
|
556
|
+
// Parse errors will be caught by importSkillFile below
|
|
557
|
+
}
|
|
558
|
+
if (raw?.baseUrl) {
|
|
559
|
+
const dnsCheck = await resolveAndValidateUrl(raw.baseUrl);
|
|
560
|
+
if (!dnsCheck.safe) {
|
|
561
|
+
const msg = `DNS rebinding risk: ${dnsCheck.reason}`;
|
|
562
|
+
if (json) {
|
|
563
|
+
console.log(JSON.stringify({ success: false, reason: msg }));
|
|
564
|
+
}
|
|
565
|
+
else {
|
|
566
|
+
console.error(`Error: ${msg}`);
|
|
567
|
+
}
|
|
568
|
+
process.exit(1);
|
|
569
|
+
}
|
|
445
570
|
}
|
|
446
571
|
const result = await importSkillFile(filePath, undefined, key);
|
|
447
572
|
if (!result.success) {
|
|
@@ -460,6 +585,270 @@ async function handleImport(positional, flags) {
|
|
|
460
585
|
console.log(`\n ✓ Imported skill file: ${result.skillFile}\n`);
|
|
461
586
|
}
|
|
462
587
|
}
|
|
588
|
+
async function handleOpenAPIImport(spec, specUrl, flags) {
|
|
589
|
+
const json = flags.json === true;
|
|
590
|
+
const dryRun = flags['dry-run'] === true;
|
|
591
|
+
const update = flags.update === true;
|
|
592
|
+
const force = flags.force === true;
|
|
593
|
+
const skillsDir = SKILLS_DIR || join(APITAP_DIR, 'skills');
|
|
594
|
+
// Convert OpenAPI spec to endpoints
|
|
595
|
+
let importResult;
|
|
596
|
+
try {
|
|
597
|
+
importResult = convertOpenAPISpec(spec, specUrl);
|
|
598
|
+
}
|
|
599
|
+
catch (err) {
|
|
600
|
+
const msg = `Failed to convert OpenAPI spec: ${err.message}`;
|
|
601
|
+
if (json) {
|
|
602
|
+
console.log(JSON.stringify({ success: false, reason: msg }));
|
|
603
|
+
}
|
|
604
|
+
else {
|
|
605
|
+
console.error(`Error: ${msg}`);
|
|
606
|
+
}
|
|
607
|
+
process.exit(1);
|
|
608
|
+
}
|
|
609
|
+
const { domain, endpoints, meta } = importResult;
|
|
610
|
+
const specVersion = spec.openapi || spec.swagger || 'unknown';
|
|
611
|
+
if (!json) {
|
|
612
|
+
console.log(`\n Importing ${domain} from OpenAPI ${specVersion} spec...\n`);
|
|
613
|
+
}
|
|
614
|
+
// SSRF validate the domain
|
|
615
|
+
const dnsCheck = await resolveAndValidateUrl(`https://${domain}`);
|
|
616
|
+
if (!dnsCheck.safe) {
|
|
617
|
+
const msg = `DNS rebinding risk for ${domain}: ${dnsCheck.reason}`;
|
|
618
|
+
if (json) {
|
|
619
|
+
console.log(JSON.stringify({ success: false, reason: msg }));
|
|
620
|
+
}
|
|
621
|
+
else {
|
|
622
|
+
console.error(`Error: ${msg}`);
|
|
623
|
+
}
|
|
624
|
+
process.exit(1);
|
|
625
|
+
}
|
|
626
|
+
// Read existing skill file (if any)
|
|
627
|
+
let existing = null;
|
|
628
|
+
try {
|
|
629
|
+
existing = await readSkillFile(domain, skillsDir, {
|
|
630
|
+
verifySignature: true,
|
|
631
|
+
trustUnsigned: true,
|
|
632
|
+
});
|
|
633
|
+
}
|
|
634
|
+
catch (err) {
|
|
635
|
+
if (err?.code !== 'ENOENT') {
|
|
636
|
+
if (!json)
|
|
637
|
+
console.error(` Warning: could not read existing skill file for ${domain}: ${err.message}`);
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
if (!force && update && existing?.metadata.importHistory?.some(h => h.specUrl === specUrl)) {
|
|
641
|
+
if (json) {
|
|
642
|
+
console.log(JSON.stringify({ success: true, skipped: true, reason: 'Already imported from this spec URL' }));
|
|
643
|
+
}
|
|
644
|
+
else {
|
|
645
|
+
console.log(' Already imported from this spec URL. Use --force to reimport.\n');
|
|
646
|
+
}
|
|
647
|
+
return;
|
|
648
|
+
}
|
|
649
|
+
// Merge
|
|
650
|
+
const { skillFile, diff } = mergeSkillFile(existing, endpoints, meta);
|
|
651
|
+
// Ensure domain and baseUrl reflect the API, not the spec source URL
|
|
652
|
+
skillFile.domain = domain;
|
|
653
|
+
skillFile.baseUrl = `https://${domain}`;
|
|
654
|
+
if (dryRun) {
|
|
655
|
+
if (json) {
|
|
656
|
+
console.log(JSON.stringify({
|
|
657
|
+
success: true,
|
|
658
|
+
dryRun: true,
|
|
659
|
+
domain,
|
|
660
|
+
diff,
|
|
661
|
+
totalEndpoints: skillFile.endpoints.length,
|
|
662
|
+
}));
|
|
663
|
+
}
|
|
664
|
+
else {
|
|
665
|
+
printOpenAPIDiff(diff, skillFile, skillsDir);
|
|
666
|
+
console.log(' (dry run — no changes written)\n');
|
|
667
|
+
}
|
|
668
|
+
return;
|
|
669
|
+
}
|
|
670
|
+
// Sign and write
|
|
671
|
+
const machineId = await getMachineId();
|
|
672
|
+
const key = deriveSigningKey(machineId);
|
|
673
|
+
// Determine provenance: 'self' if file has any captured endpoints, 'imported-signed' if all from import
|
|
674
|
+
const hasCaptured = skillFile.endpoints.some(ep => !ep.endpointProvenance || ep.endpointProvenance === 'captured');
|
|
675
|
+
const signed = signSkillFileAs(skillFile, key, hasCaptured ? 'self' : 'imported-signed');
|
|
676
|
+
const filePath = await writeSkillFile(signed, skillsDir);
|
|
677
|
+
if (json) {
|
|
678
|
+
console.log(JSON.stringify({
|
|
679
|
+
success: true,
|
|
680
|
+
domain,
|
|
681
|
+
skillFile: filePath,
|
|
682
|
+
diff,
|
|
683
|
+
totalEndpoints: signed.endpoints.length,
|
|
684
|
+
}));
|
|
685
|
+
}
|
|
686
|
+
else {
|
|
687
|
+
printOpenAPIDiff(diff, signed, filePath);
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
function printOpenAPIDiff(diff, skillFile, pathOrDir) {
|
|
691
|
+
console.log(` ✓ ${diff.preserved} existing captured endpoints preserved`);
|
|
692
|
+
console.log(` + ${diff.added} new endpoints added from OpenAPI spec`);
|
|
693
|
+
console.log(` ~ ${diff.enriched} endpoints enriched with spec metadata`);
|
|
694
|
+
console.log(` · ${diff.skipped} skipped (already imported)`);
|
|
695
|
+
console.log();
|
|
696
|
+
console.log(` Skill file: ${pathOrDir} (${skillFile.endpoints.length} endpoints)\n`);
|
|
697
|
+
}
|
|
698
|
+
async function handleApisGuruImport(flags) {
|
|
699
|
+
const json = flags.json === true;
|
|
700
|
+
const dryRun = flags['dry-run'] === true;
|
|
701
|
+
const update = flags.update === true;
|
|
702
|
+
const force = flags.force === true;
|
|
703
|
+
const limit = typeof flags.limit === 'string' ? parseInt(flags.limit, 10) : 100;
|
|
704
|
+
const search = typeof flags.search === 'string' ? flags.search : undefined;
|
|
705
|
+
const skillsDir = SKILLS_DIR || join(APITAP_DIR, 'skills');
|
|
706
|
+
if (!json) {
|
|
707
|
+
console.log(`\n Importing from APIs.guru (limit: ${limit})...\n`);
|
|
708
|
+
}
|
|
709
|
+
// Fetch and filter
|
|
710
|
+
let entries;
|
|
711
|
+
try {
|
|
712
|
+
const allEntries = await fetchApisGuruList();
|
|
713
|
+
entries = filterEntries(allEntries, { search, limit, preferOpenapi3: true });
|
|
714
|
+
}
|
|
715
|
+
catch (err) {
|
|
716
|
+
const msg = `Failed to fetch APIs.guru list: ${err.message}`;
|
|
717
|
+
if (json) {
|
|
718
|
+
console.log(JSON.stringify({ success: false, reason: msg }));
|
|
719
|
+
}
|
|
720
|
+
else {
|
|
721
|
+
console.error(`Error: ${msg}`);
|
|
722
|
+
}
|
|
723
|
+
process.exit(1);
|
|
724
|
+
}
|
|
725
|
+
if (entries.length === 0) {
|
|
726
|
+
if (json) {
|
|
727
|
+
console.log(JSON.stringify({ success: true, imported: 0, failed: 0, skipped: 0, totalEndpoints: 0 }));
|
|
728
|
+
}
|
|
729
|
+
else {
|
|
730
|
+
console.log(' No matching APIs found.\n');
|
|
731
|
+
}
|
|
732
|
+
return;
|
|
733
|
+
}
|
|
734
|
+
const total = entries.length;
|
|
735
|
+
let imported = 0;
|
|
736
|
+
let failed = 0;
|
|
737
|
+
let skippedApis = 0;
|
|
738
|
+
let totalEndpointsAdded = 0;
|
|
739
|
+
const machineId = await getMachineId();
|
|
740
|
+
const key = deriveSigningKey(machineId);
|
|
741
|
+
const results = [];
|
|
742
|
+
for (let i = 0; i < entries.length; i++) {
|
|
743
|
+
const entry = entries[i];
|
|
744
|
+
const idx = String(i + 1).padStart(String(total).length, ' ');
|
|
745
|
+
try {
|
|
746
|
+
// Fetch spec
|
|
747
|
+
const spec = await fetchSpec(entry.specUrl);
|
|
748
|
+
// Convert
|
|
749
|
+
const importResult = convertOpenAPISpec(spec, entry.specUrl);
|
|
750
|
+
const { domain, endpoints, meta } = importResult;
|
|
751
|
+
if (endpoints.length === 0) {
|
|
752
|
+
if (!json) {
|
|
753
|
+
console.log(` [${idx}/${total}] SKIP ${domain.padEnd(24)} 0 endpoints (${entry.title})`);
|
|
754
|
+
}
|
|
755
|
+
results.push({ index: i + 1, status: 'skip', domain, title: entry.title, endpointsAdded: 0 });
|
|
756
|
+
skippedApis++;
|
|
757
|
+
continue;
|
|
758
|
+
}
|
|
759
|
+
// SSRF validate
|
|
760
|
+
const dnsCheck = await resolveAndValidateUrl(`https://${domain}`);
|
|
761
|
+
if (!dnsCheck.safe) {
|
|
762
|
+
if (!json) {
|
|
763
|
+
console.log(` [${idx}/${total}] SKIP ${domain.padEnd(24)} SSRF risk (${entry.title})`);
|
|
764
|
+
}
|
|
765
|
+
results.push({ index: i + 1, status: 'skip', domain, title: entry.title, endpointsAdded: 0 });
|
|
766
|
+
skippedApis++;
|
|
767
|
+
continue;
|
|
768
|
+
}
|
|
769
|
+
// Read existing
|
|
770
|
+
let existing = null;
|
|
771
|
+
try {
|
|
772
|
+
existing = await readSkillFile(domain, skillsDir, {
|
|
773
|
+
verifySignature: true,
|
|
774
|
+
trustUnsigned: true,
|
|
775
|
+
});
|
|
776
|
+
}
|
|
777
|
+
catch (err) {
|
|
778
|
+
if (err?.code !== 'ENOENT') {
|
|
779
|
+
if (!json)
|
|
780
|
+
console.error(` Warning: could not read existing skill file for ${domain}: ${err.message}`);
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
if (!force && update && existing?.metadata.importHistory?.length) {
|
|
784
|
+
const lastImport = existing.metadata.importHistory[existing.metadata.importHistory.length - 1];
|
|
785
|
+
if (lastImport.importedAt >= entry.updated) {
|
|
786
|
+
if (!json)
|
|
787
|
+
console.log(` [${idx}/${total}] SKIP ${domain.padEnd(24)} up to date`);
|
|
788
|
+
results.push({ index: i + 1, status: 'skip', domain, title: entry.title, endpointsAdded: 0 });
|
|
789
|
+
skippedApis++;
|
|
790
|
+
continue;
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
// Merge
|
|
794
|
+
const { skillFile, diff } = mergeSkillFile(existing, endpoints, meta);
|
|
795
|
+
// Ensure domain and baseUrl reflect the API, not the spec source URL
|
|
796
|
+
skillFile.domain = domain;
|
|
797
|
+
skillFile.baseUrl = `https://${domain}`;
|
|
798
|
+
if (!dryRun) {
|
|
799
|
+
// Sign and write
|
|
800
|
+
// Determine provenance: 'self' if file has any captured endpoints, 'imported-signed' if all from import
|
|
801
|
+
const hasCaptured = skillFile.endpoints.some(ep => !ep.endpointProvenance || ep.endpointProvenance === 'captured');
|
|
802
|
+
const signed = signSkillFileAs(skillFile, key, hasCaptured ? 'self' : 'imported-signed');
|
|
803
|
+
await writeSkillFile(signed, skillsDir);
|
|
804
|
+
}
|
|
805
|
+
if (!json) {
|
|
806
|
+
console.log(` [${idx}/${total}] OK ${domain.padEnd(24)} +${diff.added} endpoints (${entry.title})`);
|
|
807
|
+
}
|
|
808
|
+
results.push({ index: i + 1, status: 'ok', domain, title: entry.title, endpointsAdded: diff.added });
|
|
809
|
+
imported++;
|
|
810
|
+
totalEndpointsAdded += diff.added;
|
|
811
|
+
}
|
|
812
|
+
catch (err) {
|
|
813
|
+
if (!json) {
|
|
814
|
+
console.log(` [${idx}/${total}] FAIL ${entry.providerName.padEnd(24)} ${err.message}`);
|
|
815
|
+
}
|
|
816
|
+
results.push({
|
|
817
|
+
index: i + 1,
|
|
818
|
+
status: 'fail',
|
|
819
|
+
domain: entry.providerName,
|
|
820
|
+
title: entry.title,
|
|
821
|
+
endpointsAdded: 0,
|
|
822
|
+
error: err.message,
|
|
823
|
+
});
|
|
824
|
+
failed++;
|
|
825
|
+
}
|
|
826
|
+
// Small delay between requests to be polite to APIs.guru
|
|
827
|
+
if (i < entries.length - 1) {
|
|
828
|
+
await new Promise(r => setTimeout(r, 100));
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
if (json) {
|
|
832
|
+
console.log(JSON.stringify({
|
|
833
|
+
success: true,
|
|
834
|
+
dryRun,
|
|
835
|
+
imported,
|
|
836
|
+
failed,
|
|
837
|
+
skipped: skippedApis,
|
|
838
|
+
totalEndpoints: totalEndpointsAdded,
|
|
839
|
+
results,
|
|
840
|
+
}));
|
|
841
|
+
}
|
|
842
|
+
else {
|
|
843
|
+
console.log();
|
|
844
|
+
console.log(` Done: ${imported} imported, ${failed} failed, ${skippedApis} skipped`);
|
|
845
|
+
console.log(` ${totalEndpointsAdded.toLocaleString()} endpoints added across ${imported} APIs`);
|
|
846
|
+
if (dryRun) {
|
|
847
|
+
console.log(' (dry run — no changes written)');
|
|
848
|
+
}
|
|
849
|
+
console.log();
|
|
850
|
+
}
|
|
851
|
+
}
|
|
463
852
|
async function handleRefresh(positional, flags) {
|
|
464
853
|
const domain = positional[0];
|
|
465
854
|
if (!domain) {
|