apiforge-analyzer 1.2.1 → 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.
package/dist/cjs/cli.js CHANGED
@@ -9,6 +9,8 @@ const fs_1 = require("fs");
9
9
  const path_1 = require("path");
10
10
  const child_process_1 = require("child_process");
11
11
  const axios_1 = __importDefault(require("axios"));
12
+ const arch_detect_js_1 = require("./arch-detect.js");
13
+ const uploader_js_1 = require("./uploader.js");
12
14
  // ─── ANSI colour helpers ──────────────────────────────────────────────────────
13
15
  const r = '\x1b[0m';
14
16
  const b = '\x1b[1m';
@@ -25,7 +27,7 @@ const green = (s) => `${gn}${s}${r}`;
25
27
  const yellow = (s) => `${yl}${s}${r}`;
26
28
  const red = (s) => `${rd}${s}${r}`;
27
29
  const white = (s) => `${wh}${s}${r}`;
28
- const VERSION = '1.2.0';
30
+ const VERSION = '1.3.1';
29
31
  // Built-in URLs — users never need to configure these
30
32
  const BACKEND_PROD = 'https://apiforgeapi.brainfogagency.com';
31
33
  const DASHBOARD_PROD = 'https://apiforge.brainfogagency.com/dashboard';
@@ -85,25 +87,6 @@ async function main() {
85
87
  if ((0, prompts_1.isCancel)(answers))
86
88
  process.exit(0);
87
89
  const { projectName, apiKey, baseUrl } = answers;
88
- // ── Select run command ────────────────────────────────────────────────────
89
- const scripts = detectScripts();
90
- const scriptOptions = scripts.map(s => ({
91
- value: s.command,
92
- label: `${bold(s.name)} ${dim('→')} ${dim(s.command)}`,
93
- }));
94
- if (scriptOptions.length === 0) {
95
- prompts_1.log.warn('No scripts found in package.json — please ensure you have a start/dev script.');
96
- (0, prompts_1.cancel)('No run script available.');
97
- process.exit(1);
98
- }
99
- const runCommand = await (0, prompts_1.select)({
100
- message: 'Which command starts your server?',
101
- options: scriptOptions,
102
- });
103
- if ((0, prompts_1.isCancel)(runCommand)) {
104
- (0, prompts_1.cancel)('Cancelled.');
105
- process.exit(0);
106
- }
107
90
  // ── Select backend ────────────────────────────────────────────────────────
108
91
  const backendChoice = await (0, prompts_1.select)({
109
92
  message: 'Send routes to',
@@ -127,13 +110,72 @@ async function main() {
127
110
  const isLocal = backendChoice === 'local';
128
111
  const backendUrl = isLocal ? BACKEND_LOCAL : BACKEND_PROD;
129
112
  const dashboardUrl = isLocal ? DASHBOARD_LOCAL : DASHBOARD_PROD;
113
+ // ── Detect architecture type ─────────────────────────────────────────────
114
+ const archResult = (0, arch_detect_js_1.detectArchitectureType)(process.cwd());
115
+ const archLabel = {
116
+ 'monolithic': cyan('Monolithic'),
117
+ 'modular-monolithic': cyan('Modular Monolithic'),
118
+ 'microservices': cyan('Microservices'),
119
+ };
120
+ // ── Full multi-service mode when apiforge.config.json exists ─────────────
121
+ if (archResult.type === 'microservices') {
122
+ let microConfig = loadMicroConfig();
123
+ // Auto-create apiforge.config.json if not present
124
+ if (!microConfig) {
125
+ microConfig = await buildAndSaveConfig(projectName);
126
+ }
127
+ if (microConfig && microConfig.services.length > 0) {
128
+ prompts_1.log.info(dim('─────────────────────────────────────────────────'));
129
+ prompts_1.log.info(`${dim('Project:')} ${bold(projectName)}`);
130
+ prompts_1.log.info(`${dim('Backend:')} ${cyan(backendUrl)}`);
131
+ prompts_1.log.info(`${dim('Architecture:')} ${archLabel['microservices']}`);
132
+ prompts_1.log.info(`${dim('Services:')} ${bold(String(microConfig.services.length))} ${dim('(from apiforge.config.json)')}`);
133
+ prompts_1.log.info(dim('─────────────────────────────────────────────────'));
134
+ await microservicesFlow(microConfig, { projectName, apiKey, baseUrl, backendUrl, dashboardUrl });
135
+ return;
136
+ }
137
+ }
138
+ // ── Single-service: ask for run command ───────────────────────────────────
139
+ const scripts = detectScripts();
140
+ const scriptOptions = scripts.map(s => ({
141
+ value: s.command,
142
+ label: `${bold(s.name)} ${dim('→')} ${dim(s.command)}`,
143
+ }));
144
+ if (scriptOptions.length === 0) {
145
+ prompts_1.log.warn('No scripts found in package.json — please ensure you have a start/dev script.');
146
+ (0, prompts_1.cancel)('No run script available.');
147
+ process.exit(1);
148
+ }
149
+ const runCommand = await (0, prompts_1.select)({
150
+ message: 'Which command starts your server?',
151
+ options: scriptOptions,
152
+ });
153
+ if ((0, prompts_1.isCancel)(runCommand)) {
154
+ (0, prompts_1.cancel)('Cancelled.');
155
+ process.exit(0);
156
+ }
157
+ if (archResult.type === 'microservices') {
158
+ (0, prompts_1.note)([
159
+ `${yellow('Microservices architecture detected.')}`,
160
+ '',
161
+ `${dim('Add an')} ${bold('apiforge.config.json')} ${dim('to document all services automatically:')}`,
162
+ '',
163
+ ` ${cyan('{ "services": [{ "name": "auth", "dir": "./services/auth" }] }')}`,
164
+ '',
165
+ `${dim('For now, analyzing the single service started by the selected command.')}`,
166
+ ].join('\n'), 'Multi-service tip');
167
+ }
130
168
  prompts_1.log.info(dim('─────────────────────────────────────────────────'));
131
- prompts_1.log.info(`${dim('Project:')} ${bold(projectName)}`);
132
- prompts_1.log.info(`${dim('Command:')} ${bold(runCommand)}`);
133
- prompts_1.log.info(`${dim('Backend:')} ${cyan(backendUrl)}`);
169
+ prompts_1.log.info(`${dim('Project:')} ${bold(projectName)}`);
170
+ prompts_1.log.info(`${dim('Command:')} ${bold(runCommand)}`);
171
+ prompts_1.log.info(`${dim('Backend:')} ${cyan(backendUrl)}`);
172
+ prompts_1.log.info(`${dim('Architecture:')} ${archLabel[archResult.type]}`);
134
173
  prompts_1.log.info(dim('─────────────────────────────────────────────────'));
174
+ if (archResult.moduleNames.length > 0) {
175
+ prompts_1.log.info(`${dim('Modules found:')} ${archResult.moduleNames.map(m => bold(m)).join(', ')}`);
176
+ }
135
177
  // ── Detect & patch entry file ─────────────────────────────────────────────
136
- const entryFile = detectEntryFile() ?? await askEntryFile();
178
+ const entryFile = detectEntryFile(archResult.type) ?? await askEntryFile(archResult.type);
137
179
  let injected = false;
138
180
  let linesAdded = 0;
139
181
  await (0, prompts_1.tasks)([
@@ -156,7 +198,7 @@ async function main() {
156
198
  tryUnlink((0, path_1.join)(process.cwd(), LOCK_FILE));
157
199
  // Store API key in .env.local — never embed it in source code
158
200
  persistApiKeyToEnv(apiKey);
159
- const result = injectCode(entryFile, projectName, apiKey, baseUrl, backendUrl);
201
+ const result = injectCode(entryFile, projectName, apiKey, baseUrl, backendUrl, archResult.type);
160
202
  injected = !result.alreadyInjected;
161
203
  linesAdded = result.linesAdded ?? 0;
162
204
  if (result.alreadyInjected) {
@@ -253,6 +295,288 @@ async function main() {
253
295
  }[approval.status];
254
296
  (0, prompts_1.outro)(outroMsg);
255
297
  }
298
+ /**
299
+ * Scan the project for services and interactively write apiforge.config.json.
300
+ * Called when microservices arch is detected but no config file exists yet.
301
+ */
302
+ async function buildAndSaveConfig(projectName) {
303
+ const cwd = process.cwd();
304
+ // Collect services from filesystem scan (same logic as findServiceEntries but raw)
305
+ const discovered = [];
306
+ for (const parentDir of ['services', 'apps', 'packages', 'microservices', 'src/services']) {
307
+ const parentPath = (0, path_1.join)(cwd, parentDir);
308
+ if (!(0, fs_1.existsSync)(parentPath))
309
+ continue;
310
+ try {
311
+ const subdirs = (0, fs_1.readdirSync)(parentPath, { withFileTypes: true }).filter(d => d.isDirectory());
312
+ for (const subdir of subdirs) {
313
+ const entry = findEntryInDir((0, path_1.join)(parentPath, subdir.name));
314
+ if (entry) {
315
+ discovered.push({ name: subdir.name, dir: `./${parentDir}/${subdir.name}` });
316
+ }
317
+ }
318
+ }
319
+ catch { }
320
+ if (discovered.length > 0)
321
+ break;
322
+ }
323
+ if (discovered.length === 0)
324
+ return null;
325
+ (0, prompts_1.note)([
326
+ bold(`Found ${discovered.length} service${discovered.length > 1 ? 's' : ''}:`),
327
+ '',
328
+ ...discovered.map(s => ` ${dim('·')} ${bold(s.name)} ${dim('→')} ${dim(s.dir)}`),
329
+ '',
330
+ `${dim('apiforge.config.json will be created so you never have to set this up again.')}`,
331
+ ].join('\n'), 'Auto-detected services');
332
+ const shouldCreate = await (0, prompts_1.confirm)({
333
+ message: `Create apiforge.config.json with these ${discovered.length} services?`,
334
+ initialValue: true,
335
+ });
336
+ if ((0, prompts_1.isCancel)(shouldCreate) || !shouldCreate)
337
+ return null;
338
+ const config = {
339
+ projectName,
340
+ architectureType: 'microservices',
341
+ services: discovered.map(s => ({ name: s.name, dir: s.dir })),
342
+ };
343
+ (0, fs_1.writeFileSync)((0, path_1.join)(cwd, 'apiforge.config.json'), JSON.stringify(config, null, 2) + '\n', 'utf-8');
344
+ prompts_1.log.success(green(`✓ Created apiforge.config.json (${discovered.length} services)`));
345
+ prompts_1.log.info(dim(' Commit this file so teammates can run apiforge without re-setup.'));
346
+ return { services: discovered };
347
+ }
348
+ function loadMicroConfig() {
349
+ for (const cfgName of ['apiforge.config.json', '.apiforge.json']) {
350
+ try {
351
+ const cfg = JSON.parse((0, fs_1.readFileSync)((0, path_1.join)(process.cwd(), cfgName), 'utf-8'));
352
+ if (Array.isArray(cfg.services) && cfg.services.length > 0) {
353
+ return { projectName: cfg.projectName, services: cfg.services };
354
+ }
355
+ }
356
+ catch { }
357
+ }
358
+ return null;
359
+ }
360
+ function serviceNodeCommand(entryRelPath) {
361
+ if (entryRelPath.endsWith('.ts')) {
362
+ const hasTsx = (0, fs_1.existsSync)((0, path_1.join)(process.cwd(), 'node_modules/.bin/tsx'));
363
+ const hasTsNode = (0, fs_1.existsSync)((0, path_1.join)(process.cwd(), 'node_modules/.bin/ts-node'));
364
+ if (hasTsx)
365
+ return `node_modules/.bin/tsx ${entryRelPath}`;
366
+ if (hasTsNode)
367
+ return `node_modules/.bin/ts-node ${entryRelPath}`;
368
+ return `npx --yes tsx ${entryRelPath}`;
369
+ }
370
+ return `node ${entryRelPath}`;
371
+ }
372
+ function waitForServiceStatusFile(statusFile, timeoutMs) {
373
+ return new Promise(resolve => {
374
+ const fullPath = (0, path_1.join)(process.cwd(), statusFile);
375
+ const poller = setInterval(() => {
376
+ try {
377
+ if (!(0, fs_1.existsSync)(fullPath))
378
+ return;
379
+ const data = JSON.parse((0, fs_1.readFileSync)(fullPath, 'utf-8'));
380
+ if (data.status === 'collected' && Array.isArray(data.routes)) {
381
+ clearInterval(poller);
382
+ clearTimeout(timer);
383
+ resolve(data.routes);
384
+ }
385
+ }
386
+ catch { }
387
+ }, 500);
388
+ const timer = setTimeout(() => {
389
+ clearInterval(poller);
390
+ resolve(null);
391
+ }, timeoutMs);
392
+ });
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
+ async function microservicesFlow(config, opts) {
399
+ const cwd = process.cwd();
400
+ const allRoutes = [];
401
+ const serviceMeta = [];
402
+ const injectedFiles = [];
403
+ // Clear any stale per-service status files
404
+ for (const svc of config.services) {
405
+ tryUnlink((0, path_1.join)(cwd, `.apiforge-status-${svc.name}.json`));
406
+ }
407
+ tryUnlink((0, path_1.join)(cwd, STATUS_FILE));
408
+ tryUnlink((0, path_1.join)(cwd, LOCK_FILE));
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
+ }
433
+ (0, prompts_1.note)([
434
+ bold(`Analyzing ${config.services.length} service${config.services.length > 1 ? 's' : ''}…`),
435
+ '',
436
+ ...config.services.map(s => ` ${dim('·')} ${bold(s.name)} ${dim('→')} ${dim(s.dir)}`),
437
+ '',
438
+ dim('Each service is started individually and its routes are collected.'),
439
+ ].join('\n'), 'Multi-service analysis');
440
+ for (const svc of config.services) {
441
+ const svcSpin = (0, prompts_1.spinner)();
442
+ svcSpin.start(`${bold(svc.name)} — starting…`);
443
+ const svcDir = svc.dir.replace(/^\.\//, '');
444
+ // Resolve entry file
445
+ const relEntry = svc.entryFile ?? findEntryInDir((0, path_1.join)(cwd, svcDir));
446
+ if (!relEntry) {
447
+ svcSpin.stop(yellow(`◌ ${svc.name} — no entry file found, skipping`));
448
+ continue;
449
+ }
450
+ const fullEntry = (0, path_1.join)(svcDir, relEntry);
451
+ const statusFile = `.apiforge-status-${svc.name}.json`;
452
+ // Inject collection snippet (upload: false, writes to per-service status file)
453
+ injectCode(fullEntry, opts.projectName, opts.apiKey, opts.baseUrl, opts.backendUrl, 'microservices', {
454
+ serviceName: svc.name,
455
+ statusFile,
456
+ });
457
+ injectedFiles.push(fullEntry);
458
+ // Start service with plain node (or tsx for .ts files)
459
+ const startCmd = svc.startCommand ?? serviceNodeCommand(fullEntry);
460
+ const child = (0, child_process_1.spawn)(startCmd, {
461
+ cwd,
462
+ stdio: ['ignore', 'pipe', 'pipe'],
463
+ shell: true,
464
+ env: { ...process.env },
465
+ detached: false,
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);
478
+ // Wait up to 30 s for the status file to appear
479
+ const routes = await waitForServiceStatusFile(statusFile, 30000);
480
+ // Kill service process
481
+ try {
482
+ child.kill('SIGTERM');
483
+ }
484
+ catch { }
485
+ tryUnlink((0, path_1.join)(cwd, statusFile));
486
+ if (routes && routes.length > 0) {
487
+ const annotated = routes.map((r) => ({ ...r, service: svc.name }));
488
+ allRoutes.push(...annotated);
489
+ serviceMeta.push({ name: svc.name, baseUrl: svc.baseUrl });
490
+ svcSpin.stop(green(`✓ ${svc.name} — ${routes.length} routes collected`));
491
+ }
492
+ else {
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
+ }
498
+ }
499
+ // Remove injected code
500
+ removeInjectedCode(fullEntry);
501
+ injectedFiles.splice(injectedFiles.indexOf(fullEntry), 1);
502
+ }
503
+ // Safety: clean up any remaining injections (e.g. if process was interrupted)
504
+ for (const f of injectedFiles)
505
+ removeInjectedCode(f);
506
+ if (allRoutes.length === 0) {
507
+ prompts_1.log.error(red('No routes were collected from any service.'));
508
+ (0, prompts_1.outro)(red('Analysis failed.'));
509
+ process.exit(1);
510
+ }
511
+ // Upload aggregated routes
512
+ const uploadSpin = (0, prompts_1.spinner)();
513
+ uploadSpin.start(`Uploading ${allRoutes.length} routes from ${serviceMeta.length} service${serviceMeta.length > 1 ? 's' : ''}…`);
514
+ const uploadResult = await (0, uploader_js_1.uploadRoutes)(allRoutes, {
515
+ projectName: opts.projectName,
516
+ apiKey: opts.apiKey,
517
+ baseUrl: opts.baseUrl,
518
+ apiForgeUrl: opts.backendUrl,
519
+ architectureType: 'microservices',
520
+ services: serviceMeta,
521
+ });
522
+ if (!uploadResult) {
523
+ uploadSpin.stop(red('✗ Upload failed'));
524
+ (0, prompts_1.outro)(red('Analysis failed.'));
525
+ process.exit(1);
526
+ }
527
+ uploadSpin.stop(green(`✓ ${uploadResult.routeCount} routes submitted across ${serviceMeta.length} service${serviceMeta.length > 1 ? 's' : ''}`));
528
+ // ── Poll for dashboard approval ───────────────────────────────────────────
529
+ (0, prompts_1.note)([
530
+ bold('Open your dashboard to review:'),
531
+ '',
532
+ ` ${cyan(opts.dashboardUrl)}`,
533
+ '',
534
+ dim('In "Import JSON" → "Pending CLI Imports"'),
535
+ dim('Accept to start AI processing · Deny to discard.'),
536
+ ].join('\n'), 'Action required');
537
+ const poll = (0, prompts_1.spinner)();
538
+ poll.start('Waiting for your decision in the dashboard… (5 min timeout)');
539
+ const approval = await pollForApproval(uploadResult.pendingImportId, opts.apiKey, opts.backendUrl, 5 * 60000);
540
+ const statusMsg = {
541
+ approved: green('✓ Accepted — AI processing started!'),
542
+ denied: red('✗ Import denied by dashboard.'),
543
+ timeout: yellow('◌ Timed out — no decision made within 5 minutes.'),
544
+ }[approval.status];
545
+ poll.stop(statusMsg);
546
+ if (approval.status === 'approved') {
547
+ (0, prompts_1.note)([
548
+ bold('Your API is being documented.'),
549
+ '',
550
+ ` ${dim('Track progress:')} ${cyan(opts.dashboardUrl)}`,
551
+ ` ${dim('Import ID:')} ${dim(uploadResult.pendingImportId)}`,
552
+ ].join('\n'), 'Processing');
553
+ }
554
+ else if (approval.status === 'denied') {
555
+ (0, prompts_1.note)([
556
+ ` ${bold('You denied this import from the dashboard.')}`,
557
+ '',
558
+ ` ${dim('No documentation was generated.')}`,
559
+ ` ${dim('Run')} ${cyan('npx apiforge')} ${dim('again whenever you\'re ready.')}`,
560
+ ].join('\n'), 'Denied');
561
+ }
562
+ else {
563
+ (0, prompts_1.note)([
564
+ ` ${dim('Your import is still pending:')}`,
565
+ ` ${cyan(opts.dashboardUrl)}`,
566
+ '',
567
+ ` ${dim('Import ID:')} ${cyan(uploadResult.pendingImportId)}`,
568
+ ` ${dim('You can still accept or deny it from the dashboard.')}`,
569
+ ].join('\n'), 'Pending');
570
+ }
571
+ tryUnlink((0, path_1.join)(cwd, STATUS_FILE));
572
+ tryUnlink((0, path_1.join)(cwd, LOCK_FILE));
573
+ const outroMsg = {
574
+ approved: `${green('✓')} Done! View your AI docs at ${cyan(opts.dashboardUrl)}`,
575
+ denied: `${red('✗')} Import denied. No changes were made.`,
576
+ timeout: `${yellow('◌')} Timed out. Your import is still pending at ${cyan(opts.dashboardUrl)}`,
577
+ }[approval.status];
578
+ (0, prompts_1.outro)(outroMsg);
579
+ }
256
580
  function startAndWaitForAnalysis(command, timeoutMs) {
257
581
  return new Promise(resolve => {
258
582
  const statusPath = (0, path_1.join)(process.cwd(), STATUS_FILE);
@@ -384,9 +708,124 @@ function removeInjectedCode(filePath) {
384
708
  return false;
385
709
  }
386
710
  }
387
- // ─── Entry file detection ─────────────────────────────────────────────────────
388
- function detectEntryFile() {
711
+ /** Look for a server entry inside a single directory (checks src/ then root). */
712
+ function findEntryInDir(absDir) {
713
+ const names = ['index', 'server', 'app', 'main'];
714
+ const exts = ['ts', 'js', 'mjs', 'cjs'];
715
+ for (const depth of ['src', '']) {
716
+ for (const name of names) {
717
+ for (const ext of exts) {
718
+ const rel = depth ? (0, path_1.join)(depth, `${name}.${ext}`) : `${name}.${ext}`;
719
+ if ((0, fs_1.existsSync)((0, path_1.join)(absDir, rel)))
720
+ return rel;
721
+ }
722
+ }
723
+ }
724
+ return null;
725
+ }
726
+ /**
727
+ * Detect the project's root/main entry point — package.json main,
728
+ * common gateway directories, then flat root candidates.
729
+ * Returns a relative path or null.
730
+ */
731
+ function detectMainEntry(cwd) {
732
+ // 1. package.json main / start / dev script
733
+ try {
734
+ const pkg = JSON.parse((0, fs_1.readFileSync)((0, path_1.join)(cwd, 'package.json'), 'utf-8'));
735
+ if (pkg.main && (0, fs_1.existsSync)((0, path_1.join)(cwd, pkg.main)))
736
+ return pkg.main;
737
+ for (const s of ['start', 'dev', 'serve']) {
738
+ const cmd = pkg.scripts?.[s] ?? '';
739
+ const m = cmd.match(/(?:node|tsx|ts-node|nodemon)\s+([^\s&|;]+\.(js|ts|mjs|cjs))/);
740
+ if (m && (0, fs_1.existsSync)((0, path_1.join)(cwd, m[1])))
741
+ return m[1];
742
+ }
743
+ }
744
+ catch { }
745
+ // 2. Common gateway/proxy directory (e.g. api-gateway/)
746
+ for (const gatewayDir of ['api-gateway', 'gateway', 'proxy', 'api-proxy']) {
747
+ const entry = findEntryInDir((0, path_1.join)(cwd, gatewayDir));
748
+ if (entry)
749
+ return (0, path_1.join)(gatewayDir, entry);
750
+ }
751
+ // 3. Flat root candidates
752
+ for (const c of [
753
+ 'index.ts', 'server.ts', 'app.ts', 'main.ts',
754
+ 'index.js', 'server.js', 'app.js', 'main.js',
755
+ 'src/index.ts', 'src/server.ts', 'src/app.ts', 'src/main.ts',
756
+ 'src/index.js', 'src/server.js', 'src/app.js', 'src/main.js',
757
+ ]) {
758
+ if ((0, fs_1.existsSync)((0, path_1.join)(cwd, c)))
759
+ return c;
760
+ }
761
+ return null;
762
+ }
763
+ /**
764
+ * Scan the project for per-service entry files.
765
+ * Prepends the root main entry (if any) so it appears first in the select list.
766
+ * Reads apiforge.config.json first; falls back to common service parent dirs.
767
+ */
768
+ function findServiceEntries() {
769
+ const cwd = process.cwd();
770
+ const results = [];
771
+ // 0. Root / main entry — shown first so the user always sees it
772
+ const mainEntry = detectMainEntry(cwd);
773
+ if (mainEntry) {
774
+ results.push({ label: `[ Main entry ] → ${mainEntry}`, path: mainEntry });
775
+ }
776
+ // 1. Authoritative list from apiforge.config.json / .apiforge.json
777
+ for (const cfgName of ['apiforge.config.json', '.apiforge.json']) {
778
+ try {
779
+ const cfg = JSON.parse((0, fs_1.readFileSync)((0, path_1.join)(cwd, cfgName), 'utf-8'));
780
+ if (!Array.isArray(cfg.services) || cfg.services.length === 0)
781
+ continue;
782
+ for (const svc of cfg.services) {
783
+ const dir = svc.dir.replace(/^\.\//, '');
784
+ const entry = findEntryInDir((0, path_1.join)(cwd, dir));
785
+ if (!entry)
786
+ continue;
787
+ const relPath = (0, path_1.join)(dir, entry);
788
+ // Skip if same path as the main entry already added
789
+ if (relPath !== mainEntry) {
790
+ results.push({ label: `${svc.name} → ${relPath}`, path: relPath });
791
+ }
792
+ }
793
+ if (results.length > 0)
794
+ return results;
795
+ }
796
+ catch { }
797
+ }
798
+ // 2. Filesystem scan of common service parent dirs
799
+ for (const parentDir of ['services', 'apps', 'packages', 'src/services', 'microservices']) {
800
+ const parentPath = (0, path_1.join)(cwd, parentDir);
801
+ if (!(0, fs_1.existsSync)(parentPath))
802
+ continue;
803
+ try {
804
+ const subdirs = (0, fs_1.readdirSync)(parentPath, { withFileTypes: true }).filter(d => d.isDirectory());
805
+ for (const subdir of subdirs) {
806
+ const entry = findEntryInDir((0, path_1.join)(parentPath, subdir.name));
807
+ if (!entry)
808
+ continue;
809
+ const relPath = (0, path_1.join)(parentDir, subdir.name, entry);
810
+ if (relPath !== mainEntry) {
811
+ results.push({ label: `${subdir.name} → ${relPath}`, path: relPath });
812
+ }
813
+ }
814
+ }
815
+ catch { }
816
+ if (results.length > 1)
817
+ return results; // >1 because main entry is already at index 0
818
+ }
819
+ return results;
820
+ }
821
+ function detectEntryFile(archType) {
389
822
  const cwd = process.cwd();
823
+ // For microservices: auto-pick only when exactly one service entry is found
824
+ if (archType === 'microservices') {
825
+ const entries = findServiceEntries();
826
+ return entries.length === 1 ? entries[0].path : null;
827
+ }
828
+ // Package.json hints
390
829
  try {
391
830
  const pkg = JSON.parse((0, fs_1.readFileSync)((0, path_1.join)(cwd, 'package.json'), 'utf-8'));
392
831
  if (pkg.main && (0, fs_1.existsSync)((0, path_1.join)(cwd, pkg.main)))
@@ -399,16 +838,47 @@ function detectEntryFile() {
399
838
  }
400
839
  }
401
840
  catch { }
841
+ // Flat file candidates — root → src/ → compiled output → misc
402
842
  const candidates = [
403
- 'index.ts', 'server.ts', 'app.ts',
404
- 'index.js', 'server.js', 'app.js',
405
- 'src/index.ts', 'src/server.ts', 'src/app.ts',
406
- 'src/index.js', 'src/server.js', 'src/app.js',
843
+ 'index.ts', 'server.ts', 'app.ts', 'main.ts',
844
+ 'index.js', 'server.js', 'app.js', 'main.js',
845
+ 'src/index.ts', 'src/server.ts', 'src/app.ts', 'src/main.ts',
846
+ 'src/index.js', 'src/server.js', 'src/app.js', 'src/main.js',
847
+ 'dist/index.js', 'dist/server.js', 'dist/main.js',
848
+ 'build/index.js', 'build/server.js', 'build/main.js',
849
+ 'lib/index.js', 'lib/server.js',
850
+ 'server/index.ts', 'server/index.js',
851
+ 'api/index.ts', 'api/index.js',
852
+ 'src/server/index.ts', 'src/server/index.js',
853
+ 'lambda.ts', 'lambda.js', 'handler.ts', 'handler.js',
407
854
  ];
408
855
  return candidates.find(c => (0, fs_1.existsSync)((0, path_1.join)(cwd, c))) ?? null;
409
856
  }
410
- async function askEntryFile() {
411
- prompts_1.log.warn('Could not auto-detect your server entry file.');
857
+ async function askEntryFile(archType) {
858
+ if (archType === 'microservices') {
859
+ const entries = findServiceEntries();
860
+ if (entries.length > 0) {
861
+ const choice = await (0, prompts_1.select)({
862
+ message: 'Which service entry file do you want to analyze?',
863
+ options: [
864
+ ...entries.map(e => ({ value: e.path, label: e.label })),
865
+ { value: '__manual', label: 'Enter path manually…' },
866
+ ],
867
+ });
868
+ if ((0, prompts_1.isCancel)(choice)) {
869
+ (0, prompts_1.cancel)('Cancelled.');
870
+ process.exit(0);
871
+ }
872
+ if (choice !== '__manual')
873
+ return choice;
874
+ }
875
+ else {
876
+ prompts_1.log.warn('Could not find service entry files in services/, apps/, or packages/.');
877
+ }
878
+ }
879
+ else {
880
+ prompts_1.log.warn('Could not auto-detect your server entry file.');
881
+ }
412
882
  const custom = await (0, prompts_1.text)({
413
883
  message: 'Path to your server entry file',
414
884
  placeholder: 'src/index.ts',
@@ -436,7 +906,7 @@ function detectScripts() {
436
906
  if (scripts[name])
437
907
  result.push({ name, command: `npm run ${name}` });
438
908
  }
439
- // Then any remaining scripts that look like server starters
909
+ // Scripts that directly invoke a node runtime
440
910
  for (const [name, cmd] of Object.entries(scripts)) {
441
911
  if (preferred.includes(name))
442
912
  continue;
@@ -444,13 +914,22 @@ function detectScripts() {
444
914
  result.push({ name, command: `npm run ${name}` });
445
915
  }
446
916
  }
917
+ // Workspace per-service scripts: start:*, dev:*, serve:* patterns
918
+ // (e.g. "dev:auth", "start:wallet" common in npm/pnpm monorepos)
919
+ for (const [name] of Object.entries(scripts)) {
920
+ if (preferred.includes(name))
921
+ continue;
922
+ if (/^(?:start|dev|serve|run):[a-z0-9_-]+$/i.test(name) && !result.some(r => r.name === name)) {
923
+ result.push({ name, command: `npm run ${name}` });
924
+ }
925
+ }
447
926
  return result;
448
927
  }
449
928
  catch {
450
929
  return [];
451
930
  }
452
931
  }
453
- function injectCode(filePath, projectName, apiKey, baseUrl, backendUrl) {
932
+ function injectCode(filePath, projectName, apiKey, baseUrl, backendUrl, architectureType, collectionOpts) {
454
933
  const fullPath = (0, path_1.join)(process.cwd(), filePath);
455
934
  const content = (0, fs_1.readFileSync)(fullPath, 'utf-8');
456
935
  if (content.includes('apiforge-analyzer')) {
@@ -458,9 +937,16 @@ function injectCode(filePath, projectName, apiKey, baseUrl, backendUrl) {
458
937
  }
459
938
  const isESM = detectESM(filePath, content);
460
939
  const appVar = detectAppVariable(content) ?? 'app';
461
- const snippet = buildSnippet(projectName, apiKey, baseUrl, backendUrl, appVar, isESM);
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.
462
948
  const listenRe = /\b(?:app|server|httpServer|http)\s*\.listen\s*\(/;
463
- const listenMatch = listenRe.exec(content);
949
+ const listenMatch = collectionOpts ? undefined : listenRe.exec(content);
464
950
  let newContent;
465
951
  let injectionPoint;
466
952
  if (listenMatch?.index !== undefined) {
@@ -509,19 +995,34 @@ function addToGitignore(pattern) {
509
995
  }
510
996
  catch { }
511
997
  }
512
- function buildSnippet(projectName, apiKey, baseUrl, backendUrl, appVar, isESM) {
998
+ function buildSnippet(projectName, apiKey, baseUrl, backendUrl, appVar, isESM, architectureType, collectionOpts) {
513
999
  // The API key is embedded directly — this snippet is TEMPORARY (auto-removed after analysis).
514
1000
  // Hardcoding guarantees the same key is used for both submit and CLI status polling,
515
1001
  // eliminating the mismatch that causes 401s when .env.local already has a different key.
516
1002
  const isProd = backendUrl === BACKEND_PROD;
517
- const opts = [
518
- ` apiKey: '${apiKey}',`,
519
- ` projectName: '${projectName}',`,
520
- ` baseUrl: '${baseUrl}',`,
521
- ...(isProd ? [] : [` apiForgeUrl: '${backendUrl}',`]),
522
- ` upload: true,`,
523
- ` runOnce: true,`,
524
- ].join('\n');
1003
+ // Collection mode (multi-service): upload: false, write to per-service status file
1004
+ const opts = collectionOpts
1005
+ ? [
1006
+ ` apiKey: '${apiKey}',`,
1007
+ ` projectName: '${projectName}',`,
1008
+ ` baseUrl: '${baseUrl}',`,
1009
+ ` service: '${collectionOpts.serviceName}',`,
1010
+ ` statusFile: '${collectionOpts.statusFile}',`,
1011
+ ` architectureType: 'microservices',`,
1012
+ ` upload: false,`,
1013
+ ` runOnce: true,`,
1014
+ ].join('\n')
1015
+ : [
1016
+ ` apiKey: '${apiKey}',`,
1017
+ ` projectName: '${projectName}',`,
1018
+ ` baseUrl: '${baseUrl}',`,
1019
+ ...(isProd ? [] : [` apiForgeUrl: '${backendUrl}',`]),
1020
+ ...(architectureType && architectureType !== 'monolithic'
1021
+ ? [` architectureType: '${architectureType}',`]
1022
+ : []),
1023
+ ` upload: true,`,
1024
+ ` runOnce: true,`,
1025
+ ].join('\n');
525
1026
  if (isESM) {
526
1027
  return [
527
1028
  `// APIForge — https://apiforge.brainfogagency.com`,