apiforge-analyzer 1.3.0 → 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.
@@ -44,6 +44,27 @@ function detectArchitectureType(rootDir) {
44
44
  siblingPackages.forEach(p => servicePaths.push(p));
45
45
  evidence.push(`${siblingPackages.length} sibling package.json files (${siblingPackages.join(', ')})`);
46
46
  }
47
+ // services/, apps/, or microservices/ dir containing 2+ independently-built
48
+ // subdirs (own Dockerfile and/or package.json) — the common Docker-Compose
49
+ // microservices layout, where each service lives one level deeper than the
50
+ // sibling-package check above looks.
51
+ for (const parentDir of ['services', 'apps', 'microservices']) {
52
+ const parentPath = (0, path_1.join)(rootDir, parentDir);
53
+ if (!(0, fs_1.existsSync)(parentPath))
54
+ continue;
55
+ try {
56
+ const subdirs = (0, fs_1.readdirSync)(parentPath, { withFileTypes: true }).filter(d => d.isDirectory());
57
+ const ownedServices = subdirs.filter(d => (0, fs_1.existsSync)((0, path_1.join)(parentPath, d.name, 'Dockerfile')) ||
58
+ (0, fs_1.existsSync)((0, path_1.join)(parentPath, d.name, 'package.json')));
59
+ if (ownedServices.length >= 2) {
60
+ microScore += 45;
61
+ ownedServices.forEach(d => servicePaths.push(`./${parentDir}/${d.name}`));
62
+ evidence.push(`${parentDir}/ has ${ownedServices.length} independently-built services: ${ownedServices.map(d => d.name).join(', ')}`);
63
+ break;
64
+ }
65
+ }
66
+ catch { }
67
+ }
47
68
  // ── Modular Monolithic signals ────────────────────────────────────────────────
48
69
  // NestJS module files anywhere in src/
49
70
  const srcDir = (0, path_1.join)(rootDir, 'src');
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.0';
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';
@@ -391,6 +391,162 @@ function waitForServiceStatusFile(statusFile, timeoutMs) {
391
391
  }, timeoutMs);
392
392
  });
393
393
  }
394
+ function hasDockerCompose(cwd) {
395
+ return ['docker-compose.yml', 'docker-compose.yaml', 'compose.yml', 'compose.yaml']
396
+ .some(f => (0, fs_1.existsSync)((0, path_1.join)(cwd, f)));
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
+ }
394
550
  async function microservicesFlow(config, opts) {
395
551
  const cwd = process.cwd();
396
552
  const allRoutes = [];
@@ -403,6 +559,29 @@ async function microservicesFlow(config, opts) {
403
559
  tryUnlink((0, path_1.join)(cwd, STATUS_FILE));
404
560
  tryUnlink((0, path_1.join)(cwd, LOCK_FILE));
405
561
  persistApiKeyToEnv(opts.apiKey);
562
+ if (hasDockerCompose(cwd)) {
563
+ (0, prompts_1.note)([
564
+ `${yellow('Docker Compose detected — APIForge will NOT start, build, or touch Docker.')}`,
565
+ '',
566
+ dim('Each service below is run directly with node/tsx on this machine, just'),
567
+ dim('long enough to capture its routes, then reverted. Your containers (if'),
568
+ dim('running) are left completely alone — no ports, network, or images are used.'),
569
+ '',
570
+ bold('For this to work:'),
571
+ ` ${dim('·')} ${bold('npm install')} ${dim('has been run at the project root (deps must resolve outside Docker)')}`,
572
+ ` ${dim('·')} a readable ${bold('.env')} ${dim('at the project root (same values Docker passes via env_file)')}`,
573
+ '',
574
+ dim("Routes are captured before RabbitMQ/Redis/DB connections resolve, so those don't need to be reachable."),
575
+ ].join('\n'), 'Docker Compose project');
576
+ const proceed = await (0, prompts_1.confirm)({
577
+ message: 'Run services locally to collect routes?',
578
+ initialValue: true,
579
+ });
580
+ if ((0, prompts_1.isCancel)(proceed) || !proceed) {
581
+ (0, prompts_1.cancel)(dim('Cancelled. Run `npm install` at the project root, then re-run apiforge.'));
582
+ process.exit(0);
583
+ }
584
+ }
406
585
  (0, prompts_1.note)([
407
586
  bold(`Analyzing ${config.services.length} service${config.services.length > 1 ? 's' : ''}…`),
408
587
  '',
@@ -410,6 +589,14 @@ async function microservicesFlow(config, opts) {
410
589
  '',
411
590
  dim('Each service is started individually and its routes are collected.'),
412
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
+ }
413
600
  for (const svc of config.services) {
414
601
  const svcSpin = (0, prompts_1.spinner)();
415
602
  svcSpin.start(`${bold(svc.name)} — starting…`);
@@ -432,11 +619,23 @@ async function microservicesFlow(config, opts) {
432
619
  const startCmd = svc.startCommand ?? serviceNodeCommand(fullEntry);
433
620
  const child = (0, child_process_1.spawn)(startCmd, {
434
621
  cwd,
435
- stdio: 'ignore',
622
+ stdio: ['ignore', 'pipe', 'pipe'],
436
623
  shell: true,
437
624
  env: { ...process.env },
438
625
  detached: false,
439
626
  });
627
+ // Capture output so failures are debuggable without re-running the service by hand.
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.
631
+ let output = '';
632
+ const MAX_OUTPUT = 6000;
633
+ const capture = (chunk) => {
634
+ if (output.length < MAX_OUTPUT)
635
+ output += chunk.toString();
636
+ };
637
+ child.stdout?.on('data', capture);
638
+ child.stderr?.on('data', capture);
440
639
  // Wait up to 30 s for the status file to appear
441
640
  const routes = await waitForServiceStatusFile(statusFile, 30000);
442
641
  // Kill service process
@@ -446,13 +645,22 @@ async function microservicesFlow(config, opts) {
446
645
  catch { }
447
646
  tryUnlink((0, path_1.join)(cwd, statusFile));
448
647
  if (routes && routes.length > 0) {
449
- 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
+ }));
450
654
  allRoutes.push(...annotated);
451
655
  serviceMeta.push({ name: svc.name, baseUrl: svc.baseUrl });
452
656
  svcSpin.stop(green(`✓ ${svc.name} — ${routes.length} routes collected`));
453
657
  }
454
658
  else {
455
659
  svcSpin.stop(yellow(`◌ ${svc.name} — no routes collected (timeout or error)`));
660
+ const trimmed = output.trim();
661
+ if (trimmed) {
662
+ prompts_1.log.error(dim(` ${bold(svc.name)} output:\n` + trimmed.split('\n').slice(0, 30).map(l => ` ${l}`).join('\n')));
663
+ }
456
664
  }
457
665
  // Remove injected code
458
666
  removeInjectedCode(fullEntry);
@@ -461,6 +669,7 @@ async function microservicesFlow(config, opts) {
461
669
  // Safety: clean up any remaining injections (e.g. if process was interrupted)
462
670
  for (const f of injectedFiles)
463
671
  removeInjectedCode(f);
672
+ enrichRoutesFromPostman(cwd, allRoutes);
464
673
  if (allRoutes.length === 0) {
465
674
  prompts_1.log.error(red('No routes were collected from any service.'));
466
675
  (0, prompts_1.outro)(red('Analysis failed.'));
@@ -896,8 +1105,15 @@ function injectCode(filePath, projectName, apiKey, baseUrl, backendUrl, architec
896
1105
  const isESM = detectESM(filePath, content);
897
1106
  const appVar = detectAppVariable(content) ?? 'app';
898
1107
  const snippet = buildSnippet(projectName, apiKey, baseUrl, backendUrl, appVar, isESM, architectureType, collectionOpts);
1108
+ // Collection mode (microservices): append at end of file instead of before
1109
+ // .listen(). Route mounting (app.use(...)) happens synchronously at module
1110
+ // load; .listen() is often gated behind an async bootstrap (DB/broker
1111
+ // connect) that can hang or fail outside its container/network. Appending
1112
+ // at the end runs right after the synchronous portion of the module —
1113
+ // independent of whether that bootstrap ever finishes — so route capture
1114
+ // doesn't require RabbitMQ/Redis/DB to be reachable.
899
1115
  const listenRe = /\b(?:app|server|httpServer|http)\s*\.listen\s*\(/;
900
- const listenMatch = listenRe.exec(content);
1116
+ const listenMatch = collectionOpts ? undefined : listenRe.exec(content);
901
1117
  let newContent;
902
1118
  let injectionPoint;
903
1119
  if (listenMatch?.index !== undefined) {
@@ -974,13 +1190,33 @@ function buildSnippet(projectName, apiKey, baseUrl, backendUrl, appVar, isESM, a
974
1190
  ` upload: true,`,
975
1191
  ` runOnce: true,`,
976
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}`;
977
1215
  if (isESM) {
978
1216
  return [
979
1217
  `// APIForge — https://apiforge.brainfogagency.com`,
980
1218
  `import('apiforge-analyzer').then(({ default: APIForge }) => {`,
981
- ` APIForge.analyze(${appVar}, {`,
982
- opts,
983
- ` })`,
1219
+ call,
984
1220
  `}).catch(() => {})`,
985
1221
  '',
986
1222
  ].join('\n');
@@ -989,9 +1225,7 @@ function buildSnippet(projectName, apiKey, baseUrl, backendUrl, appVar, isESM, a
989
1225
  `// APIForge — https://apiforge.brainfogagency.com`,
990
1226
  `Promise.resolve().then(() => require('apiforge-analyzer')).then(mod => {`,
991
1227
  ` const APIForge = mod.default || mod`,
992
- ` APIForge.analyze(${appVar}, {`,
993
- opts,
994
- ` })`,
1228
+ call,
995
1229
  `}).catch(() => {})`,
996
1230
  '',
997
1231
  ].join('\n');
@@ -41,6 +41,27 @@ export function detectArchitectureType(rootDir) {
41
41
  siblingPackages.forEach(p => servicePaths.push(p));
42
42
  evidence.push(`${siblingPackages.length} sibling package.json files (${siblingPackages.join(', ')})`);
43
43
  }
44
+ // services/, apps/, or microservices/ dir containing 2+ independently-built
45
+ // subdirs (own Dockerfile and/or package.json) — the common Docker-Compose
46
+ // microservices layout, where each service lives one level deeper than the
47
+ // sibling-package check above looks.
48
+ for (const parentDir of ['services', 'apps', 'microservices']) {
49
+ const parentPath = join(rootDir, parentDir);
50
+ if (!existsSync(parentPath))
51
+ continue;
52
+ try {
53
+ const subdirs = readdirSync(parentPath, { withFileTypes: true }).filter(d => d.isDirectory());
54
+ const ownedServices = subdirs.filter(d => existsSync(join(parentPath, d.name, 'Dockerfile')) ||
55
+ existsSync(join(parentPath, d.name, 'package.json')));
56
+ if (ownedServices.length >= 2) {
57
+ microScore += 45;
58
+ ownedServices.forEach(d => servicePaths.push(`./${parentDir}/${d.name}`));
59
+ evidence.push(`${parentDir}/ has ${ownedServices.length} independently-built services: ${ownedServices.map(d => d.name).join(', ')}`);
60
+ break;
61
+ }
62
+ }
63
+ catch { }
64
+ }
44
65
  // ── Modular Monolithic signals ────────────────────────────────────────────────
45
66
  // NestJS module files anywhere in src/
46
67
  const srcDir = join(rootDir, 'src');
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.0';
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';
@@ -386,6 +386,162 @@ function waitForServiceStatusFile(statusFile, timeoutMs) {
386
386
  }, timeoutMs);
387
387
  });
388
388
  }
389
+ function hasDockerCompose(cwd) {
390
+ return ['docker-compose.yml', 'docker-compose.yaml', 'compose.yml', 'compose.yaml']
391
+ .some(f => existsSync(join(cwd, f)));
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
+ }
389
545
  async function microservicesFlow(config, opts) {
390
546
  const cwd = process.cwd();
391
547
  const allRoutes = [];
@@ -398,6 +554,29 @@ async function microservicesFlow(config, opts) {
398
554
  tryUnlink(join(cwd, STATUS_FILE));
399
555
  tryUnlink(join(cwd, LOCK_FILE));
400
556
  persistApiKeyToEnv(opts.apiKey);
557
+ if (hasDockerCompose(cwd)) {
558
+ note([
559
+ `${yellow('Docker Compose detected — APIForge will NOT start, build, or touch Docker.')}`,
560
+ '',
561
+ dim('Each service below is run directly with node/tsx on this machine, just'),
562
+ dim('long enough to capture its routes, then reverted. Your containers (if'),
563
+ dim('running) are left completely alone — no ports, network, or images are used.'),
564
+ '',
565
+ bold('For this to work:'),
566
+ ` ${dim('·')} ${bold('npm install')} ${dim('has been run at the project root (deps must resolve outside Docker)')}`,
567
+ ` ${dim('·')} a readable ${bold('.env')} ${dim('at the project root (same values Docker passes via env_file)')}`,
568
+ '',
569
+ dim("Routes are captured before RabbitMQ/Redis/DB connections resolve, so those don't need to be reachable."),
570
+ ].join('\n'), 'Docker Compose project');
571
+ const proceed = await confirm({
572
+ message: 'Run services locally to collect routes?',
573
+ initialValue: true,
574
+ });
575
+ if (isCancel(proceed) || !proceed) {
576
+ cancel(dim('Cancelled. Run `npm install` at the project root, then re-run apiforge.'));
577
+ process.exit(0);
578
+ }
579
+ }
401
580
  note([
402
581
  bold(`Analyzing ${config.services.length} service${config.services.length > 1 ? 's' : ''}…`),
403
582
  '',
@@ -405,6 +584,14 @@ async function microservicesFlow(config, opts) {
405
584
  '',
406
585
  dim('Each service is started individually and its routes are collected.'),
407
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
+ }
408
595
  for (const svc of config.services) {
409
596
  const svcSpin = spinner();
410
597
  svcSpin.start(`${bold(svc.name)} — starting…`);
@@ -427,11 +614,23 @@ async function microservicesFlow(config, opts) {
427
614
  const startCmd = svc.startCommand ?? serviceNodeCommand(fullEntry);
428
615
  const child = spawn(startCmd, {
429
616
  cwd,
430
- stdio: 'ignore',
617
+ stdio: ['ignore', 'pipe', 'pipe'],
431
618
  shell: true,
432
619
  env: { ...process.env },
433
620
  detached: false,
434
621
  });
622
+ // Capture output so failures are debuggable without re-running the service by hand.
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.
626
+ let output = '';
627
+ const MAX_OUTPUT = 6000;
628
+ const capture = (chunk) => {
629
+ if (output.length < MAX_OUTPUT)
630
+ output += chunk.toString();
631
+ };
632
+ child.stdout?.on('data', capture);
633
+ child.stderr?.on('data', capture);
435
634
  // Wait up to 30 s for the status file to appear
436
635
  const routes = await waitForServiceStatusFile(statusFile, 30000);
437
636
  // Kill service process
@@ -441,13 +640,22 @@ async function microservicesFlow(config, opts) {
441
640
  catch { }
442
641
  tryUnlink(join(cwd, statusFile));
443
642
  if (routes && routes.length > 0) {
444
- 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
+ }));
445
649
  allRoutes.push(...annotated);
446
650
  serviceMeta.push({ name: svc.name, baseUrl: svc.baseUrl });
447
651
  svcSpin.stop(green(`✓ ${svc.name} — ${routes.length} routes collected`));
448
652
  }
449
653
  else {
450
654
  svcSpin.stop(yellow(`◌ ${svc.name} — no routes collected (timeout or error)`));
655
+ const trimmed = output.trim();
656
+ if (trimmed) {
657
+ log.error(dim(` ${bold(svc.name)} output:\n` + trimmed.split('\n').slice(0, 30).map(l => ` ${l}`).join('\n')));
658
+ }
451
659
  }
452
660
  // Remove injected code
453
661
  removeInjectedCode(fullEntry);
@@ -456,6 +664,7 @@ async function microservicesFlow(config, opts) {
456
664
  // Safety: clean up any remaining injections (e.g. if process was interrupted)
457
665
  for (const f of injectedFiles)
458
666
  removeInjectedCode(f);
667
+ enrichRoutesFromPostman(cwd, allRoutes);
459
668
  if (allRoutes.length === 0) {
460
669
  log.error(red('No routes were collected from any service.'));
461
670
  outro(red('Analysis failed.'));
@@ -891,8 +1100,15 @@ function injectCode(filePath, projectName, apiKey, baseUrl, backendUrl, architec
891
1100
  const isESM = detectESM(filePath, content);
892
1101
  const appVar = detectAppVariable(content) ?? 'app';
893
1102
  const snippet = buildSnippet(projectName, apiKey, baseUrl, backendUrl, appVar, isESM, architectureType, collectionOpts);
1103
+ // Collection mode (microservices): append at end of file instead of before
1104
+ // .listen(). Route mounting (app.use(...)) happens synchronously at module
1105
+ // load; .listen() is often gated behind an async bootstrap (DB/broker
1106
+ // connect) that can hang or fail outside its container/network. Appending
1107
+ // at the end runs right after the synchronous portion of the module —
1108
+ // independent of whether that bootstrap ever finishes — so route capture
1109
+ // doesn't require RabbitMQ/Redis/DB to be reachable.
894
1110
  const listenRe = /\b(?:app|server|httpServer|http)\s*\.listen\s*\(/;
895
- const listenMatch = listenRe.exec(content);
1111
+ const listenMatch = collectionOpts ? undefined : listenRe.exec(content);
896
1112
  let newContent;
897
1113
  let injectionPoint;
898
1114
  if (listenMatch?.index !== undefined) {
@@ -969,13 +1185,33 @@ function buildSnippet(projectName, apiKey, baseUrl, backendUrl, appVar, isESM, a
969
1185
  ` upload: true,`,
970
1186
  ` runOnce: true,`,
971
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}`;
972
1210
  if (isESM) {
973
1211
  return [
974
1212
  `// APIForge — https://apiforge.brainfogagency.com`,
975
1213
  `import('apiforge-analyzer').then(({ default: APIForge }) => {`,
976
- ` APIForge.analyze(${appVar}, {`,
977
- opts,
978
- ` })`,
1214
+ call,
979
1215
  `}).catch(() => {})`,
980
1216
  '',
981
1217
  ].join('\n');
@@ -984,9 +1220,7 @@ function buildSnippet(projectName, apiKey, baseUrl, backendUrl, appVar, isESM, a
984
1220
  `// APIForge — https://apiforge.brainfogagency.com`,
985
1221
  `Promise.resolve().then(() => require('apiforge-analyzer')).then(mod => {`,
986
1222
  ` const APIForge = mod.default || mod`,
987
- ` APIForge.analyze(${appVar}, {`,
988
- opts,
989
- ` })`,
1223
+ call,
990
1224
  `}).catch(() => {})`,
991
1225
  '',
992
1226
  ].join('\n');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "apiforge-analyzer",
3
- "version": "1.3.0",
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",