@apitap/core 1.8.2 → 1.9.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 CHANGED
@@ -31,6 +31,7 @@ import { mergeSkillFile } from './skill/merge.js';
31
31
  import { fetchApisGuruList, filterEntries, fetchSpec } from './skill/apis-guru.js';
32
32
  import { searchSwaggerHub, fetchSwaggerHubSpec } from './skill/swaggerhub.js';
33
33
  import { buildIndex, removeFromIndex } from './skill/index.js';
34
+ import { resolveGitHubToken, searchOrgSpecs, searchTopicSpecs, fetchGitHubSpec, filterResults, hasServerUrl, isLocalhostSpec, normalizeSpecServerUrls, CANONICAL_TOPICS, } from './skill/github.js';
34
35
  const __dirname = fileURLToPath(new URL('.', import.meta.url));
35
36
  const pkg = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf-8'));
36
37
  const VERSION = pkg.version;
@@ -76,6 +77,10 @@ function printUsage() {
76
77
  Bulk-import from APIs.guru directory
77
78
  apitap import --from swaggerhub --query <term>
78
79
  Import from SwaggerHub (785K+ public specs)
80
+ apitap import --from github --org <name>
81
+ Import specs from a GitHub org's repos
82
+ apitap import --from github --topic [name]
83
+ Import from topic-tagged repos (--query to filter)
79
84
  apitap refresh <domain> Refresh auth tokens via browser
80
85
  apitap auth [domain] View or manage stored auth
81
86
  apitap mcp Run the full ApiTap MCP server over stdio
@@ -470,6 +475,11 @@ async function handleImport(positional, flags) {
470
475
  await handleSwaggerHubImport(flags);
471
476
  return;
472
477
  }
478
+ // --from github: GitHub import mode
479
+ if (flags['from'] === 'github') {
480
+ await handleGitHubImport(flags);
481
+ return;
482
+ }
473
483
  const source = positional[0];
474
484
  if (!source) {
475
485
  console.error('Error: File path or URL required. Usage: apitap import <file|url>');
@@ -1028,6 +1038,321 @@ async function handleSwaggerHubImport(flags) {
1028
1038
  console.log();
1029
1039
  }
1030
1040
  }
1041
+ async function handleGitHubImport(flags) {
1042
+ const json = flags.json === true;
1043
+ const force = flags.force === true;
1044
+ const update = flags.update === true;
1045
+ const dryRun = flags['dry-run'] === true;
1046
+ const noAuthOnly = flags['no-auth-only'] === true;
1047
+ const includeStale = flags['include-stale'] === true;
1048
+ const limit = typeof flags.limit === 'string' ? parseInt(flags.limit, 10) : 20;
1049
+ const org = typeof flags.org === 'string' ? flags.org : undefined;
1050
+ const topicFlag = flags.topic;
1051
+ const query = typeof flags.query === 'string' ? flags.query : undefined;
1052
+ const skillsDir = SKILLS_DIR || join(APITAP_DIR, 'skills');
1053
+ // Mutual exclusion
1054
+ if (org && topicFlag) {
1055
+ const msg = '--org and --topic are mutually exclusive';
1056
+ if (json) {
1057
+ console.log(JSON.stringify({ success: false, reason: msg }));
1058
+ }
1059
+ else {
1060
+ console.error(`Error: ${msg}`);
1061
+ }
1062
+ process.exit(1);
1063
+ }
1064
+ if (!org && !topicFlag) {
1065
+ const msg = '--org or --topic required. Usage: apitap import --from github --org <name> OR --topic [name]';
1066
+ if (json) {
1067
+ console.log(JSON.stringify({ success: false, reason: msg }));
1068
+ }
1069
+ else {
1070
+ console.error(`Error: ${msg}`);
1071
+ }
1072
+ process.exit(1);
1073
+ }
1074
+ // --min-stars: different defaults per mode
1075
+ const minStarsDefault = org ? 0 : 10;
1076
+ const minStars = typeof flags['min-stars'] === 'string'
1077
+ ? parseInt(flags['min-stars'], 10) : minStarsDefault;
1078
+ // --topic parsing: bare --topic = all four topics, --topic openapi = just that one
1079
+ const topics = typeof topicFlag === 'string'
1080
+ ? [topicFlag]
1081
+ : CANONICAL_TOPICS;
1082
+ // Auth requirement for --org
1083
+ const token = await resolveGitHubToken();
1084
+ if (org && token === null) {
1085
+ const msg = "--org requires a GitHub token. Run 'gh auth login' or set GITHUB_TOKEN.";
1086
+ if (json) {
1087
+ console.log(JSON.stringify({ success: false, reason: msg }));
1088
+ }
1089
+ else {
1090
+ console.error(`Error: ${msg}`);
1091
+ }
1092
+ process.exit(1);
1093
+ }
1094
+ // Discovery phase
1095
+ if (!json) {
1096
+ if (org) {
1097
+ console.log(`\n Scanning GitHub org "${org}" for OpenAPI specs (limit: ${limit})...\n`);
1098
+ }
1099
+ else {
1100
+ const topicStr = topics.join(', ');
1101
+ console.log(`\n Searching GitHub topics [${topicStr}] (min-stars: ${minStars}, limit: ${limit})...\n`);
1102
+ }
1103
+ }
1104
+ let rawResults;
1105
+ // Rate limit tracking: stored in a mutable container so TypeScript doesn't
1106
+ // narrow to `null` based on control flow. Currently not populated because
1107
+ // searchOrgSpecs/searchTopicSpecs don't expose rate limit info, but the
1108
+ // plumbing is in place for when they do.
1109
+ const rateLimitRef = { value: null };
1110
+ try {
1111
+ if (org) {
1112
+ rawResults = await searchOrgSpecs(org, token);
1113
+ }
1114
+ else {
1115
+ rawResults = await searchTopicSpecs(topics, token, { minStars, query });
1116
+ }
1117
+ }
1118
+ catch (err) {
1119
+ const msg = `GitHub discovery failed: ${err.message}`;
1120
+ if (json) {
1121
+ console.log(JSON.stringify({ success: false, reason: msg }));
1122
+ }
1123
+ else {
1124
+ console.error(`Error: ${msg}`);
1125
+ }
1126
+ process.exit(1);
1127
+ }
1128
+ // Filter: forks, archived, stale, min-stars
1129
+ const { passed, skips: repoSkips } = filterResults(rawResults, { includeStale, minStars });
1130
+ if (!json && repoSkips.length > 0) {
1131
+ for (const skip of repoSkips) {
1132
+ console.log(` SKIP repo ${skip.repo.padEnd(40)} ${skip.reason}`);
1133
+ }
1134
+ console.log();
1135
+ }
1136
+ // Cap to limit
1137
+ const capped = passed.slice(0, limit);
1138
+ if (capped.length === 0) {
1139
+ if (json) {
1140
+ console.log(JSON.stringify({
1141
+ success: true,
1142
+ imported: 0,
1143
+ failed: 0,
1144
+ skipped: 0,
1145
+ totalEndpoints: 0,
1146
+ repoSkips,
1147
+ specSkips: [],
1148
+ results: [],
1149
+ }));
1150
+ }
1151
+ else {
1152
+ console.log(' No matching specs found.\n');
1153
+ }
1154
+ return;
1155
+ }
1156
+ if (!json) {
1157
+ console.log(` Found ${passed.length} specs, importing top ${capped.length}...\n`);
1158
+ }
1159
+ const total = capped.length;
1160
+ let imported = 0;
1161
+ let failed = 0;
1162
+ let skippedSpecs = 0;
1163
+ let totalEndpointsAdded = 0;
1164
+ const specSkips = [];
1165
+ const machineId = await getMachineId();
1166
+ const key = deriveSigningKey(machineId);
1167
+ const results = [];
1168
+ for (let i = 0; i < capped.length; i++) {
1169
+ const result = capped[i];
1170
+ const idx = String(i + 1).padStart(String(total).length, ' ');
1171
+ try {
1172
+ // Fetch spec
1173
+ const spec = await fetchGitHubSpec(result.specUrl, token);
1174
+ // Pre-conversion content filters
1175
+ if (!hasServerUrl(spec)) {
1176
+ if (!json) {
1177
+ console.log(` [${idx}/${total}] SKIP ${result.repoFullName.padEnd(40)} no server URL`);
1178
+ }
1179
+ specSkips.push({ domain: result.repoFullName, reason: 'no server URL' });
1180
+ skippedSpecs++;
1181
+ continue;
1182
+ }
1183
+ if (isLocalhostSpec(spec)) {
1184
+ specSkips.push({ domain: result.repoFullName, reason: 'localhost/placeholder' });
1185
+ skippedSpecs++;
1186
+ continue;
1187
+ }
1188
+ // Normalize templated domains before conversion
1189
+ normalizeSpecServerUrls(spec);
1190
+ // Convert
1191
+ const importResult = convertOpenAPISpec(spec, result.specUrl);
1192
+ const { domain, endpoints, meta } = importResult;
1193
+ if (endpoints.length === 0) {
1194
+ if (!json) {
1195
+ console.log(` [${idx}/${total}] SKIP ${domain.padEnd(40)} 0 endpoints`);
1196
+ }
1197
+ specSkips.push({ domain, reason: '0 endpoints' });
1198
+ skippedSpecs++;
1199
+ continue;
1200
+ }
1201
+ // SSRF validate
1202
+ const dnsCheck = await resolveAndValidateUrl(`https://${domain}`);
1203
+ if (!dnsCheck.safe) {
1204
+ if (!json) {
1205
+ console.log(` [${idx}/${total}] SKIP ${domain.padEnd(40)} SSRF risk`);
1206
+ }
1207
+ specSkips.push({ domain, reason: 'SSRF risk' });
1208
+ skippedSpecs++;
1209
+ continue;
1210
+ }
1211
+ // Skip auth-required APIs when --no-auth-only is set
1212
+ if (noAuthOnly && meta.requiresAuth) {
1213
+ if (!json) {
1214
+ console.log(` [${idx}/${total}] SKIP ${domain.padEnd(40)} requires auth`);
1215
+ }
1216
+ specSkips.push({ domain, reason: 'requires auth' });
1217
+ skippedSpecs++;
1218
+ continue;
1219
+ }
1220
+ // Read existing — skip HMAC since we only need endpoints for merge
1221
+ let existing = null;
1222
+ try {
1223
+ existing = await readSkillFile(domain, skillsDir, { verifySignature: false });
1224
+ }
1225
+ catch (err) {
1226
+ if (err?.code !== 'ENOENT') {
1227
+ if (!json)
1228
+ console.error(` Warning: could not read existing skill file for ${domain}: ${err.message}`);
1229
+ }
1230
+ }
1231
+ // --update check: skip if existing import from same specUrl is newer than repo push
1232
+ if (!force && update && existing?.metadata.importHistory?.some((h) => h.specUrl === result.specUrl && h.importedAt >= result.pushedAt)) {
1233
+ if (!json)
1234
+ console.log(` [${idx}/${total}] SKIP ${domain.padEnd(40)} up to date`);
1235
+ skippedSpecs++;
1236
+ specSkips.push({ domain, reason: 'up to date' });
1237
+ continue;
1238
+ }
1239
+ // Skip if already exists and not --force and not --update
1240
+ if (!force && !update && existing?.endpoints.length) {
1241
+ if (!json) {
1242
+ console.log(` [${idx}/${total}] SKIP ${domain.padEnd(40)} already exists (${existing.endpoints.length} endpoints)`);
1243
+ }
1244
+ specSkips.push({ domain, reason: 'already exists' });
1245
+ skippedSpecs++;
1246
+ continue;
1247
+ }
1248
+ // Merge
1249
+ const { skillFile, diff } = mergeSkillFile(existing, endpoints, meta);
1250
+ skillFile.domain = domain;
1251
+ skillFile.baseUrl = `https://${domain}`;
1252
+ if (dryRun) {
1253
+ if (!json) {
1254
+ console.log(` (dry run) [${idx}/${total}] OK ${domain.padEnd(40)} +${diff.added} endpoints`);
1255
+ console.log(` -> ${result.htmlUrl}`);
1256
+ }
1257
+ results.push({
1258
+ index: i + 1,
1259
+ status: 'ok',
1260
+ domain,
1261
+ title: meta.title || result.description,
1262
+ endpointsAdded: diff.added,
1263
+ htmlUrl: result.htmlUrl,
1264
+ });
1265
+ imported++;
1266
+ totalEndpointsAdded += diff.added;
1267
+ continue;
1268
+ }
1269
+ // Sign and write
1270
+ const hasCaptured = skillFile.endpoints.some(ep => !ep.endpointProvenance || ep.endpointProvenance === 'captured');
1271
+ const signed = signSkillFileAs(skillFile, key, hasCaptured ? 'self' : 'imported-signed');
1272
+ await writeSkillFile(signed, skillsDir);
1273
+ if (!json) {
1274
+ console.log(` [${idx}/${total}] OK ${domain.padEnd(40)} +${diff.added} endpoints (${result.repoFullName})`);
1275
+ }
1276
+ results.push({
1277
+ index: i + 1,
1278
+ status: 'ok',
1279
+ domain,
1280
+ title: meta.title || result.description,
1281
+ endpointsAdded: diff.added,
1282
+ htmlUrl: result.htmlUrl,
1283
+ });
1284
+ imported++;
1285
+ totalEndpointsAdded += diff.added;
1286
+ }
1287
+ catch (err) {
1288
+ if (!json) {
1289
+ console.log(` [${idx}/${total}] FAIL ${result.repoFullName.padEnd(40)} ${err.message.slice(0, 60)}`);
1290
+ }
1291
+ results.push({
1292
+ index: i + 1,
1293
+ status: 'fail',
1294
+ domain: result.repoFullName,
1295
+ title: result.description,
1296
+ endpointsAdded: 0,
1297
+ htmlUrl: result.htmlUrl,
1298
+ error: err.message,
1299
+ });
1300
+ failed++;
1301
+ }
1302
+ // Polite delay between spec fetches
1303
+ if (i < capped.length - 1) {
1304
+ await new Promise(r => setTimeout(r, 100));
1305
+ }
1306
+ }
1307
+ if (json) {
1308
+ console.log(JSON.stringify({
1309
+ success: true,
1310
+ imported,
1311
+ failed,
1312
+ skipped: skippedSpecs,
1313
+ totalEndpoints: totalEndpointsAdded,
1314
+ repoSkips,
1315
+ specSkips,
1316
+ results,
1317
+ ...(rateLimitRef.value ? {
1318
+ githubApiUsage: {
1319
+ used: rateLimitRef.value.limit - rateLimitRef.value.remaining,
1320
+ limit: rateLimitRef.value.limit,
1321
+ resetAt: rateLimitRef.value.resetAt.toISOString(),
1322
+ },
1323
+ } : {}),
1324
+ }));
1325
+ }
1326
+ else {
1327
+ console.log(`\n Imported ${imported} specs: ${totalEndpointsAdded.toLocaleString()} endpoints across ${imported} domains`);
1328
+ if (repoSkips.length) {
1329
+ const reasons = new Map();
1330
+ for (const s of repoSkips)
1331
+ reasons.set(s.reason, (reasons.get(s.reason) ?? 0) + 1);
1332
+ const parts = [...reasons.entries()].map(([r, n]) => `${n} ${r}`);
1333
+ console.log(` Repo skips: ${repoSkips.length} (${parts.join(', ')})`);
1334
+ }
1335
+ if (specSkips.length) {
1336
+ const reasons = new Map();
1337
+ for (const s of specSkips)
1338
+ reasons.set(s.reason, (reasons.get(s.reason) ?? 0) + 1);
1339
+ const parts = [...reasons.entries()].map(([r, n]) => `${n} ${r}`);
1340
+ console.log(` Spec skips: ${specSkips.length} (${parts.join(', ')})`);
1341
+ }
1342
+ if (rateLimitRef.value) {
1343
+ const used = rateLimitRef.value.limit - rateLimitRef.value.remaining;
1344
+ let rateLine = ` GitHub API: ${used}/${rateLimitRef.value.limit} requests used`;
1345
+ if (rateLimitRef.value.remaining < 100) {
1346
+ rateLine += ` (resets ${rateLimitRef.value.resetAt.toLocaleTimeString()})`;
1347
+ }
1348
+ console.log(rateLine);
1349
+ }
1350
+ if (dryRun) {
1351
+ console.log(' (dry run — no changes written)');
1352
+ }
1353
+ console.log();
1354
+ }
1355
+ }
1031
1356
  async function handleRefresh(positional, flags) {
1032
1357
  const domain = positional[0];
1033
1358
  if (!domain) {