botversion-sdk 1.1.6 → 2.0.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/interceptor.js CHANGED
@@ -3,6 +3,29 @@
3
3
 
4
4
  const reportedEndpoints = new Set();
5
5
 
6
+ function structureToJsonSchema(bodyStructure) {
7
+ if (!bodyStructure) return null;
8
+ return {
9
+ type: "object",
10
+ properties: Object.fromEntries(
11
+ Object.entries(bodyStructure).map(function ([key, typeOrObj]) {
12
+ if (typeOrObj && typeof typeOrObj === "object" && typeOrObj.type) {
13
+ return [key, typeOrObj];
14
+ }
15
+ return [
16
+ key,
17
+ {
18
+ type:
19
+ typeOrObj === "null" || typeOrObj === "[redacted]"
20
+ ? "string"
21
+ : typeOrObj,
22
+ },
23
+ ];
24
+ }),
25
+ ),
26
+ };
27
+ }
28
+
6
29
  /**
7
30
  * Attaches a middleware to the Express app that
8
31
  * silently intercepts every request and reports
@@ -23,6 +46,39 @@ function attachInterceptor(app, client, options) {
23
46
 
24
47
  app.use(function botVersionInterceptor(req, res, next) {
25
48
  const path = req.path || req.url || "";
49
+
50
+ // ── Scan trigger from BotVersion dashboard ───────────────────────────
51
+ if (path === "/__botversion/scan" && req.method === "POST") {
52
+ const providedKey = req.headers["x-botversion-scan-key"] || "";
53
+ if (
54
+ options.scanSecret &&
55
+ providedKey === options.scanSecret &&
56
+ typeof options.onScanRequested === "function"
57
+ ) {
58
+ Promise.resolve(options.onScanRequested())
59
+ .then(function (result) {
60
+ res.statusCode = 200;
61
+ res.setHeader("Content-Type", "application/json");
62
+ res.end(JSON.stringify({ success: true, result: result || null }));
63
+ })
64
+ .catch(function (err) {
65
+ res.statusCode = 500;
66
+ res.setHeader("Content-Type", "application/json");
67
+ res.end(
68
+ JSON.stringify({
69
+ success: false,
70
+ error: String((err && err.message) || err),
71
+ }),
72
+ );
73
+ });
74
+ } else {
75
+ res.statusCode = 401;
76
+ res.setHeader("Content-Type", "application/json");
77
+ res.end(JSON.stringify({ success: false, error: "Unauthorized" }));
78
+ }
79
+ return;
80
+ }
81
+
26
82
  const shouldIgnore = ignorePaths.some(function (p) {
27
83
  return path.startsWith(p);
28
84
  });
@@ -50,24 +106,7 @@ function attachInterceptor(app, client, options) {
50
106
  if (!reportedEndpoints.has(bodyKey)) {
51
107
  reportedEndpoints.add(bodyKey);
52
108
 
53
- const jsonSchema = bodyStructure
54
- ? {
55
- type: "object",
56
- properties: Object.fromEntries(
57
- Object.entries(bodyStructure).map(function ([key, type]) {
58
- return [
59
- key,
60
- {
61
- type:
62
- type === "null" || type === "[redacted]"
63
- ? "string"
64
- : type,
65
- },
66
- ];
67
- }),
68
- ),
69
- }
70
- : null;
109
+ const jsonSchema = structureToJsonSchema(bodyStructure);
71
110
 
72
111
  // Report async — never block the request
73
112
  setImmediate(function () {
@@ -126,6 +165,25 @@ function normalizePath(path) {
126
165
  .join("/");
127
166
  }
128
167
 
168
+ /**
169
+ * Unwraps a single (non-batched) tRPC/superjson envelope.
170
+ * tRPC sends single calls as { json: {...realFields...} }
171
+ * or with superjson as { json: {...realFields...}, meta: {...} }
172
+ */
173
+ function unwrapTrpcJsonEnvelope(obj) {
174
+ if (
175
+ obj &&
176
+ typeof obj === "object" &&
177
+ !Array.isArray(obj) &&
178
+ obj.json &&
179
+ typeof obj.json === "object" &&
180
+ !Array.isArray(obj.json)
181
+ ) {
182
+ return obj.json;
183
+ }
184
+ return obj;
185
+ }
186
+
129
187
  /**
130
188
  * Extract just the structure of a request body
131
189
  * (keys and value types — never actual values for security)
@@ -133,6 +191,37 @@ function normalizePath(path) {
133
191
  function buildBodyStructure(body) {
134
192
  if (!body || typeof body !== "object") return null;
135
193
 
194
+ // Unwrap tRPC envelope
195
+ // Case A: single tRPC call — { json: { ...realFields... } } (optionally with a "meta" sibling)
196
+ // Case B: batched tRPC call — { "0": { json: {...} }, "1": { json: {...} } } or { "0": {...realFields...} }
197
+ // We detect whichever shape is present and flatten it to the real fields before processing
198
+ let keys = Object.keys(body);
199
+
200
+ const isTrpcEnvelope =
201
+ keys.length > 0 &&
202
+ keys.every(function (k) {
203
+ return (
204
+ /^\d+$/.test(k) &&
205
+ body[k] !== null &&
206
+ typeof body[k] === "object" &&
207
+ !Array.isArray(body[k])
208
+ );
209
+ });
210
+
211
+ if (isTrpcEnvelope) {
212
+ const unwrapped = {};
213
+ keys.forEach(function (k) {
214
+ // Each batched entry may itself be a { json: {...} } envelope
215
+ Object.assign(unwrapped, unwrapTrpcJsonEnvelope(body[k]));
216
+ });
217
+ body = unwrapped;
218
+ } else {
219
+ // Not a numeric batch — check for the single { json: {...} } shape
220
+ body = unwrapTrpcJsonEnvelope(body);
221
+ }
222
+
223
+ keys = Object.keys(body);
224
+
136
225
  const structure = {};
137
226
 
138
227
  Object.keys(body).forEach(function (key) {
@@ -163,6 +252,17 @@ function buildBodyStructure(body) {
163
252
  structure[key] = "array";
164
253
  } else if (val === null) {
165
254
  structure[key] = "null";
255
+ } else if (typeof val === "object") {
256
+ const nestedProps = {};
257
+ Object.keys(val).forEach(function (nk) {
258
+ nestedProps[nk] = {
259
+ type: val[nk] === null ? "string" : typeof val[nk],
260
+ };
261
+ });
262
+ structure[key] =
263
+ Object.keys(nestedProps).length > 0
264
+ ? { type: "object", properties: nestedProps }
265
+ : "object";
166
266
  } else {
167
267
  structure[key] = typeof val;
168
268
  }
@@ -181,6 +281,40 @@ function attachNextJsInterceptor(client, options) {
181
281
  const path = req.url ? req.url.split("?")[0] : "";
182
282
  const method = req.method ? req.method.toUpperCase() : "";
183
283
 
284
+ // ── Scan trigger from BotVersion dashboard ───────────────────────
285
+ if (path === "/__botversion/scan" && method === "POST") {
286
+ const providedKey = req.headers["x-botversion-scan-key"] || "";
287
+ if (
288
+ options.scanSecret &&
289
+ providedKey === options.scanSecret &&
290
+ typeof options.onScanRequested === "function"
291
+ ) {
292
+ Promise.resolve(options.onScanRequested())
293
+ .then(function (result) {
294
+ res.statusCode = 200;
295
+ res.setHeader("Content-Type", "application/json");
296
+ res.end(
297
+ JSON.stringify({ success: true, result: result || null }),
298
+ );
299
+ })
300
+ .catch(function (err) {
301
+ res.statusCode = 500;
302
+ res.setHeader("Content-Type", "application/json");
303
+ res.end(
304
+ JSON.stringify({
305
+ success: false,
306
+ error: String((err && err.message) || err),
307
+ }),
308
+ );
309
+ });
310
+ } else {
311
+ res.statusCode = 401;
312
+ res.setHeader("Content-Type", "application/json");
313
+ res.end(JSON.stringify({ success: false, error: "Unauthorized" }));
314
+ }
315
+ return; // handled directly — don't pass through to the app
316
+ }
317
+
184
318
  const shouldIgnore = (options.exclude || [])
185
319
  .concat([
186
320
  "/health",
@@ -220,24 +354,91 @@ function attachNextJsInterceptor(client, options) {
220
354
  // Helper to send body structure to platform
221
355
  function reportBody(bodyObj) {
222
356
  try {
223
- let parsedBody = null;
224
- const structure = buildBodyStructure(bodyObj);
225
- if (structure) {
226
- parsedBody = {
227
- type: "object",
228
- properties: Object.fromEntries(
229
- Object.entries(structure).map(([key, type]) => [
230
- key,
231
- {
232
- type:
233
- type === "null" || type === "[redacted]"
234
- ? "string"
235
- : type,
236
- },
237
- ]),
238
- ),
239
- };
357
+ // ── tRPC GET input extraction ──────────────────────────────────────
358
+ // tRPC GET requests send their input as a URL-encoded JSON query
359
+ // parameter called "input". The body is always empty for GET requests.
360
+ // We only attempt this extraction when:
361
+ // 1. The body is empty (GET requests have no body)
362
+ // 2. The URL contains an "input" query parameter
363
+ // 3. The path looks like a tRPC endpoint (/trpc/)
364
+ // This prevents accidentally treating regular REST query params
365
+ // (e.g. ?filter=active&limit=10) as a body schema.
366
+ const isTrpcPath = path.includes("/trpc/");
367
+ const hasInputParam = req.url && req.url.includes("input=");
368
+
369
+ if (
370
+ isTrpcPath &&
371
+ hasInputParam &&
372
+ (!bodyObj || Object.keys(bodyObj).length === 0)
373
+ ) {
374
+ try {
375
+ // Use URL constructor to safely parse query params
376
+ const urlObj = new URL(req.url, "http://localhost");
377
+ const inputParam = urlObj.searchParams.get("input");
378
+
379
+ if (inputParam) {
380
+ let decoded;
381
+ try {
382
+ decoded = JSON.parse(decodeURIComponent(inputParam));
383
+ } catch (e) {
384
+ // input param exists but is not valid JSON — skip
385
+ decoded = null;
386
+ }
387
+
388
+ if (
389
+ decoded &&
390
+ typeof decoded === "object" &&
391
+ !Array.isArray(decoded)
392
+ ) {
393
+ const keys = Object.keys(decoded);
394
+
395
+ // Format 1 — batched: { "0": { json: {...} }, "1": { json: {...} } }
396
+ // All keys are numeric strings
397
+ const isBatch =
398
+ keys.length > 0 && keys.every((k) => /^\d+$/.test(k));
399
+
400
+ if (isBatch) {
401
+ // Merge all batch entries into one flat object
402
+ // so we capture the full schema across all procedures
403
+ const merged = {};
404
+ keys.forEach((k) => {
405
+ const entry = decoded[k];
406
+ if (entry && typeof entry === "object") {
407
+ // Unwrap { json: {...} } envelope if present
408
+ const unwrapped =
409
+ entry.json && typeof entry.json === "object"
410
+ ? entry.json
411
+ : entry;
412
+ Object.assign(merged, unwrapped);
413
+ }
414
+ });
415
+ if (Object.keys(merged).length > 0) {
416
+ bodyObj = merged;
417
+ }
418
+ }
419
+ // Format 2 — single with superjson: { json: {...}, meta: {...} }
420
+ else if (
421
+ decoded.json &&
422
+ typeof decoded.json === "object"
423
+ ) {
424
+ bodyObj = decoded.json;
425
+ }
426
+ // Format 3 — already unwrapped: { group: {...}, limit: 10 }
427
+ // Only use this if it doesn't look like a tRPC envelope
428
+ else if (!decoded.meta && !decoded.json) {
429
+ bodyObj = decoded;
430
+ }
431
+ }
432
+ }
433
+ } catch (e) {
434
+ // URL parsing failed — fall through to normal body handling
435
+ }
240
436
  }
437
+
438
+ // ── Normal body processing (unchanged) ────────────────────────────
439
+ const parsedBody = structureToJsonSchema(
440
+ buildBodyStructure(bodyObj),
441
+ );
241
442
  client
242
443
  .updateEndpoint({
243
444
  method: method,
@@ -330,6 +531,29 @@ function attachFastifyInterceptor(fastify, client, options) {
330
531
  const path = request.url ? request.url.split("?")[0] : "";
331
532
  const method = request.method ? request.method.toUpperCase() : "";
332
533
 
534
+ // ── Scan trigger from BotVersion dashboard ───────────────────────────
535
+ if (path === "/__botversion/scan" && method === "POST") {
536
+ const providedKey = request.headers["x-botversion-scan-key"] || "";
537
+ if (
538
+ options.scanSecret &&
539
+ providedKey === options.scanSecret &&
540
+ typeof options.onScanRequested === "function"
541
+ ) {
542
+ try {
543
+ const result = await options.onScanRequested();
544
+ reply.code(200).send({ success: true, result: result || null });
545
+ } catch (err) {
546
+ reply.code(500).send({
547
+ success: false,
548
+ error: String((err && err.message) || err),
549
+ });
550
+ }
551
+ } else {
552
+ reply.code(401).send({ success: false, error: "Unauthorized" });
553
+ }
554
+ return reply;
555
+ }
556
+
333
557
  const shouldIgnore = ignorePaths.some(function (p) {
334
558
  return path.startsWith(p);
335
559
  });
@@ -373,19 +597,7 @@ function attachFastifyInterceptor(fastify, client, options) {
373
597
  const bodyStructure = buildBodyStructure(body);
374
598
  if (!bodyStructure) return;
375
599
 
376
- const jsonSchema = {
377
- type: "object",
378
- properties: Object.fromEntries(
379
- Object.entries(bodyStructure).map(function ([key, type]) {
380
- return [
381
- key,
382
- {
383
- type: type === "null" || type === "[redacted]" ? "string" : type,
384
- },
385
- ];
386
- }),
387
- ),
388
- };
600
+ const jsonSchema = structureToJsonSchema(bodyStructure);
389
601
 
390
602
  setImmediate(function () {
391
603
  client
@@ -419,11 +631,37 @@ function attachKoaInterceptor(app, client, options) {
419
631
  // IMPORTANT: This interceptor must be added AFTER koa-bodyparser middleware
420
632
  // so that ctx.request.body is already populated when we read it.
421
633
  app.use(async function botVersionKoaInterceptor(ctx, next) {
422
- await next();
423
-
424
634
  const path = ctx.path || "";
425
635
  const method = ctx.method ? ctx.method.toUpperCase() : "";
426
636
 
637
+ // ── Scan trigger from BotVersion dashboard ───────────────────────────
638
+ if (path === "/__botversion/scan" && method === "POST") {
639
+ const providedKey = ctx.headers["x-botversion-scan-key"] || "";
640
+ if (
641
+ options.scanSecret &&
642
+ providedKey === options.scanSecret &&
643
+ typeof options.onScanRequested === "function"
644
+ ) {
645
+ try {
646
+ const result = await options.onScanRequested();
647
+ ctx.status = 200;
648
+ ctx.body = { success: true, result: result || null };
649
+ } catch (err) {
650
+ ctx.status = 500;
651
+ ctx.body = {
652
+ success: false,
653
+ error: String((err && err.message) || err),
654
+ };
655
+ }
656
+ } else {
657
+ ctx.status = 401;
658
+ ctx.body = { success: false, error: "Unauthorized" };
659
+ }
660
+ return; // handled directly — don't call next()
661
+ }
662
+
663
+ await next();
664
+
427
665
  const shouldIgnore = ignorePaths.some(function (p) {
428
666
  return path.startsWith(p);
429
667
  });
@@ -447,24 +685,7 @@ function attachKoaInterceptor(app, client, options) {
447
685
  if (!reportedEndpoints.has(bodyKey)) {
448
686
  reportedEndpoints.add(bodyKey);
449
687
 
450
- const jsonSchema = bodyStructure
451
- ? {
452
- type: "object",
453
- properties: Object.fromEntries(
454
- Object.entries(bodyStructure).map(function ([key, type]) {
455
- return [
456
- key,
457
- {
458
- type:
459
- type === "null" || type === "[redacted]"
460
- ? "string"
461
- : type,
462
- },
463
- ];
464
- }),
465
- ),
466
- }
467
- : null;
688
+ const jsonSchema = structureToJsonSchema(bodyStructure);
468
689
 
469
690
  setImmediate(function () {
470
691
  client
@@ -500,6 +721,38 @@ function attachHapiInterceptor(server, client, options) {
500
721
  const path = request.path || "";
501
722
  const method = request.method ? request.method.toUpperCase() : "";
502
723
 
724
+ // ── Scan trigger from BotVersion dashboard ───────────────────────────
725
+ if (path === "/__botversion/scan" && method === "POST") {
726
+ const providedKey =
727
+ (request.headers && request.headers["x-botversion-scan-key"]) || "";
728
+ if (
729
+ options.scanSecret &&
730
+ providedKey === options.scanSecret &&
731
+ typeof options.onScanRequested === "function"
732
+ ) {
733
+ return Promise.resolve(options.onScanRequested())
734
+ .then(function (result) {
735
+ return h
736
+ .response({ success: true, result: result || null })
737
+ .code(200)
738
+ .takeover();
739
+ })
740
+ .catch(function (err) {
741
+ return h
742
+ .response({
743
+ success: false,
744
+ error: String((err && err.message) || err),
745
+ })
746
+ .code(500)
747
+ .takeover();
748
+ });
749
+ }
750
+ return h
751
+ .response({ success: false, error: "Unauthorized" })
752
+ .code(401)
753
+ .takeover();
754
+ }
755
+
503
756
  const shouldIgnore = ignorePaths.some(function (p) {
504
757
  return path.startsWith(p);
505
758
  });
@@ -524,24 +777,7 @@ function attachHapiInterceptor(server, client, options) {
524
777
  if (!reportedEndpoints.has(bodyKey)) {
525
778
  reportedEndpoints.add(bodyKey);
526
779
 
527
- const jsonSchema = bodyStructure
528
- ? {
529
- type: "object",
530
- properties: Object.fromEntries(
531
- Object.entries(bodyStructure).map(function ([key, type]) {
532
- return [
533
- key,
534
- {
535
- type:
536
- type === "null" || type === "[redacted]"
537
- ? "string"
538
- : type,
539
- },
540
- ];
541
- }),
542
- ),
543
- }
544
- : null;
780
+ const jsonSchema = structureToJsonSchema(bodyStructure);
545
781
 
546
782
  setImmediate(function () {
547
783
  client
@@ -571,6 +807,8 @@ function attachNestJsInterceptor(client, options) {
571
807
  exclude: (options || {}).exclude || [],
572
808
  apiPrefix: (options || {}).apiPrefix || "/",
573
809
  debug: (options || {}).debug || false,
810
+ scanSecret: (options || {}).scanSecret,
811
+ onScanRequested: (options || {}).onScanRequested,
574
812
  });
575
813
  }
576
814
 
@@ -583,6 +821,8 @@ function attachSvelteKitInterceptor(client, options) {
583
821
  exclude: (options || {}).exclude || [],
584
822
  apiPrefix: (options || {}).apiPrefix || "/",
585
823
  debug: (options || {}).debug || false,
824
+ scanSecret: (options || {}).scanSecret,
825
+ onScanRequested: (options || {}).onScanRequested,
586
826
  });
587
827
  }
588
828
 
@@ -591,6 +831,8 @@ function attachNuxtInterceptor(client, options) {
591
831
  exclude: (options || {}).exclude || [],
592
832
  apiPrefix: (options || {}).apiPrefix || "/api",
593
833
  debug: (options || {}).debug || false,
834
+ scanSecret: (options || {}).scanSecret,
835
+ onScanRequested: (options || {}).onScanRequested,
594
836
  });
595
837
  }
596
838
 
@@ -599,6 +841,8 @@ function attachRemixInterceptor(client, options) {
599
841
  exclude: (options || {}).exclude || [],
600
842
  apiPrefix: (options || {}).apiPrefix || "/",
601
843
  debug: (options || {}).debug || false,
844
+ scanSecret: (options || {}).scanSecret,
845
+ onScanRequested: (options || {}).onScanRequested,
602
846
  });
603
847
  }
604
848
 
@@ -607,6 +851,8 @@ function attachAdonisInterceptor(client, options) {
607
851
  exclude: (options || {}).exclude || [],
608
852
  apiPrefix: (options || {}).apiPrefix || "/",
609
853
  debug: (options || {}).debug || false,
854
+ scanSecret: (options || {}).scanSecret,
855
+ onScanRequested: (options || {}).onScanRequested,
610
856
  });
611
857
  }
612
858
 
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "botversion-sdk",
3
- "version": "1.1.6",
3
+ "version": "2.0.0",
4
4
  "description": "BotVersion SDK — auto-detect and register your API endpoints",
5
5
  "main": "index.js",
6
6
  "bin": {
7
- "botversion-sdk": "bin/init.js"
7
+ "botversion-scan-frontend": "./bin/scan-frontend.js"
8
8
  },
9
9
  "keywords": [
10
10
  "botversion",
package/scanner.js CHANGED
@@ -1146,112 +1146,13 @@ function findAllFrontendDirs(cwd) {
1146
1146
  }
1147
1147
  }
1148
1148
 
1149
- // ── Step 2: Walk up exactly 1 level to find sibling folders ─────────
1150
- // Handles the case where SDK is installed in backend/ but
1151
- // frontend/ is a sibling folder at the same level
1152
- const parentDir = path.dirname(cwd);
1153
- if (parentDir !== cwd) {
1154
- const UNSAFE_PARENTS = new Set([
1155
- "Desktop",
1156
- "Documents",
1157
- "Downloads",
1158
- "Pictures",
1159
- "Videos",
1160
- "Music",
1161
- "home",
1162
- "users",
1163
- "Users",
1164
- "var",
1165
- "www",
1166
- "srv",
1167
- "opt",
1168
- "tmp",
1169
- "workspace",
1170
- "Workspace",
1171
- "projects",
1172
- "Projects",
1173
- "code",
1174
- "Code",
1175
- "sites",
1176
- "Sites",
1177
- "dev",
1178
- "Dev",
1179
- "work",
1180
- "Work",
1181
- ]);
1182
-
1183
- if (UNSAFE_PARENTS.has(path.basename(parentDir))) {
1184
- if (found.length === 0) found.push(cwd);
1185
- return found;
1186
- }
1187
- // Safety check — only scan siblings if the parent folder
1188
- // looks like a project root (has package.json OR common project folders)
1189
- // This prevents scanning unrelated projects on the Desktop
1190
- const parentHasPackageJson = fs.existsSync(
1191
- path.join(parentDir, "package.json"),
1192
- );
1193
- const parentHasCommonProjectFiles =
1194
- fs.existsSync(path.join(parentDir, "docker-compose.yml")) ||
1195
- fs.existsSync(path.join(parentDir, "docker-compose.yaml")) ||
1196
- fs.existsSync(path.join(parentDir, ".env")) ||
1197
- fs.existsSync(path.join(parentDir, "turbo.json")) ||
1198
- fs.existsSync(path.join(parentDir, "pnpm-workspace.yaml"));
1199
-
1200
- // If parent has no signs of being a project root, skip sibling scanning
1201
- if (!parentHasPackageJson && !parentHasCommonProjectFiles) {
1202
- // skip — parent is probably just a random folder like Desktop
1203
- } else {
1204
- let parentEntries;
1205
- try {
1206
- parentEntries = fs.readdirSync(parentDir);
1207
- } catch {
1208
- parentEntries = [];
1209
- }
1210
-
1211
- for (const entry of parentEntries) {
1212
- if (SKIP_DIRS.has(entry)) continue;
1213
-
1214
- const sibling = path.join(parentDir, entry);
1215
- if (sibling === cwd) continue; // skip cwd itself
1216
-
1217
- let stat;
1218
- try {
1219
- stat = fs.statSync(sibling);
1220
- } catch {
1221
- continue;
1222
- }
1223
- if (!stat.isDirectory()) continue;
1224
-
1225
- // Only add if it actually looks like a frontend project
1226
- if (isFrontendDir(sibling) && !found.includes(sibling)) {
1227
- found.push(sibling);
1228
- }
1229
-
1230
- // Also check one level inside the sibling
1231
- let siblingEntries;
1232
- try {
1233
- siblingEntries = fs.readdirSync(sibling);
1234
- } catch {
1235
- continue;
1236
- }
1237
-
1238
- for (const siblingEntry of siblingEntries) {
1239
- if (SKIP_DIRS.has(siblingEntry)) continue;
1240
- const siblingChild = path.join(sibling, siblingEntry);
1241
- let siblingChildStat;
1242
- try {
1243
- siblingChildStat = fs.statSync(siblingChild);
1244
- } catch {
1245
- continue;
1246
- }
1247
- if (!siblingChildStat.isDirectory()) continue;
1248
- if (isFrontendDir(siblingChild) && !found.includes(siblingChild)) {
1249
- found.push(siblingChild);
1250
- }
1251
- }
1252
- }
1253
- }
1254
- }
1149
+ // Note: we intentionally do NOT walk up to sibling folders (e.g. a
1150
+ // separate frontend/ folder next to backend/) anymore. That only worked
1151
+ // in local dev monorepos where both folders live on the same disk. In
1152
+ // production, frontend and backend are frequently on completely separate
1153
+ // servers, so there is nothing to find outside cwd. If this install has
1154
+ // no frontend, classification (in index.js) reports that to the
1155
+ // dashboard instead of guessing at a folder that may not exist here.
1255
1156
 
1256
1157
  // If nothing found, fall back to cwd
1257
1158
  if (found.length === 0) {