botversion-sdk 1.0.4 → 1.0.5
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/bin/init.js +186 -140
- package/cli/detector.js +360 -173
- package/cli/generator.js +18 -476
- package/cli/prompts.js +40 -42
- package/cli/writer.js +118 -42
- package/client.js +215 -38
- package/index.js +74 -392
- package/interceptor.js +112 -124
- package/package.json +3 -2
- package/scanner.js +792 -27
package/scanner.js
CHANGED
|
@@ -4,31 +4,94 @@
|
|
|
4
4
|
/**
|
|
5
5
|
* Scans an Express app object and extracts all registered routes
|
|
6
6
|
*/
|
|
7
|
-
function scanExpressRoutes(app) {
|
|
7
|
+
function scanExpressRoutes(app, cwd) {
|
|
8
8
|
const endpoints = [];
|
|
9
9
|
const seen = new Set();
|
|
10
10
|
|
|
11
|
-
//
|
|
12
|
-
|
|
13
|
-
const router = app._router || app.router || (app.stack ? app : null);
|
|
11
|
+
// Build body map from ALL files upfront
|
|
12
|
+
const bodyMap = cwd ? buildBodyMap(cwd) : {};
|
|
14
13
|
|
|
15
|
-
if (
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
)
|
|
14
|
+
if (app) {
|
|
15
|
+
if (app.lazyrouter) app.lazyrouter();
|
|
16
|
+
const router = app._router || app.router || (app.stack ? app : null);
|
|
17
|
+
if (router) {
|
|
18
|
+
const stack = router.stack || [];
|
|
19
|
+
extractRoutes(stack, "", endpoints, seen, bodyMap);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
19
22
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
23
|
+
// ALSO scan all JS/TS files statically for route definitions
|
|
24
|
+
if (cwd) {
|
|
25
|
+
const allFiles = scanAllExpressFiles(cwd);
|
|
26
|
+
for (const file of allFiles) {
|
|
27
|
+
const fileEndpoints = scanExpressFileStatically(file, seen);
|
|
28
|
+
endpoints.push(...fileEndpoints);
|
|
26
29
|
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
console.log(`\n[DEBUG] ===== JS SCAN SUMMARY =====`);
|
|
33
|
+
endpoints.forEach((ep) => {
|
|
34
|
+
const status = ep.requestBody ? "✅" : "❌ NULL";
|
|
35
|
+
console.log(
|
|
36
|
+
`[DEBUG] ${status} ${ep.method.padEnd(6)} ${ep.path} → ${JSON.stringify(ep.requestBody)}`,
|
|
37
|
+
);
|
|
38
|
+
});
|
|
39
|
+
console.log(`[DEBUG] ================================\n`);
|
|
40
|
+
|
|
41
|
+
return endpoints;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function scanExpressFileStatically(filePath, seen) {
|
|
45
|
+
const fs = require("fs");
|
|
46
|
+
const endpoints = [];
|
|
47
|
+
|
|
48
|
+
let content;
|
|
49
|
+
try {
|
|
50
|
+
content = fs.readFileSync(filePath, "utf8");
|
|
51
|
+
} catch {
|
|
27
52
|
return endpoints;
|
|
28
53
|
}
|
|
54
|
+
console.log(`[DEBUG] Scanning file statically: ${filePath}`);
|
|
55
|
+
|
|
56
|
+
// Match patterns like:
|
|
57
|
+
// app.get('/path', ...)
|
|
58
|
+
// router.post('/path', ...)
|
|
59
|
+
// app.all('/curd.php', ...)
|
|
60
|
+
const routePattern =
|
|
61
|
+
/\.(get|post|put|delete|patch|all)\s*\(\s*['"`]([^'"`]+)['"`]/gi;
|
|
62
|
+
|
|
63
|
+
let match;
|
|
64
|
+
while ((match = routePattern.exec(content)) !== null) {
|
|
65
|
+
const method = match[1].toUpperCase();
|
|
66
|
+
const routePath = match[2];
|
|
67
|
+
|
|
68
|
+
// Skip middleware patterns
|
|
69
|
+
if (routePath.includes("*")) continue;
|
|
70
|
+
|
|
71
|
+
const key = method + ":" + routePath;
|
|
72
|
+
if (seen.has(key)) continue;
|
|
73
|
+
seen.add(key);
|
|
74
|
+
|
|
75
|
+
const effectiveMethod = method === "ALL" ? "GET" : method;
|
|
76
|
+
const needsBody = ["POST", "PUT", "PATCH"].includes(effectiveMethod);
|
|
77
|
+
const bodyFields = needsBody ? extractBodyFieldsFromFile(content) : null;
|
|
78
|
+
|
|
79
|
+
console.log(
|
|
80
|
+
`[DEBUG] ${effectiveMethod} ${routePath} → bodyFields: ${JSON.stringify(bodyFields)}`,
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
const routeParamMap = buildRouteParamMap(routePath, []);
|
|
84
|
+
|
|
85
|
+
endpoints.push({
|
|
86
|
+
method: effectiveMethod,
|
|
87
|
+
path: routePath,
|
|
88
|
+
description: "",
|
|
89
|
+
requestBody: bodyFields,
|
|
90
|
+
routeParamMap: routeParamMap,
|
|
91
|
+
detectedBy: "static-scan-file",
|
|
92
|
+
});
|
|
93
|
+
}
|
|
29
94
|
|
|
30
|
-
const stack = router.stack || [];
|
|
31
|
-
extractRoutes(stack, "", endpoints, seen);
|
|
32
95
|
return endpoints;
|
|
33
96
|
}
|
|
34
97
|
|
|
@@ -40,7 +103,7 @@ function scanExpressRoutes(app) {
|
|
|
40
103
|
* - a router layer → layer.name === 'router', has its own .handle.stack
|
|
41
104
|
* - middleware → everything else (body-parser, cors, etc.) — skip these
|
|
42
105
|
*/
|
|
43
|
-
function extractRoutes(stack, prefix, endpoints, seen) {
|
|
106
|
+
function extractRoutes(stack, prefix, endpoints, seen, bodyMap) {
|
|
44
107
|
prefix = prefix || "";
|
|
45
108
|
|
|
46
109
|
stack.forEach(function (layer) {
|
|
@@ -53,28 +116,54 @@ function extractRoutes(stack, prefix, endpoints, seen) {
|
|
|
53
116
|
|
|
54
117
|
methods.forEach(function (method) {
|
|
55
118
|
method = method.toUpperCase();
|
|
56
|
-
|
|
57
|
-
// Skip Express internals
|
|
58
119
|
if (method === "_ALL") return;
|
|
59
120
|
|
|
60
121
|
var key = method + ":" + routePath;
|
|
61
122
|
if (seen.has(key)) return;
|
|
62
123
|
seen.add(key);
|
|
63
124
|
|
|
64
|
-
|
|
125
|
+
const needsBody = ["POST", "PUT", "PATCH"].includes(method);
|
|
126
|
+
let requestBody = null;
|
|
127
|
+
|
|
128
|
+
if (needsBody) {
|
|
129
|
+
// Strategy 1: scan inline handler directly via fn.toString() — most accurate
|
|
130
|
+
for (const handler of layer.route.stack) {
|
|
131
|
+
const fn = handler.handle || handler;
|
|
132
|
+
const fnStr = fn.toString();
|
|
133
|
+
const fields = extractBodyFieldsFromFile(fnStr);
|
|
134
|
+
if (fields) {
|
|
135
|
+
requestBody = fields;
|
|
136
|
+
console.log(
|
|
137
|
+
`[DEBUG] Found body from inline handler for ${method} ${routePath}`,
|
|
138
|
+
);
|
|
139
|
+
break;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Strategy 2: fall back to bodyMap using handler name
|
|
144
|
+
if (!requestBody) {
|
|
145
|
+
const handlerName = extractHandlerName(layer);
|
|
146
|
+
console.log(
|
|
147
|
+
`[DEBUG] ${method} ${routePath} → handlerName: ${handlerName}`,
|
|
148
|
+
);
|
|
149
|
+
if (handlerName && bodyMap[handlerName]) {
|
|
150
|
+
requestBody = bodyMap[handlerName];
|
|
151
|
+
console.log(`[DEBUG] Found body from bodyMap for ${handlerName}`);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const routeParamMap = buildRouteParamMap(routePath, []);
|
|
65
157
|
|
|
66
158
|
endpoints.push({
|
|
67
159
|
method: method,
|
|
68
160
|
path: routePath,
|
|
69
161
|
description: "",
|
|
70
|
-
requestBody:
|
|
71
|
-
|
|
72
|
-
? buildParamSchema(params)
|
|
73
|
-
: null,
|
|
162
|
+
requestBody: requestBody,
|
|
163
|
+
routeParamMap: routeParamMap,
|
|
74
164
|
detectedBy: "static-scan",
|
|
75
165
|
});
|
|
76
166
|
});
|
|
77
|
-
|
|
78
167
|
return;
|
|
79
168
|
}
|
|
80
169
|
|
|
@@ -82,7 +171,7 @@ function extractRoutes(stack, prefix, endpoints, seen) {
|
|
|
82
171
|
if (layer.name === "router" && layer.handle && layer.handle.stack) {
|
|
83
172
|
// Extract the mount path from the regexp
|
|
84
173
|
var mountPath = prefix + regexpToPath(layer.regexp, layer.keys);
|
|
85
|
-
extractRoutes(layer.handle.stack, mountPath, endpoints, seen);
|
|
174
|
+
extractRoutes(layer.handle.stack, mountPath, endpoints, seen, bodyMap);
|
|
86
175
|
return;
|
|
87
176
|
}
|
|
88
177
|
|
|
@@ -139,11 +228,14 @@ function scanNextJsRoutes(pagesDir) {
|
|
|
139
228
|
bodyFields ||
|
|
140
229
|
(method === "DELETE" && queryFields ? queryFields : null);
|
|
141
230
|
|
|
231
|
+
const routeParamMap = buildRouteParamMap(normalizedPath, []);
|
|
232
|
+
|
|
142
233
|
endpoints.push({
|
|
143
234
|
method: method,
|
|
144
235
|
path: normalizedPath,
|
|
145
236
|
description: "",
|
|
146
237
|
requestBody: effectiveRequestBody,
|
|
238
|
+
routeParamMap: routeParamMap,
|
|
147
239
|
detectedBy: "static-scan",
|
|
148
240
|
});
|
|
149
241
|
});
|
|
@@ -230,6 +322,35 @@ function buildParamSchema(params) {
|
|
|
230
322
|
return schema;
|
|
231
323
|
}
|
|
232
324
|
|
|
325
|
+
function inferFieldType(fieldName, content) {
|
|
326
|
+
const arrayPatterns = [
|
|
327
|
+
new RegExp(
|
|
328
|
+
`${fieldName}\\s*\\.\\s*(map|filter|forEach|push|reduce|find|some|every|includes|join|slice|splice|length)\\b`,
|
|
329
|
+
),
|
|
330
|
+
new RegExp(`Array\\.isArray\\s*\\(\\s*${fieldName}\\s*\\)`),
|
|
331
|
+
new RegExp(`for\\s*\\(.*of\\s+${fieldName}\\b`),
|
|
332
|
+
new RegExp(`\\[\\s*\\.\\.\\.${fieldName}\\s*\\]`),
|
|
333
|
+
];
|
|
334
|
+
if (arrayPatterns.some((p) => p.test(content))) return "array";
|
|
335
|
+
|
|
336
|
+
const numberPatterns = [
|
|
337
|
+
new RegExp(`${fieldName}\\s*[+\\-*/%]\\s*\\d`),
|
|
338
|
+
new RegExp(`parseInt\\s*\\(\\s*${fieldName}`),
|
|
339
|
+
new RegExp(`parseFloat\\s*\\(\\s*${fieldName}`),
|
|
340
|
+
new RegExp(`Number\\s*\\(\\s*${fieldName}`),
|
|
341
|
+
];
|
|
342
|
+
if (numberPatterns.some((p) => p.test(content))) return "number";
|
|
343
|
+
|
|
344
|
+
const boolPatterns = [
|
|
345
|
+
new RegExp(`${fieldName}\\s*===?\\s*(true|false)`),
|
|
346
|
+
new RegExp(`(true|false)\\s*===?\\s*${fieldName}`),
|
|
347
|
+
new RegExp(`Boolean\\s*\\(\\s*${fieldName}`),
|
|
348
|
+
];
|
|
349
|
+
if (boolPatterns.some((p) => p.test(content))) return "boolean";
|
|
350
|
+
|
|
351
|
+
return "string";
|
|
352
|
+
}
|
|
353
|
+
|
|
233
354
|
function extractBodyFieldsFromFile(content) {
|
|
234
355
|
const fields = new Set();
|
|
235
356
|
|
|
@@ -284,12 +405,192 @@ function extractBodyFieldsFromFile(content) {
|
|
|
284
405
|
|
|
285
406
|
const properties = {};
|
|
286
407
|
fields.forEach(function (field) {
|
|
287
|
-
|
|
408
|
+
const type = inferFieldType(field, content);
|
|
409
|
+
properties[field] =
|
|
410
|
+
type === "array"
|
|
411
|
+
? { type: "array", items: { type: "object" } }
|
|
412
|
+
: { type };
|
|
288
413
|
});
|
|
414
|
+
console.log(
|
|
415
|
+
`[DEBUG] extractBodyFieldsFromFile found fields: ${JSON.stringify([...fields])}`,
|
|
416
|
+
);
|
|
289
417
|
|
|
290
418
|
return { type: "object", properties };
|
|
291
419
|
}
|
|
292
420
|
|
|
421
|
+
function scanNextJsAppRoutes(appDir) {
|
|
422
|
+
const fs = require("fs");
|
|
423
|
+
const path = require("path");
|
|
424
|
+
const endpoints = [];
|
|
425
|
+
|
|
426
|
+
const apiDir = path.join(appDir, "api");
|
|
427
|
+
if (!fs.existsSync(apiDir)) {
|
|
428
|
+
console.warn("[BotVersion SDK] No app/api directory found at:", apiDir);
|
|
429
|
+
return endpoints;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
function walkDir(dir, routePath) {
|
|
433
|
+
routePath = routePath || "/api";
|
|
434
|
+
const files = fs.readdirSync(dir);
|
|
435
|
+
|
|
436
|
+
files.forEach(function (file) {
|
|
437
|
+
const fullPath = path.join(dir, file);
|
|
438
|
+
const stat = fs.statSync(fullPath);
|
|
439
|
+
|
|
440
|
+
if (stat.isDirectory()) {
|
|
441
|
+
// Convert [param] → :param
|
|
442
|
+
const segment = file.replace(/\[([^\]]+)\]/g, ":$1");
|
|
443
|
+
walkDir(fullPath, routePath + "/" + segment);
|
|
444
|
+
return;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// Only process route.ts / route.js
|
|
448
|
+
if (!/^route\.(js|ts)$/.test(file)) return;
|
|
449
|
+
|
|
450
|
+
const content = fs.readFileSync(fullPath, "utf8");
|
|
451
|
+
const methods = detectAppRouterMethods(content);
|
|
452
|
+
|
|
453
|
+
methods.forEach(function (method) {
|
|
454
|
+
const bodyFields =
|
|
455
|
+
method !== "GET" ? extractAppRouterBodyFields(content) : null;
|
|
456
|
+
const queryFields = extractQueryFieldsFromFile(content);
|
|
457
|
+
|
|
458
|
+
const routeParamMap = buildRouteParamMap(routePath, []);
|
|
459
|
+
|
|
460
|
+
endpoints.push({
|
|
461
|
+
method: method,
|
|
462
|
+
path: routePath,
|
|
463
|
+
description: "",
|
|
464
|
+
requestBody:
|
|
465
|
+
bodyFields ||
|
|
466
|
+
(method === "DELETE" && queryFields ? queryFields : null),
|
|
467
|
+
routeParamMap: routeParamMap,
|
|
468
|
+
detectedBy: "static-scan",
|
|
469
|
+
});
|
|
470
|
+
});
|
|
471
|
+
});
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
walkDir(apiDir);
|
|
475
|
+
return endpoints;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
function detectAppRouterMethods(content) {
|
|
479
|
+
const methods = [];
|
|
480
|
+
const patterns = [
|
|
481
|
+
{ pattern: /export\s+async\s+function\s+GET\b/, method: "GET" },
|
|
482
|
+
{ pattern: /export\s+async\s+function\s+POST\b/, method: "POST" },
|
|
483
|
+
{ pattern: /export\s+async\s+function\s+PUT\b/, method: "PUT" },
|
|
484
|
+
{ pattern: /export\s+async\s+function\s+DELETE\b/, method: "DELETE" },
|
|
485
|
+
{ pattern: /export\s+async\s+function\s+PATCH\b/, method: "PATCH" },
|
|
486
|
+
// named exports too: export { POST }
|
|
487
|
+
{ pattern: /export\s+function\s+GET\b/, method: "GET" },
|
|
488
|
+
{ pattern: /export\s+function\s+POST\b/, method: "POST" },
|
|
489
|
+
{ pattern: /export\s+function\s+PUT\b/, method: "PUT" },
|
|
490
|
+
{ pattern: /export\s+function\s+DELETE\b/, method: "DELETE" },
|
|
491
|
+
{ pattern: /export\s+function\s+PATCH\b/, method: "PATCH" },
|
|
492
|
+
];
|
|
493
|
+
|
|
494
|
+
patterns.forEach(function (p) {
|
|
495
|
+
if (p.pattern.test(content)) methods.push(p.method);
|
|
496
|
+
});
|
|
497
|
+
|
|
498
|
+
return methods.length > 0 ? methods : ["GET"];
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
function extractAppRouterBodyFields(content) {
|
|
502
|
+
const fields = new Set();
|
|
503
|
+
|
|
504
|
+
// Pattern 1: const { userId, tokens } = await request.json()
|
|
505
|
+
const destructureMatches = content.matchAll(
|
|
506
|
+
/const\s*\{([^}]+)\}\s*=\s*await\s+\w+\.json\(\)/g,
|
|
507
|
+
);
|
|
508
|
+
for (const match of destructureMatches) {
|
|
509
|
+
match[1].split(",").forEach(function (f) {
|
|
510
|
+
const clean = f.trim().split(":")[0].trim();
|
|
511
|
+
if (clean) fields.add(clean);
|
|
512
|
+
});
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
// Pattern 2: const body = await request.json() then body.userId
|
|
516
|
+
const bodyVarMatch = content.match(
|
|
517
|
+
/const\s+(\w+)\s*=\s*await\s+\w+\.json\(\)/,
|
|
518
|
+
);
|
|
519
|
+
if (bodyVarMatch) {
|
|
520
|
+
const varName = bodyVarMatch[1];
|
|
521
|
+
const varMatches = content.matchAll(
|
|
522
|
+
new RegExp(`${varName}\\.([a-zA-Z_][a-zA-Z0-9_]*)`, "g"),
|
|
523
|
+
);
|
|
524
|
+
for (const match of varMatches) {
|
|
525
|
+
fields.add(match[1]);
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
// Pattern 3: (await request.json()).userId
|
|
530
|
+
const inlineMatches = content.matchAll(
|
|
531
|
+
/\(await\s+\w+\.json\(\)\)\.([a-zA-Z_][a-zA-Z0-9_]*)/g,
|
|
532
|
+
);
|
|
533
|
+
for (const match of inlineMatches) {
|
|
534
|
+
fields.add(match[1]);
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
if (fields.size === 0) return null;
|
|
538
|
+
|
|
539
|
+
const properties = {};
|
|
540
|
+
fields.forEach(function (field) {
|
|
541
|
+
const type = inferFieldType(field, content);
|
|
542
|
+
properties[field] =
|
|
543
|
+
type === "array"
|
|
544
|
+
? { type: "array", items: { type: "object" } }
|
|
545
|
+
: { type };
|
|
546
|
+
});
|
|
547
|
+
|
|
548
|
+
return { type: "object", properties };
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
function buildRouteParamMap(routePath, segments) {
|
|
552
|
+
const paramMap = {};
|
|
553
|
+
const parts = routePath.split("/").filter(Boolean);
|
|
554
|
+
|
|
555
|
+
for (let i = 0; i < parts.length; i++) {
|
|
556
|
+
const part = parts[i];
|
|
557
|
+
|
|
558
|
+
// Skip catch-all [...slug] and optional [[...slug]]
|
|
559
|
+
if (/^\[?\[\.\.\./.test(part)) continue;
|
|
560
|
+
|
|
561
|
+
// Check if this segment is a dynamic param
|
|
562
|
+
// Handles both Next.js style [id] and Express style :id
|
|
563
|
+
const nextParamMatch = part.match(/^\[([^\]]+)\]$/);
|
|
564
|
+
const expressParamMatch = part.match(/^:([a-zA-Z_][a-zA-Z0-9_]*)$/);
|
|
565
|
+
const paramMatch = nextParamMatch || expressParamMatch;
|
|
566
|
+
if (!paramMatch) continue;
|
|
567
|
+
|
|
568
|
+
const paramName = paramMatch[1];
|
|
569
|
+
|
|
570
|
+
// If param already has a descriptive name like [projectId], use it as-is
|
|
571
|
+
if (paramName !== "id" && paramName !== "slug" && paramName !== "param") {
|
|
572
|
+
paramMap[paramName] = paramName;
|
|
573
|
+
continue;
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
// If param is just [id], look at the parent folder name to figure out type
|
|
577
|
+
// e.g. projects/[id] → projectId
|
|
578
|
+
const parentSegment = parts[i - 1];
|
|
579
|
+
if (parentSegment) {
|
|
580
|
+
// Remove any dynamic brackets from parent if it's also a param
|
|
581
|
+
const cleanParent = parentSegment.replace(/^\[([^\]]+)\]$/, "$1");
|
|
582
|
+
// Singularize simple plural names: projects → project, users → user
|
|
583
|
+
const singular = cleanParent.replace(/ies$/, "y").replace(/s$/, "");
|
|
584
|
+
paramMap[paramName] = singular + "Id";
|
|
585
|
+
} else {
|
|
586
|
+
// No parent segment, just call it "id"
|
|
587
|
+
paramMap[paramName] = "id";
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
return paramMap;
|
|
592
|
+
}
|
|
593
|
+
|
|
293
594
|
function extractQueryFieldsFromFile(content) {
|
|
294
595
|
const fields = new Set();
|
|
295
596
|
|
|
@@ -355,8 +656,472 @@ function regexpToPath(regexp, keys) {
|
|
|
355
656
|
}
|
|
356
657
|
}
|
|
357
658
|
|
|
659
|
+
function scanAllExpressFiles(cwd) {
|
|
660
|
+
const fs = require("fs");
|
|
661
|
+
const path = require("path");
|
|
662
|
+
|
|
663
|
+
const SKIP_DIRS = [
|
|
664
|
+
"node_modules",
|
|
665
|
+
".git",
|
|
666
|
+
".next",
|
|
667
|
+
"dist",
|
|
668
|
+
"build",
|
|
669
|
+
".cache",
|
|
670
|
+
"coverage",
|
|
671
|
+
"out",
|
|
672
|
+
];
|
|
673
|
+
|
|
674
|
+
const routeFiles = [];
|
|
675
|
+
|
|
676
|
+
function walk(dir, depth) {
|
|
677
|
+
if (depth > 4) return;
|
|
678
|
+
|
|
679
|
+
let entries;
|
|
680
|
+
try {
|
|
681
|
+
entries = fs.readdirSync(dir);
|
|
682
|
+
} catch {
|
|
683
|
+
return;
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
for (const entry of entries) {
|
|
687
|
+
if (SKIP_DIRS.includes(entry)) continue;
|
|
688
|
+
|
|
689
|
+
const fullPath = path.join(dir, entry);
|
|
690
|
+
let stat;
|
|
691
|
+
try {
|
|
692
|
+
stat = fs.statSync(fullPath);
|
|
693
|
+
} catch {
|
|
694
|
+
continue;
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
if (stat.isDirectory()) {
|
|
698
|
+
walk(fullPath, depth + 1);
|
|
699
|
+
} else if (/\.(js|ts)$/.test(entry)) {
|
|
700
|
+
try {
|
|
701
|
+
const content = fs.readFileSync(fullPath, "utf8");
|
|
702
|
+
// Check if file contains Express route definitions
|
|
703
|
+
const isExpressFile =
|
|
704
|
+
content.includes("express()") ||
|
|
705
|
+
content.includes("express.Router()") ||
|
|
706
|
+
content.includes("Router()") ||
|
|
707
|
+
// Match only route-like patterns: app.get('/...) or router.post('/...)
|
|
708
|
+
/(?:app|router|server)\.(get|post|put|delete|patch|all)\s*\(\s*['"`]\//.test(
|
|
709
|
+
content,
|
|
710
|
+
);
|
|
711
|
+
|
|
712
|
+
if (isExpressFile) {
|
|
713
|
+
console.log(`[DEBUG] Found route file: ${fullPath}`); // already have this
|
|
714
|
+
routeFiles.push(fullPath);
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
// ADD THIS — log files that are being SKIPPED
|
|
718
|
+
else if (/\.(js|ts)$/.test(entry)) {
|
|
719
|
+
console.log(
|
|
720
|
+
`[DEBUG] Skipped (not detected as route file): ${fullPath}`,
|
|
721
|
+
);
|
|
722
|
+
}
|
|
723
|
+
} catch {
|
|
724
|
+
continue;
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
walk(cwd, 0);
|
|
731
|
+
return routeFiles;
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
function buildBodyMap(cwd) {
|
|
735
|
+
const fs = require("fs");
|
|
736
|
+
const path = require("path");
|
|
737
|
+
const bodyMap = {}; // { functionName: { type: "object", properties: {...} } }
|
|
738
|
+
|
|
739
|
+
const SKIP_DIRS = [
|
|
740
|
+
"node_modules",
|
|
741
|
+
".git",
|
|
742
|
+
".next",
|
|
743
|
+
"dist",
|
|
744
|
+
"build",
|
|
745
|
+
".cache",
|
|
746
|
+
"coverage",
|
|
747
|
+
"out",
|
|
748
|
+
];
|
|
749
|
+
|
|
750
|
+
function walk(dir, depth) {
|
|
751
|
+
if (depth > 4) return;
|
|
752
|
+
let entries;
|
|
753
|
+
try {
|
|
754
|
+
entries = fs.readdirSync(dir);
|
|
755
|
+
} catch {
|
|
756
|
+
return;
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
for (const entry of entries) {
|
|
760
|
+
if (SKIP_DIRS.includes(entry)) continue;
|
|
761
|
+
const fullPath = path.join(dir, entry);
|
|
762
|
+
let stat;
|
|
763
|
+
try {
|
|
764
|
+
stat = fs.statSync(fullPath);
|
|
765
|
+
} catch {
|
|
766
|
+
continue;
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
if (stat.isDirectory()) {
|
|
770
|
+
walk(fullPath, depth + 1);
|
|
771
|
+
continue;
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
if (!/\.(js|ts)$/.test(entry)) continue;
|
|
775
|
+
|
|
776
|
+
let content;
|
|
777
|
+
try {
|
|
778
|
+
content = fs.readFileSync(fullPath, "utf8");
|
|
779
|
+
} catch {
|
|
780
|
+
continue;
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
// Skip files with no req.body at all
|
|
784
|
+
// Split file into individual function chunks more reliably
|
|
785
|
+
// by finding each function and extracting a reasonable chunk after it
|
|
786
|
+
|
|
787
|
+
const fnPatterns = [
|
|
788
|
+
// function loginUser(req, res) {
|
|
789
|
+
/(?:export\s+)?(?:async\s+)?function\s+([a-zA-Z_][a-zA-Z0-9_]*)\s*\([^)]*\)\s*\{/g,
|
|
790
|
+
// const loginUser = async (req, res) => {
|
|
791
|
+
/const\s+([a-zA-Z_][a-zA-Z0-9_]*)\s*=\s*(?:async\s*)?\([^)]*\)\s*=>\s*\{/g,
|
|
792
|
+
// exports.loginUser = async (req, res) => {
|
|
793
|
+
/exports\.([a-zA-Z_][a-zA-Z0-9_]*)\s*=\s*(?:async\s*)?\([^)]*\)\s*=>\s*\{/g,
|
|
794
|
+
// export const loginUser = async (req, res) => {
|
|
795
|
+
/export\s+const\s+([a-zA-Z_][a-zA-Z0-9_]*)\s*=\s*(?:async\s*)?\([^)]*\)\s*=>\s*\{/g,
|
|
796
|
+
];
|
|
797
|
+
|
|
798
|
+
if (content.includes("req.body")) {
|
|
799
|
+
console.log(`[DEBUG] bodyMap: found req.body in ${fullPath}`);
|
|
800
|
+
console.log(
|
|
801
|
+
`[DEBUG] bodyMap: fnNames found: ${JSON.stringify([
|
|
802
|
+
...(() => {
|
|
803
|
+
const names = new Set();
|
|
804
|
+
for (const p of fnPatterns) {
|
|
805
|
+
p.lastIndex = 0;
|
|
806
|
+
let m;
|
|
807
|
+
while ((m = p.exec(content)) !== null) names.add(m[1]);
|
|
808
|
+
}
|
|
809
|
+
return names;
|
|
810
|
+
})(),
|
|
811
|
+
])}`,
|
|
812
|
+
);
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
// Map every function name in this file to the whole file's body fields.
|
|
816
|
+
// Brace-counting is unreliable across JS/TS variations, so we use
|
|
817
|
+
// file-level field extraction here. Per-function accuracy is handled
|
|
818
|
+
// at runtime by Strategy 2 (fn.toString()) in extractRoutes.
|
|
819
|
+
const fileFields = extractBodyFieldsFromFile(content);
|
|
820
|
+
if (fileFields) {
|
|
821
|
+
const fnNames = new Set();
|
|
822
|
+
for (const pattern of fnPatterns) {
|
|
823
|
+
pattern.lastIndex = 0; // reset regex state before each use
|
|
824
|
+
let m;
|
|
825
|
+
while ((m = pattern.exec(content)) !== null) fnNames.add(m[1]);
|
|
826
|
+
}
|
|
827
|
+
for (const name of fnNames) {
|
|
828
|
+
if (!bodyMap[name]) {
|
|
829
|
+
// don't overwrite a more precise entry
|
|
830
|
+
bodyMap[name] = fileFields;
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
walk(cwd, 0);
|
|
838
|
+
console.log(`[DEBUG] bodyMap keys: ${Object.keys(bodyMap)}`);
|
|
839
|
+
return bodyMap;
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
function extractHandlerName(layer) {
|
|
843
|
+
const handlers = layer.route.stack.map((h) => h.handle || h);
|
|
844
|
+
|
|
845
|
+
const SKIP_NAMES = new Set([
|
|
846
|
+
"anonymous",
|
|
847
|
+
"",
|
|
848
|
+
"bound dispatch",
|
|
849
|
+
"middleware",
|
|
850
|
+
"protect",
|
|
851
|
+
"admin",
|
|
852
|
+
"auth",
|
|
853
|
+
"verify",
|
|
854
|
+
"validate",
|
|
855
|
+
"isAuth",
|
|
856
|
+
"isAdmin",
|
|
857
|
+
"checkAuth",
|
|
858
|
+
"authenticate",
|
|
859
|
+
]);
|
|
860
|
+
|
|
861
|
+
// Try from last to first, skip known middleware names
|
|
862
|
+
for (let i = handlers.length - 1; i >= 0; i--) {
|
|
863
|
+
const fn = handlers[i];
|
|
864
|
+
const name = fn.name || "";
|
|
865
|
+
if (name && !SKIP_NAMES.has(name) && !name.startsWith("bound ")) {
|
|
866
|
+
return name;
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
return null;
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
function convertNextJsSegment(segment) {
|
|
873
|
+
// Skip catch-all [...slug] and optional [[...slug]]
|
|
874
|
+
if (/^\[?\[\.\.\./.test(segment)) return null;
|
|
875
|
+
// Convert [projectId] → :projectId (Next.js / Nuxt / SvelteKit)
|
|
876
|
+
if (/^\[([^\]]+)\]$/.test(segment))
|
|
877
|
+
return segment.replace(/^\[([^\]]+)\]$/, ":$1");
|
|
878
|
+
// Convert $projectId → :projectId (Remix)
|
|
879
|
+
if (/^\$([a-zA-Z_][a-zA-Z0-9_]*)$/.test(segment))
|
|
880
|
+
return segment.replace(/^\$/, ":");
|
|
881
|
+
return segment;
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
function extractParamPositions(segments) {
|
|
885
|
+
const paramMap = {};
|
|
886
|
+
segments.forEach(function (segment, index) {
|
|
887
|
+
if (segment && segment.startsWith(":")) {
|
|
888
|
+
const paramName = segment.slice(1);
|
|
889
|
+
paramMap[paramName] = index;
|
|
890
|
+
}
|
|
891
|
+
});
|
|
892
|
+
return paramMap;
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
function scanConfigBasedRoutes(cwd) {
|
|
896
|
+
const fs = require("fs");
|
|
897
|
+
const path = require("path");
|
|
898
|
+
const patterns = [];
|
|
899
|
+
const seen = new Set();
|
|
900
|
+
|
|
901
|
+
// Files to scan for route definitions
|
|
902
|
+
const filesToCheck = [
|
|
903
|
+
// React Router — common file names
|
|
904
|
+
path.join(cwd, "src", "App.jsx"),
|
|
905
|
+
path.join(cwd, "src", "App.tsx"),
|
|
906
|
+
path.join(cwd, "src", "App.js"),
|
|
907
|
+
path.join(cwd, "src", "router.jsx"),
|
|
908
|
+
path.join(cwd, "src", "router.tsx"),
|
|
909
|
+
path.join(cwd, "src", "router.js"),
|
|
910
|
+
path.join(cwd, "src", "routes.jsx"),
|
|
911
|
+
path.join(cwd, "src", "routes.tsx"),
|
|
912
|
+
path.join(cwd, "src", "routes.js"),
|
|
913
|
+
path.join(cwd, "src", "Router.jsx"),
|
|
914
|
+
path.join(cwd, "src", "Router.tsx"),
|
|
915
|
+
// Vue Router
|
|
916
|
+
path.join(cwd, "src", "router", "index.js"),
|
|
917
|
+
path.join(cwd, "src", "router", "index.ts"),
|
|
918
|
+
path.join(cwd, "src", "router.js"),
|
|
919
|
+
path.join(cwd, "src", "router.ts"),
|
|
920
|
+
// Angular
|
|
921
|
+
path.join(cwd, "src", "app", "app-routing.module.ts"),
|
|
922
|
+
path.join(cwd, "src", "app", "app.routes.ts"),
|
|
923
|
+
];
|
|
924
|
+
|
|
925
|
+
for (const filePath of filesToCheck) {
|
|
926
|
+
if (!fs.existsSync(filePath)) continue;
|
|
927
|
+
|
|
928
|
+
let content;
|
|
929
|
+
try {
|
|
930
|
+
content = fs.readFileSync(filePath, "utf8");
|
|
931
|
+
} catch {
|
|
932
|
+
continue;
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
console.log("[BotVersion SDK] Scanning config-based routes in:", filePath);
|
|
936
|
+
|
|
937
|
+
// Pattern 1 — React Router JSX: <Route path="/:projectId/dashboard" />
|
|
938
|
+
const jsxRouteMatches = content.matchAll(
|
|
939
|
+
/<Route[^>]+path=["']([^"']+)["']/g,
|
|
940
|
+
);
|
|
941
|
+
for (const match of jsxRouteMatches) {
|
|
942
|
+
addConfigPattern(match[1], seen, patterns);
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
// Pattern 2 — React Router / Vue Router object: { path: '/:projectId/dashboard' }
|
|
946
|
+
const objectRouteMatches = content.matchAll(/path\s*:\s*["']([^"']+)["']/g);
|
|
947
|
+
for (const match of objectRouteMatches) {
|
|
948
|
+
addConfigPattern(match[1], seen, patterns);
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
// Pattern 3 — Angular: { path: ':projectId/dashboard' }
|
|
952
|
+
const angularRouteMatches = content.matchAll(
|
|
953
|
+
/\{\s*path\s*:\s*["']([^"']+)["']/g,
|
|
954
|
+
);
|
|
955
|
+
for (const match of angularRouteMatches) {
|
|
956
|
+
addConfigPattern(match[1], seen, patterns);
|
|
957
|
+
}
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
return patterns;
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
function addConfigPattern(routePath, seen, patterns) {
|
|
964
|
+
// Skip empty, wildcard and catch-all routes
|
|
965
|
+
if (!routePath || routePath === "*" || routePath === "**") return;
|
|
966
|
+
// Skip routes with no dynamic params
|
|
967
|
+
if (!routePath.includes(":") && !routePath.includes("$")) return;
|
|
968
|
+
|
|
969
|
+
// Normalize — ensure leading slash
|
|
970
|
+
const normalized = routePath.startsWith("/") ? routePath : "/" + routePath;
|
|
971
|
+
|
|
972
|
+
if (seen.has(normalized)) return;
|
|
973
|
+
seen.add(normalized);
|
|
974
|
+
|
|
975
|
+
// Extract params and their positions
|
|
976
|
+
const segments = normalized.split("/").filter(Boolean);
|
|
977
|
+
const paramMap = {};
|
|
978
|
+
segments.forEach(function (segment, index) {
|
|
979
|
+
if (segment.startsWith(":")) {
|
|
980
|
+
paramMap[segment.slice(1)] = index;
|
|
981
|
+
}
|
|
982
|
+
});
|
|
983
|
+
|
|
984
|
+
if (Object.keys(paramMap).length === 0) return;
|
|
985
|
+
|
|
986
|
+
patterns.push({ pattern: normalized, params: paramMap });
|
|
987
|
+
console.log(
|
|
988
|
+
"[BotVersion SDK] Found config-based route pattern:",
|
|
989
|
+
normalized,
|
|
990
|
+
"→",
|
|
991
|
+
paramMap,
|
|
992
|
+
);
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
function scanFrontendRoutes(cwd) {
|
|
996
|
+
const fs = require("fs");
|
|
997
|
+
const path = require("path");
|
|
998
|
+
const patterns = [];
|
|
999
|
+
const seen = new Set();
|
|
1000
|
+
|
|
1001
|
+
const dirsToScan = [
|
|
1002
|
+
// Next.js Pages Router
|
|
1003
|
+
path.join(cwd, "pages"),
|
|
1004
|
+
path.join(cwd, "src", "pages"),
|
|
1005
|
+
// Next.js App Router
|
|
1006
|
+
path.join(cwd, "app"),
|
|
1007
|
+
path.join(cwd, "src", "app"),
|
|
1008
|
+
// Nuxt.js
|
|
1009
|
+
path.join(cwd, "pages"),
|
|
1010
|
+
// SvelteKit
|
|
1011
|
+
path.join(cwd, "src", "routes"),
|
|
1012
|
+
path.join(cwd, "routes"),
|
|
1013
|
+
// Remix
|
|
1014
|
+
path.join(cwd, "app", "routes"),
|
|
1015
|
+
];
|
|
1016
|
+
|
|
1017
|
+
function walkDir(dir, routeSegments) {
|
|
1018
|
+
if (!fs.existsSync(dir)) return;
|
|
1019
|
+
let entries;
|
|
1020
|
+
try {
|
|
1021
|
+
entries = fs.readdirSync(dir);
|
|
1022
|
+
} catch {
|
|
1023
|
+
return;
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
entries.forEach(function (file) {
|
|
1027
|
+
const fullPath = path.join(dir, file);
|
|
1028
|
+
let stat;
|
|
1029
|
+
try {
|
|
1030
|
+
stat = fs.statSync(fullPath);
|
|
1031
|
+
} catch {
|
|
1032
|
+
return;
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
if (stat.isDirectory()) {
|
|
1036
|
+
if (file === "api") return; // skip API routes
|
|
1037
|
+
if (file.startsWith("_")) return; // skip Next.js internals
|
|
1038
|
+
if (/^\(.*\)$/.test(file)) {
|
|
1039
|
+
// route groups like (marketing) — don't add to URL
|
|
1040
|
+
walkDir(fullPath, routeSegments);
|
|
1041
|
+
return;
|
|
1042
|
+
}
|
|
1043
|
+
const segment = convertNextJsSegment(file);
|
|
1044
|
+
if (!segment) return;
|
|
1045
|
+
walkDir(fullPath, routeSegments.concat(segment));
|
|
1046
|
+
return;
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
if (!/\.(js|ts|jsx|tsx|vue|svelte)$/.test(file)) return;
|
|
1050
|
+
if (file.startsWith("_")) return;
|
|
1051
|
+
|
|
1052
|
+
const routeName = file.replace(/\.(js|ts|jsx|tsx|vue|svelte)$/, "");
|
|
1053
|
+
|
|
1054
|
+
// Skip non-page files
|
|
1055
|
+
if (
|
|
1056
|
+
["layout", "loading", "error", "template", "not-found"].includes(
|
|
1057
|
+
routeName,
|
|
1058
|
+
)
|
|
1059
|
+
)
|
|
1060
|
+
return;
|
|
1061
|
+
|
|
1062
|
+
// SvelteKit uses +page.svelte — skip +layout, +error etc.
|
|
1063
|
+
if (routeName.startsWith("+") && routeName !== "+page") return;
|
|
1064
|
+
|
|
1065
|
+
// Remix uses dots as path separators — e.g. $projectId.dashboard.tsx → /:projectId/dashboard
|
|
1066
|
+
const isRemixRoute =
|
|
1067
|
+
routeName.includes(".") && !routeName.startsWith("+");
|
|
1068
|
+
let finalSegments;
|
|
1069
|
+
|
|
1070
|
+
if (isRemixRoute) {
|
|
1071
|
+
// Split by dots and convert each segment
|
|
1072
|
+
const remixSegments = routeName
|
|
1073
|
+
.split(".")
|
|
1074
|
+
.map((s) => convertNextJsSegment(s) || s);
|
|
1075
|
+
finalSegments = routeSegments.concat(remixSegments);
|
|
1076
|
+
} else if (
|
|
1077
|
+
routeName === "index" ||
|
|
1078
|
+
routeName === "page" ||
|
|
1079
|
+
routeName === "+page"
|
|
1080
|
+
) {
|
|
1081
|
+
finalSegments = routeSegments;
|
|
1082
|
+
} else {
|
|
1083
|
+
finalSegments = routeSegments.concat(
|
|
1084
|
+
convertNextJsSegment(routeName) || routeName,
|
|
1085
|
+
);
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
const pattern = "/" + finalSegments.filter(Boolean).join("/");
|
|
1089
|
+
if (seen.has(pattern)) return;
|
|
1090
|
+
seen.add(pattern);
|
|
1091
|
+
|
|
1092
|
+
const paramMap = extractParamPositions(finalSegments);
|
|
1093
|
+
if (Object.keys(paramMap).length === 0) return; // no dynamic params, skip static routes
|
|
1094
|
+
|
|
1095
|
+
patterns.push({ pattern, params: paramMap });
|
|
1096
|
+
console.log(
|
|
1097
|
+
"[BotVersion SDK] Found frontend route pattern:",
|
|
1098
|
+
pattern,
|
|
1099
|
+
"→",
|
|
1100
|
+
paramMap,
|
|
1101
|
+
);
|
|
1102
|
+
});
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
dirsToScan.forEach(function (dir) {
|
|
1106
|
+
if (fs.existsSync(dir)) walkDir(dir, []);
|
|
1107
|
+
});
|
|
1108
|
+
|
|
1109
|
+
// Also scan config-based frameworks (React Router, Vue Router, Angular)
|
|
1110
|
+
const configPatterns = scanConfigBasedRoutes(cwd);
|
|
1111
|
+
configPatterns.forEach(function (p) {
|
|
1112
|
+
if (!seen.has(p.pattern)) {
|
|
1113
|
+
seen.add(p.pattern);
|
|
1114
|
+
patterns.push(p);
|
|
1115
|
+
}
|
|
1116
|
+
});
|
|
1117
|
+
|
|
1118
|
+
return patterns;
|
|
1119
|
+
}
|
|
1120
|
+
|
|
358
1121
|
module.exports = {
|
|
359
1122
|
scanExpressRoutes,
|
|
360
1123
|
scanNextJsRoutes,
|
|
1124
|
+
scanNextJsAppRoutes,
|
|
361
1125
|
extractPathParams,
|
|
1126
|
+
scanFrontendRoutes,
|
|
362
1127
|
};
|