apiforge-analyzer 1.3.1 → 1.3.2

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/cjs/cli.js CHANGED
@@ -27,7 +27,7 @@ const green = (s) => `${gn}${s}${r}`;
27
27
  const yellow = (s) => `${yl}${s}${r}`;
28
28
  const red = (s) => `${rd}${s}${r}`;
29
29
  const white = (s) => `${wh}${s}${r}`;
30
- const VERSION = '1.3.1';
30
+ const VERSION = '1.3.2';
31
31
  // Built-in URLs — users never need to configure these
32
32
  const BACKEND_PROD = 'https://apiforgeapi.brainfogagency.com';
33
33
  const DASHBOARD_PROD = 'https://apiforge.brainfogagency.com/dashboard';
@@ -395,6 +395,158 @@ function hasDockerCompose(cwd) {
395
395
  return ['docker-compose.yml', 'docker-compose.yaml', 'compose.yml', 'compose.yaml']
396
396
  .some(f => (0, fs_1.existsSync)((0, path_1.join)(cwd, f)));
397
397
  }
398
+ /**
399
+ * Find the gateway among the discovered services (by name or by proxy-middleware
400
+ * usage) and parse its prefix → service map, e.g. { auth: '/auth', gold_rates: '/gold-rates' }.
401
+ * Matches the common `'/prefix': process.env.X_SERVICE_URL || ...` gateway route-table
402
+ * pattern (express-gateway / http-proxy-middleware style). Returns {} if no gateway
403
+ * or no recognizable mapping is found — callers should treat that as "no rewrite."
404
+ */
405
+ function detectGatewayPrefixMap(cwd, services) {
406
+ const prefixMap = {};
407
+ for (const svc of services) {
408
+ const svcDir = svc.dir.replace(/^\.\//, '');
409
+ const relEntry = svc.entryFile ?? findEntryInDir((0, path_1.join)(cwd, svcDir));
410
+ if (!relEntry)
411
+ continue;
412
+ let source = '';
413
+ try {
414
+ source = (0, fs_1.readFileSync)((0, path_1.join)(cwd, svcDir, relEntry), 'utf-8');
415
+ }
416
+ catch {
417
+ continue;
418
+ }
419
+ const looksLikeGateway = /gateway/i.test(svc.name) || /createProxyMiddleware|http-proxy-middleware/.test(source);
420
+ if (!looksLikeGateway)
421
+ continue;
422
+ // '/auth': process.env.AUTH_SERVICE_URL || `http://...`
423
+ const re = /['"](\/[a-zA-Z0-9_-]*)['"]\s*:\s*process\.env\.([A-Z][A-Z0-9_]*)/g;
424
+ let m;
425
+ while ((m = re.exec(source))) {
426
+ const prefix = m[1];
427
+ const serviceName = m[2].replace(/_SERVICE_URL$|_URL$/, '').toLowerCase();
428
+ if (!prefixMap[serviceName])
429
+ prefixMap[serviceName] = prefix; // first (primary) mount wins over aliases
430
+ }
431
+ if (Object.keys(prefixMap).length > 0)
432
+ break;
433
+ }
434
+ return prefixMap;
435
+ }
436
+ function withGatewayPrefix(prefix, routePath) {
437
+ if (routePath === '/')
438
+ return prefix;
439
+ return prefix.replace(/\/$/, '') + (routePath.startsWith('/') ? routePath : `/${routePath}`);
440
+ }
441
+ // ─── Postman collection enrichment ─────────────────────────────────────────────
442
+ // Runtime introspection only sees field NAMES (via regex over handler source),
443
+ // never real example values — a Postman collection, when present, already has
444
+ // hand-written example bodies and descriptions. We fold those in as enrichment
445
+ // only: matched by method + path, never adds or removes an endpoint.
446
+ function findFileRecursive(dir, matches, maxDepth, depth = 0) {
447
+ if (depth > maxDepth)
448
+ return null;
449
+ let entries;
450
+ try {
451
+ entries = (0, fs_1.readdirSync)(dir, { withFileTypes: true });
452
+ }
453
+ catch {
454
+ return null;
455
+ }
456
+ for (const e of entries) {
457
+ if (e.isFile() && matches(e.name))
458
+ return (0, path_1.join)(dir, e.name);
459
+ }
460
+ for (const e of entries) {
461
+ if (e.isDirectory() && !['node_modules', '.git', 'dist', 'build', '.next'].includes(e.name)) {
462
+ const found = findFileRecursive((0, path_1.join)(dir, e.name), matches, maxDepth, depth + 1);
463
+ if (found)
464
+ return found;
465
+ }
466
+ }
467
+ return null;
468
+ }
469
+ function findPostmanCollection(cwd) {
470
+ const isCollection = (name) => name.endsWith('.postman_collection.json');
471
+ for (const dir of ['postman', 'docs/postman', '.postman', 'collections']) {
472
+ const dirPath = (0, path_1.join)(cwd, dir);
473
+ if (!(0, fs_1.existsSync)(dirPath))
474
+ continue;
475
+ try {
476
+ const match = (0, fs_1.readdirSync)(dirPath).find(isCollection);
477
+ if (match)
478
+ return (0, path_1.join)(dirPath, match);
479
+ }
480
+ catch { }
481
+ }
482
+ return findFileRecursive(cwd, isCollection, 3);
483
+ }
484
+ /** "{{baseUrl}}/gold-rates/graph/{{userId}}?interval=x" -> "/gold-rates/graph/:userId" */
485
+ function postmanRawToRoutePath(raw) {
486
+ let p = raw.trim();
487
+ p = p.replace(/^\{\{[^}]+\}\}/, '');
488
+ p = p.split('?')[0];
489
+ p = p.replace(/\{\{([a-zA-Z0-9_]+)\}\}/g, ':$1');
490
+ if (!p)
491
+ p = '/';
492
+ if (!p.startsWith('/'))
493
+ p = '/' + p;
494
+ if (p !== '/' && p.endsWith('/'))
495
+ p = p.slice(0, -1);
496
+ return p;
497
+ }
498
+ /** Recursively walk a Postman collection's item tree (folders nest via `item`; requests carry `request`). */
499
+ function walkPostmanItems(items, index) {
500
+ for (const node of items ?? []) {
501
+ if (Array.isArray(node.item))
502
+ walkPostmanItems(node.item, index);
503
+ if (!node.request)
504
+ continue;
505
+ const req = node.request;
506
+ const method = String(req.method || 'GET').toUpperCase();
507
+ const rawUrl = typeof req.url === 'string' ? req.url : req.url?.raw;
508
+ if (!rawUrl)
509
+ continue;
510
+ const key = `${method} ${postmanRawToRoutePath(rawUrl)}`;
511
+ if (index.has(key))
512
+ continue; // first match wins — collection often repeats the same endpoint in a smoke-test folder
513
+ const entry = {};
514
+ if (req.body?.mode === 'raw' && typeof req.body.raw === 'string' && req.body.raw.trim()) {
515
+ entry.bodyExample = req.body.raw.trim();
516
+ }
517
+ if (typeof req.description === 'string' && req.description.trim()) {
518
+ entry.description = req.description.trim();
519
+ }
520
+ if (entry.bodyExample || entry.description)
521
+ index.set(key, entry);
522
+ }
523
+ }
524
+ /** Enrich collected routes in-place with Postman example bodies/descriptions, matched by method+path. Never adds or drops routes. */
525
+ function enrichRoutesFromPostman(cwd, routes) {
526
+ const collectionPath = findPostmanCollection(cwd);
527
+ if (!collectionPath)
528
+ return;
529
+ try {
530
+ const collection = JSON.parse((0, fs_1.readFileSync)(collectionPath, 'utf-8'));
531
+ const index = new Map();
532
+ walkPostmanItems(collection.item, index);
533
+ let enriched = 0;
534
+ for (const route of routes) {
535
+ const match = index.get(`${route.method} ${route.path}`);
536
+ if (!match)
537
+ continue;
538
+ if (match.bodyExample)
539
+ route.bodyExample = match.bodyExample;
540
+ if (match.description && !route.description)
541
+ route.description = match.description;
542
+ enriched++;
543
+ }
544
+ if (enriched > 0) {
545
+ prompts_1.log.info(dim(`Enriched ${enriched} routes with examples from ${(0, path_1.basename)(collectionPath)}`));
546
+ }
547
+ }
548
+ catch { }
549
+ }
398
550
  async function microservicesFlow(config, opts) {
399
551
  const cwd = process.cwd();
400
552
  const allRoutes = [];
@@ -437,6 +589,14 @@ async function microservicesFlow(config, opts) {
437
589
  '',
438
590
  dim('Each service is started individually and its routes are collected.'),
439
591
  ].join('\n'), 'Multi-service analysis');
592
+ // Routes are collected at each service's own internal paths (e.g. auth's /register),
593
+ // not the path clients actually call through the gateway (/auth/register) — Express
594
+ // strips the mount prefix before the service ever sees the request. Rewrite paths
595
+ // here so the docs match what's actually callable.
596
+ const gatewayPrefixes = detectGatewayPrefixMap(cwd, config.services);
597
+ if (Object.keys(gatewayPrefixes).length > 0) {
598
+ prompts_1.log.info(dim(`Gateway prefixes detected: ${Object.entries(gatewayPrefixes).map(([s, p]) => `${s}→${p}`).join(', ')}`));
599
+ }
440
600
  for (const svc of config.services) {
441
601
  const svcSpin = (0, prompts_1.spinner)();
442
602
  svcSpin.start(`${bold(svc.name)} — starting…`);
@@ -465,13 +625,14 @@ async function microservicesFlow(config, opts) {
465
625
  detached: false,
466
626
  });
467
627
  // Capture output so failures are debuggable without re-running the service by hand.
468
- // Bounded to the last ~4000 chars so a chatty/looping service can't blow up memory.
628
+ // Keep the HEAD, not the tail: Node prints "Error: ..." / "Cannot find module ..."
629
+ // first, then unwinds the call stack — a long stack trace must not push that
630
+ // opening line out of the window, so stop growing once capped instead of sliding.
469
631
  let output = '';
470
- const MAX_OUTPUT = 4000;
632
+ const MAX_OUTPUT = 6000;
471
633
  const capture = (chunk) => {
472
- output += chunk.toString();
473
- if (output.length > MAX_OUTPUT)
474
- output = output.slice(-MAX_OUTPUT);
634
+ if (output.length < MAX_OUTPUT)
635
+ output += chunk.toString();
475
636
  };
476
637
  child.stdout?.on('data', capture);
477
638
  child.stderr?.on('data', capture);
@@ -484,7 +645,12 @@ async function microservicesFlow(config, opts) {
484
645
  catch { }
485
646
  tryUnlink((0, path_1.join)(cwd, statusFile));
486
647
  if (routes && routes.length > 0) {
487
- const annotated = routes.map((r) => ({ ...r, service: svc.name }));
648
+ const prefix = gatewayPrefixes[svc.name];
649
+ const annotated = routes.map((r) => ({
650
+ ...r,
651
+ service: svc.name,
652
+ path: prefix ? withGatewayPrefix(prefix, r.path) : r.path,
653
+ }));
488
654
  allRoutes.push(...annotated);
489
655
  serviceMeta.push({ name: svc.name, baseUrl: svc.baseUrl });
490
656
  svcSpin.stop(green(`✓ ${svc.name} — ${routes.length} routes collected`));
@@ -493,7 +659,7 @@ async function microservicesFlow(config, opts) {
493
659
  svcSpin.stop(yellow(`◌ ${svc.name} — no routes collected (timeout or error)`));
494
660
  const trimmed = output.trim();
495
661
  if (trimmed) {
496
- prompts_1.log.error(dim(` ${bold(svc.name)} output:\n` + trimmed.split('\n').slice(-20).map(l => ` ${l}`).join('\n')));
662
+ prompts_1.log.error(dim(` ${bold(svc.name)} output:\n` + trimmed.split('\n').slice(0, 30).map(l => ` ${l}`).join('\n')));
497
663
  }
498
664
  }
499
665
  // Remove injected code
@@ -503,6 +669,7 @@ async function microservicesFlow(config, opts) {
503
669
  // Safety: clean up any remaining injections (e.g. if process was interrupted)
504
670
  for (const f of injectedFiles)
505
671
  removeInjectedCode(f);
672
+ enrichRoutesFromPostman(cwd, allRoutes);
506
673
  if (allRoutes.length === 0) {
507
674
  prompts_1.log.error(red('No routes were collected from any service.'));
508
675
  (0, prompts_1.outro)(red('Analysis failed.'));
@@ -1023,13 +1190,33 @@ function buildSnippet(projectName, apiKey, baseUrl, backendUrl, appVar, isESM, a
1023
1190
  ` upload: true,`,
1024
1191
  ` runOnce: true,`,
1025
1192
  ].join('\n');
1193
+ // Collection mode: some services only mount routes inside an async bootstrap
1194
+ // gated behind a DB/broker connect (which may hang/fail outside Docker).
1195
+ // Poll briefly for app._router to be populated (Express-shaped apps only)
1196
+ // before giving up and collecting whatever is there — bounded well under
1197
+ // the CLI's 30s per-service timeout so a hung connect still surfaces.
1198
+ const runAnalyze = `APIForge.analyze(${appVar}, {\n${opts}\n })`;
1199
+ const call = collectionOpts
1200
+ ? [
1201
+ ` let __apiforgeTries = 0`,
1202
+ ` const __apiforgeTryCollect = () => {`,
1203
+ ` const hasRoutes = !${appVar} || !${appVar}._router`,
1204
+ ` ? true`,
1205
+ ` : Array.isArray(${appVar}._router.stack) && ${appVar}._router.stack.length > 0`,
1206
+ ` if (hasRoutes || __apiforgeTries++ >= 26) {`,
1207
+ ` ${runAnalyze}`,
1208
+ ` } else {`,
1209
+ ` setTimeout(__apiforgeTryCollect, 300)`,
1210
+ ` }`,
1211
+ ` }`,
1212
+ ` __apiforgeTryCollect()`,
1213
+ ].join('\n')
1214
+ : ` ${runAnalyze}`;
1026
1215
  if (isESM) {
1027
1216
  return [
1028
1217
  `// APIForge — https://apiforge.brainfogagency.com`,
1029
1218
  `import('apiforge-analyzer').then(({ default: APIForge }) => {`,
1030
- ` APIForge.analyze(${appVar}, {`,
1031
- opts,
1032
- ` })`,
1219
+ call,
1033
1220
  `}).catch(() => {})`,
1034
1221
  '',
1035
1222
  ].join('\n');
@@ -1038,9 +1225,7 @@ function buildSnippet(projectName, apiKey, baseUrl, backendUrl, appVar, isESM, a
1038
1225
  `// APIForge — https://apiforge.brainfogagency.com`,
1039
1226
  `Promise.resolve().then(() => require('apiforge-analyzer')).then(mod => {`,
1040
1227
  ` const APIForge = mod.default || mod`,
1041
- ` APIForge.analyze(${appVar}, {`,
1042
- opts,
1043
- ` })`,
1228
+ call,
1044
1229
  `}).catch(() => {})`,
1045
1230
  '',
1046
1231
  ].join('\n');
package/dist/esm/cli.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { group, intro, outro, text, select, password, confirm, tasks, note, log, isCancel, cancel, spinner, } from '@clack/prompts';
3
3
  import { readFileSync, existsSync, writeFileSync, unlinkSync, readdirSync } from 'fs';
4
- import { join } from 'path';
4
+ import { join, basename } from 'path';
5
5
  import { spawn } from 'child_process';
6
6
  import axios from 'axios';
7
7
  import { detectArchitectureType } from './arch-detect.js';
@@ -22,7 +22,7 @@ const green = (s) => `${gn}${s}${r}`;
22
22
  const yellow = (s) => `${yl}${s}${r}`;
23
23
  const red = (s) => `${rd}${s}${r}`;
24
24
  const white = (s) => `${wh}${s}${r}`;
25
- const VERSION = '1.3.1';
25
+ const VERSION = '1.3.2';
26
26
  // Built-in URLs — users never need to configure these
27
27
  const BACKEND_PROD = 'https://apiforgeapi.brainfogagency.com';
28
28
  const DASHBOARD_PROD = 'https://apiforge.brainfogagency.com/dashboard';
@@ -390,6 +390,158 @@ function hasDockerCompose(cwd) {
390
390
  return ['docker-compose.yml', 'docker-compose.yaml', 'compose.yml', 'compose.yaml']
391
391
  .some(f => existsSync(join(cwd, f)));
392
392
  }
393
+ /**
394
+ * Find the gateway among the discovered services (by name or by proxy-middleware
395
+ * usage) and parse its prefix → service map, e.g. { auth: '/auth', gold_rates: '/gold-rates' }.
396
+ * Matches the common `'/prefix': process.env.X_SERVICE_URL || ...` gateway route-table
397
+ * pattern (express-gateway / http-proxy-middleware style). Returns {} if no gateway
398
+ * or no recognizable mapping is found — callers should treat that as "no rewrite."
399
+ */
400
+ function detectGatewayPrefixMap(cwd, services) {
401
+ const prefixMap = {};
402
+ for (const svc of services) {
403
+ const svcDir = svc.dir.replace(/^\.\//, '');
404
+ const relEntry = svc.entryFile ?? findEntryInDir(join(cwd, svcDir));
405
+ if (!relEntry)
406
+ continue;
407
+ let source = '';
408
+ try {
409
+ source = readFileSync(join(cwd, svcDir, relEntry), 'utf-8');
410
+ }
411
+ catch {
412
+ continue;
413
+ }
414
+ const looksLikeGateway = /gateway/i.test(svc.name) || /createProxyMiddleware|http-proxy-middleware/.test(source);
415
+ if (!looksLikeGateway)
416
+ continue;
417
+ // '/auth': process.env.AUTH_SERVICE_URL || `http://...`
418
+ const re = /['"](\/[a-zA-Z0-9_-]*)['"]\s*:\s*process\.env\.([A-Z][A-Z0-9_]*)/g;
419
+ let m;
420
+ while ((m = re.exec(source))) {
421
+ const prefix = m[1];
422
+ const serviceName = m[2].replace(/_SERVICE_URL$|_URL$/, '').toLowerCase();
423
+ if (!prefixMap[serviceName])
424
+ prefixMap[serviceName] = prefix; // first (primary) mount wins over aliases
425
+ }
426
+ if (Object.keys(prefixMap).length > 0)
427
+ break;
428
+ }
429
+ return prefixMap;
430
+ }
431
+ function withGatewayPrefix(prefix, routePath) {
432
+ if (routePath === '/')
433
+ return prefix;
434
+ return prefix.replace(/\/$/, '') + (routePath.startsWith('/') ? routePath : `/${routePath}`);
435
+ }
436
+ // ─── Postman collection enrichment ─────────────────────────────────────────────
437
+ // Runtime introspection only sees field NAMES (via regex over handler source),
438
+ // never real example values — a Postman collection, when present, already has
439
+ // hand-written example bodies and descriptions. We fold those in as enrichment
440
+ // only: matched by method + path, never adds or removes an endpoint.
441
+ function findFileRecursive(dir, matches, maxDepth, depth = 0) {
442
+ if (depth > maxDepth)
443
+ return null;
444
+ let entries;
445
+ try {
446
+ entries = readdirSync(dir, { withFileTypes: true });
447
+ }
448
+ catch {
449
+ return null;
450
+ }
451
+ for (const e of entries) {
452
+ if (e.isFile() && matches(e.name))
453
+ return join(dir, e.name);
454
+ }
455
+ for (const e of entries) {
456
+ if (e.isDirectory() && !['node_modules', '.git', 'dist', 'build', '.next'].includes(e.name)) {
457
+ const found = findFileRecursive(join(dir, e.name), matches, maxDepth, depth + 1);
458
+ if (found)
459
+ return found;
460
+ }
461
+ }
462
+ return null;
463
+ }
464
+ function findPostmanCollection(cwd) {
465
+ const isCollection = (name) => name.endsWith('.postman_collection.json');
466
+ for (const dir of ['postman', 'docs/postman', '.postman', 'collections']) {
467
+ const dirPath = join(cwd, dir);
468
+ if (!existsSync(dirPath))
469
+ continue;
470
+ try {
471
+ const match = readdirSync(dirPath).find(isCollection);
472
+ if (match)
473
+ return join(dirPath, match);
474
+ }
475
+ catch { }
476
+ }
477
+ return findFileRecursive(cwd, isCollection, 3);
478
+ }
479
+ /** "{{baseUrl}}/gold-rates/graph/{{userId}}?interval=x" -> "/gold-rates/graph/:userId" */
480
+ function postmanRawToRoutePath(raw) {
481
+ let p = raw.trim();
482
+ p = p.replace(/^\{\{[^}]+\}\}/, '');
483
+ p = p.split('?')[0];
484
+ p = p.replace(/\{\{([a-zA-Z0-9_]+)\}\}/g, ':$1');
485
+ if (!p)
486
+ p = '/';
487
+ if (!p.startsWith('/'))
488
+ p = '/' + p;
489
+ if (p !== '/' && p.endsWith('/'))
490
+ p = p.slice(0, -1);
491
+ return p;
492
+ }
493
+ /** Recursively walk a Postman collection's item tree (folders nest via `item`; requests carry `request`). */
494
+ function walkPostmanItems(items, index) {
495
+ for (const node of items ?? []) {
496
+ if (Array.isArray(node.item))
497
+ walkPostmanItems(node.item, index);
498
+ if (!node.request)
499
+ continue;
500
+ const req = node.request;
501
+ const method = String(req.method || 'GET').toUpperCase();
502
+ const rawUrl = typeof req.url === 'string' ? req.url : req.url?.raw;
503
+ if (!rawUrl)
504
+ continue;
505
+ const key = `${method} ${postmanRawToRoutePath(rawUrl)}`;
506
+ if (index.has(key))
507
+ continue; // first match wins — collection often repeats the same endpoint in a smoke-test folder
508
+ const entry = {};
509
+ if (req.body?.mode === 'raw' && typeof req.body.raw === 'string' && req.body.raw.trim()) {
510
+ entry.bodyExample = req.body.raw.trim();
511
+ }
512
+ if (typeof req.description === 'string' && req.description.trim()) {
513
+ entry.description = req.description.trim();
514
+ }
515
+ if (entry.bodyExample || entry.description)
516
+ index.set(key, entry);
517
+ }
518
+ }
519
+ /** Enrich collected routes in-place with Postman example bodies/descriptions, matched by method+path. Never adds or drops routes. */
520
+ function enrichRoutesFromPostman(cwd, routes) {
521
+ const collectionPath = findPostmanCollection(cwd);
522
+ if (!collectionPath)
523
+ return;
524
+ try {
525
+ const collection = JSON.parse(readFileSync(collectionPath, 'utf-8'));
526
+ const index = new Map();
527
+ walkPostmanItems(collection.item, index);
528
+ let enriched = 0;
529
+ for (const route of routes) {
530
+ const match = index.get(`${route.method} ${route.path}`);
531
+ if (!match)
532
+ continue;
533
+ if (match.bodyExample)
534
+ route.bodyExample = match.bodyExample;
535
+ if (match.description && !route.description)
536
+ route.description = match.description;
537
+ enriched++;
538
+ }
539
+ if (enriched > 0) {
540
+ log.info(dim(`Enriched ${enriched} routes with examples from ${basename(collectionPath)}`));
541
+ }
542
+ }
543
+ catch { }
544
+ }
393
545
  async function microservicesFlow(config, opts) {
394
546
  const cwd = process.cwd();
395
547
  const allRoutes = [];
@@ -432,6 +584,14 @@ async function microservicesFlow(config, opts) {
432
584
  '',
433
585
  dim('Each service is started individually and its routes are collected.'),
434
586
  ].join('\n'), 'Multi-service analysis');
587
+ // Routes are collected at each service's own internal paths (e.g. auth's /register),
588
+ // not the path clients actually call through the gateway (/auth/register) — Express
589
+ // strips the mount prefix before the service ever sees the request. Rewrite paths
590
+ // here so the docs match what's actually callable.
591
+ const gatewayPrefixes = detectGatewayPrefixMap(cwd, config.services);
592
+ if (Object.keys(gatewayPrefixes).length > 0) {
593
+ log.info(dim(`Gateway prefixes detected: ${Object.entries(gatewayPrefixes).map(([s, p]) => `${s}→${p}`).join(', ')}`));
594
+ }
435
595
  for (const svc of config.services) {
436
596
  const svcSpin = spinner();
437
597
  svcSpin.start(`${bold(svc.name)} — starting…`);
@@ -460,13 +620,14 @@ async function microservicesFlow(config, opts) {
460
620
  detached: false,
461
621
  });
462
622
  // Capture output so failures are debuggable without re-running the service by hand.
463
- // Bounded to the last ~4000 chars so a chatty/looping service can't blow up memory.
623
+ // Keep the HEAD, not the tail: Node prints "Error: ..." / "Cannot find module ..."
624
+ // first, then unwinds the call stack — a long stack trace must not push that
625
+ // opening line out of the window, so stop growing once capped instead of sliding.
464
626
  let output = '';
465
- const MAX_OUTPUT = 4000;
627
+ const MAX_OUTPUT = 6000;
466
628
  const capture = (chunk) => {
467
- output += chunk.toString();
468
- if (output.length > MAX_OUTPUT)
469
- output = output.slice(-MAX_OUTPUT);
629
+ if (output.length < MAX_OUTPUT)
630
+ output += chunk.toString();
470
631
  };
471
632
  child.stdout?.on('data', capture);
472
633
  child.stderr?.on('data', capture);
@@ -479,7 +640,12 @@ async function microservicesFlow(config, opts) {
479
640
  catch { }
480
641
  tryUnlink(join(cwd, statusFile));
481
642
  if (routes && routes.length > 0) {
482
- const annotated = routes.map((r) => ({ ...r, service: svc.name }));
643
+ const prefix = gatewayPrefixes[svc.name];
644
+ const annotated = routes.map((r) => ({
645
+ ...r,
646
+ service: svc.name,
647
+ path: prefix ? withGatewayPrefix(prefix, r.path) : r.path,
648
+ }));
483
649
  allRoutes.push(...annotated);
484
650
  serviceMeta.push({ name: svc.name, baseUrl: svc.baseUrl });
485
651
  svcSpin.stop(green(`✓ ${svc.name} — ${routes.length} routes collected`));
@@ -488,7 +654,7 @@ async function microservicesFlow(config, opts) {
488
654
  svcSpin.stop(yellow(`◌ ${svc.name} — no routes collected (timeout or error)`));
489
655
  const trimmed = output.trim();
490
656
  if (trimmed) {
491
- log.error(dim(` ${bold(svc.name)} output:\n` + trimmed.split('\n').slice(-20).map(l => ` ${l}`).join('\n')));
657
+ log.error(dim(` ${bold(svc.name)} output:\n` + trimmed.split('\n').slice(0, 30).map(l => ` ${l}`).join('\n')));
492
658
  }
493
659
  }
494
660
  // Remove injected code
@@ -498,6 +664,7 @@ async function microservicesFlow(config, opts) {
498
664
  // Safety: clean up any remaining injections (e.g. if process was interrupted)
499
665
  for (const f of injectedFiles)
500
666
  removeInjectedCode(f);
667
+ enrichRoutesFromPostman(cwd, allRoutes);
501
668
  if (allRoutes.length === 0) {
502
669
  log.error(red('No routes were collected from any service.'));
503
670
  outro(red('Analysis failed.'));
@@ -1018,13 +1185,33 @@ function buildSnippet(projectName, apiKey, baseUrl, backendUrl, appVar, isESM, a
1018
1185
  ` upload: true,`,
1019
1186
  ` runOnce: true,`,
1020
1187
  ].join('\n');
1188
+ // Collection mode: some services only mount routes inside an async bootstrap
1189
+ // gated behind a DB/broker connect (which may hang/fail outside Docker).
1190
+ // Poll briefly for app._router to be populated (Express-shaped apps only)
1191
+ // before giving up and collecting whatever is there — bounded well under
1192
+ // the CLI's 30s per-service timeout so a hung connect still surfaces.
1193
+ const runAnalyze = `APIForge.analyze(${appVar}, {\n${opts}\n })`;
1194
+ const call = collectionOpts
1195
+ ? [
1196
+ ` let __apiforgeTries = 0`,
1197
+ ` const __apiforgeTryCollect = () => {`,
1198
+ ` const hasRoutes = !${appVar} || !${appVar}._router`,
1199
+ ` ? true`,
1200
+ ` : Array.isArray(${appVar}._router.stack) && ${appVar}._router.stack.length > 0`,
1201
+ ` if (hasRoutes || __apiforgeTries++ >= 26) {`,
1202
+ ` ${runAnalyze}`,
1203
+ ` } else {`,
1204
+ ` setTimeout(__apiforgeTryCollect, 300)`,
1205
+ ` }`,
1206
+ ` }`,
1207
+ ` __apiforgeTryCollect()`,
1208
+ ].join('\n')
1209
+ : ` ${runAnalyze}`;
1021
1210
  if (isESM) {
1022
1211
  return [
1023
1212
  `// APIForge — https://apiforge.brainfogagency.com`,
1024
1213
  `import('apiforge-analyzer').then(({ default: APIForge }) => {`,
1025
- ` APIForge.analyze(${appVar}, {`,
1026
- opts,
1027
- ` })`,
1214
+ call,
1028
1215
  `}).catch(() => {})`,
1029
1216
  '',
1030
1217
  ].join('\n');
@@ -1033,9 +1220,7 @@ function buildSnippet(projectName, apiKey, baseUrl, backendUrl, appVar, isESM, a
1033
1220
  `// APIForge — https://apiforge.brainfogagency.com`,
1034
1221
  `Promise.resolve().then(() => require('apiforge-analyzer')).then(mod => {`,
1035
1222
  ` const APIForge = mod.default || mod`,
1036
- ` APIForge.analyze(${appVar}, {`,
1037
- opts,
1038
- ` })`,
1223
+ call,
1039
1224
  `}).catch(() => {})`,
1040
1225
  '',
1041
1226
  ].join('\n');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "apiforge-analyzer",
3
- "version": "1.3.1",
3
+ "version": "1.3.2",
4
4
  "description": "Universal API documentation generator for Express, Next.js, Vite, React Router, and Node.js",
5
5
  "type": "module",
6
6
  "main": "./dist/cjs/index.js",