@apitap/core 1.8.2 → 1.9.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.
- package/dist/cli.js +325 -0
- package/dist/cli.js.map +1 -1
- package/dist/skill/github.d.ts +105 -0
- package/dist/skill/github.js +410 -0
- package/dist/skill/github.js.map +1 -0
- package/package.json +1 -1
- package/src/cli.ts +368 -0
- package/src/skill/github.ts +542 -0
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) {
|