apiforge-analyzer 1.3.0 → 1.3.1

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.1';
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,10 @@ 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
+ }
394
398
  async function microservicesFlow(config, opts) {
395
399
  const cwd = process.cwd();
396
400
  const allRoutes = [];
@@ -403,6 +407,29 @@ async function microservicesFlow(config, opts) {
403
407
  tryUnlink((0, path_1.join)(cwd, STATUS_FILE));
404
408
  tryUnlink((0, path_1.join)(cwd, LOCK_FILE));
405
409
  persistApiKeyToEnv(opts.apiKey);
410
+ if (hasDockerCompose(cwd)) {
411
+ (0, prompts_1.note)([
412
+ `${yellow('Docker Compose detected — APIForge will NOT start, build, or touch Docker.')}`,
413
+ '',
414
+ dim('Each service below is run directly with node/tsx on this machine, just'),
415
+ dim('long enough to capture its routes, then reverted. Your containers (if'),
416
+ dim('running) are left completely alone — no ports, network, or images are used.'),
417
+ '',
418
+ bold('For this to work:'),
419
+ ` ${dim('·')} ${bold('npm install')} ${dim('has been run at the project root (deps must resolve outside Docker)')}`,
420
+ ` ${dim('·')} a readable ${bold('.env')} ${dim('at the project root (same values Docker passes via env_file)')}`,
421
+ '',
422
+ dim("Routes are captured before RabbitMQ/Redis/DB connections resolve, so those don't need to be reachable."),
423
+ ].join('\n'), 'Docker Compose project');
424
+ const proceed = await (0, prompts_1.confirm)({
425
+ message: 'Run services locally to collect routes?',
426
+ initialValue: true,
427
+ });
428
+ if ((0, prompts_1.isCancel)(proceed) || !proceed) {
429
+ (0, prompts_1.cancel)(dim('Cancelled. Run `npm install` at the project root, then re-run apiforge.'));
430
+ process.exit(0);
431
+ }
432
+ }
406
433
  (0, prompts_1.note)([
407
434
  bold(`Analyzing ${config.services.length} service${config.services.length > 1 ? 's' : ''}…`),
408
435
  '',
@@ -432,11 +459,22 @@ async function microservicesFlow(config, opts) {
432
459
  const startCmd = svc.startCommand ?? serviceNodeCommand(fullEntry);
433
460
  const child = (0, child_process_1.spawn)(startCmd, {
434
461
  cwd,
435
- stdio: 'ignore',
462
+ stdio: ['ignore', 'pipe', 'pipe'],
436
463
  shell: true,
437
464
  env: { ...process.env },
438
465
  detached: false,
439
466
  });
467
+ // 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.
469
+ let output = '';
470
+ const MAX_OUTPUT = 4000;
471
+ const capture = (chunk) => {
472
+ output += chunk.toString();
473
+ if (output.length > MAX_OUTPUT)
474
+ output = output.slice(-MAX_OUTPUT);
475
+ };
476
+ child.stdout?.on('data', capture);
477
+ child.stderr?.on('data', capture);
440
478
  // Wait up to 30 s for the status file to appear
441
479
  const routes = await waitForServiceStatusFile(statusFile, 30000);
442
480
  // Kill service process
@@ -453,6 +491,10 @@ async function microservicesFlow(config, opts) {
453
491
  }
454
492
  else {
455
493
  svcSpin.stop(yellow(`◌ ${svc.name} — no routes collected (timeout or error)`));
494
+ const trimmed = output.trim();
495
+ if (trimmed) {
496
+ prompts_1.log.error(dim(` ${bold(svc.name)} output:\n` + trimmed.split('\n').slice(-20).map(l => ` ${l}`).join('\n')));
497
+ }
456
498
  }
457
499
  // Remove injected code
458
500
  removeInjectedCode(fullEntry);
@@ -896,8 +938,15 @@ function injectCode(filePath, projectName, apiKey, baseUrl, backendUrl, architec
896
938
  const isESM = detectESM(filePath, content);
897
939
  const appVar = detectAppVariable(content) ?? 'app';
898
940
  const snippet = buildSnippet(projectName, apiKey, baseUrl, backendUrl, appVar, isESM, architectureType, collectionOpts);
941
+ // Collection mode (microservices): append at end of file instead of before
942
+ // .listen(). Route mounting (app.use(...)) happens synchronously at module
943
+ // load; .listen() is often gated behind an async bootstrap (DB/broker
944
+ // connect) that can hang or fail outside its container/network. Appending
945
+ // at the end runs right after the synchronous portion of the module —
946
+ // independent of whether that bootstrap ever finishes — so route capture
947
+ // doesn't require RabbitMQ/Redis/DB to be reachable.
899
948
  const listenRe = /\b(?:app|server|httpServer|http)\s*\.listen\s*\(/;
900
- const listenMatch = listenRe.exec(content);
949
+ const listenMatch = collectionOpts ? undefined : listenRe.exec(content);
901
950
  let newContent;
902
951
  let injectionPoint;
903
952
  if (listenMatch?.index !== undefined) {
@@ -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
@@ -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.1';
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,10 @@ 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
+ }
389
393
  async function microservicesFlow(config, opts) {
390
394
  const cwd = process.cwd();
391
395
  const allRoutes = [];
@@ -398,6 +402,29 @@ async function microservicesFlow(config, opts) {
398
402
  tryUnlink(join(cwd, STATUS_FILE));
399
403
  tryUnlink(join(cwd, LOCK_FILE));
400
404
  persistApiKeyToEnv(opts.apiKey);
405
+ if (hasDockerCompose(cwd)) {
406
+ note([
407
+ `${yellow('Docker Compose detected — APIForge will NOT start, build, or touch Docker.')}`,
408
+ '',
409
+ dim('Each service below is run directly with node/tsx on this machine, just'),
410
+ dim('long enough to capture its routes, then reverted. Your containers (if'),
411
+ dim('running) are left completely alone — no ports, network, or images are used.'),
412
+ '',
413
+ bold('For this to work:'),
414
+ ` ${dim('·')} ${bold('npm install')} ${dim('has been run at the project root (deps must resolve outside Docker)')}`,
415
+ ` ${dim('·')} a readable ${bold('.env')} ${dim('at the project root (same values Docker passes via env_file)')}`,
416
+ '',
417
+ dim("Routes are captured before RabbitMQ/Redis/DB connections resolve, so those don't need to be reachable."),
418
+ ].join('\n'), 'Docker Compose project');
419
+ const proceed = await confirm({
420
+ message: 'Run services locally to collect routes?',
421
+ initialValue: true,
422
+ });
423
+ if (isCancel(proceed) || !proceed) {
424
+ cancel(dim('Cancelled. Run `npm install` at the project root, then re-run apiforge.'));
425
+ process.exit(0);
426
+ }
427
+ }
401
428
  note([
402
429
  bold(`Analyzing ${config.services.length} service${config.services.length > 1 ? 's' : ''}…`),
403
430
  '',
@@ -427,11 +454,22 @@ async function microservicesFlow(config, opts) {
427
454
  const startCmd = svc.startCommand ?? serviceNodeCommand(fullEntry);
428
455
  const child = spawn(startCmd, {
429
456
  cwd,
430
- stdio: 'ignore',
457
+ stdio: ['ignore', 'pipe', 'pipe'],
431
458
  shell: true,
432
459
  env: { ...process.env },
433
460
  detached: false,
434
461
  });
462
+ // 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.
464
+ let output = '';
465
+ const MAX_OUTPUT = 4000;
466
+ const capture = (chunk) => {
467
+ output += chunk.toString();
468
+ if (output.length > MAX_OUTPUT)
469
+ output = output.slice(-MAX_OUTPUT);
470
+ };
471
+ child.stdout?.on('data', capture);
472
+ child.stderr?.on('data', capture);
435
473
  // Wait up to 30 s for the status file to appear
436
474
  const routes = await waitForServiceStatusFile(statusFile, 30000);
437
475
  // Kill service process
@@ -448,6 +486,10 @@ async function microservicesFlow(config, opts) {
448
486
  }
449
487
  else {
450
488
  svcSpin.stop(yellow(`◌ ${svc.name} — no routes collected (timeout or error)`));
489
+ const trimmed = output.trim();
490
+ if (trimmed) {
491
+ log.error(dim(` ${bold(svc.name)} output:\n` + trimmed.split('\n').slice(-20).map(l => ` ${l}`).join('\n')));
492
+ }
451
493
  }
452
494
  // Remove injected code
453
495
  removeInjectedCode(fullEntry);
@@ -891,8 +933,15 @@ function injectCode(filePath, projectName, apiKey, baseUrl, backendUrl, architec
891
933
  const isESM = detectESM(filePath, content);
892
934
  const appVar = detectAppVariable(content) ?? 'app';
893
935
  const snippet = buildSnippet(projectName, apiKey, baseUrl, backendUrl, appVar, isESM, architectureType, collectionOpts);
936
+ // Collection mode (microservices): append at end of file instead of before
937
+ // .listen(). Route mounting (app.use(...)) happens synchronously at module
938
+ // load; .listen() is often gated behind an async bootstrap (DB/broker
939
+ // connect) that can hang or fail outside its container/network. Appending
940
+ // at the end runs right after the synchronous portion of the module —
941
+ // independent of whether that bootstrap ever finishes — so route capture
942
+ // doesn't require RabbitMQ/Redis/DB to be reachable.
894
943
  const listenRe = /\b(?:app|server|httpServer|http)\s*\.listen\s*\(/;
895
- const listenMatch = listenRe.exec(content);
944
+ const listenMatch = collectionOpts ? undefined : listenRe.exec(content);
896
945
  let newContent;
897
946
  let injectionPoint;
898
947
  if (listenMatch?.index !== undefined) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "apiforge-analyzer",
3
- "version": "1.3.0",
3
+ "version": "1.3.1",
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",