apiforge-analyzer 1.3.3 → 1.3.6

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
@@ -205,7 +205,9 @@ async function main() {
205
205
  return `${yellow('◌')} Already configured — no changes made`;
206
206
  }
207
207
  return [
208
- `${green('✓')} Patched ${bold(entryFile)} ${dim(`+${linesAdded} lines`)}`,
208
+ result.updated
209
+ ? `${green('✓')} Updated ${bold(entryFile)} ${dim('(project name/API key changed since last run)')}`
210
+ : `${green('✓')} Patched ${bold(entryFile)} ${dim(`+${linesAdded} lines`)}`,
209
211
  ` ${green('✓')} API key saved to ${bold('.env.local')} ${dim('(gitignored)')}`,
210
212
  ].join('\n');
211
213
  },
@@ -220,7 +222,19 @@ async function main() {
220
222
  ].join('\n'), 'Running');
221
223
  const s = (0, prompts_1.spinner)();
222
224
  s.start('Starting server and analyzing routes…');
223
- const serverResult = await startAndWaitForAnalysis(runCommand, 90000);
225
+ // If the dev command runs under nodemon, our own STATUS_FILE/LOCK_FILE
226
+ // writes (both plain .json files inside the project root) are themselves
227
+ // inside nodemon's default watch glob — writing them mid-analysis makes
228
+ // nodemon restart the very server we're trying to analyze, racing the old
229
+ // and new process for the port (EADDRINUSE) and hanging the whole run.
230
+ const restoreNodemonIgnore = suppressNodemonSelfRestart(process.cwd());
231
+ let serverResult;
232
+ try {
233
+ serverResult = await startAndWaitForAnalysis(runCommand, 90000);
234
+ }
235
+ finally {
236
+ restoreNodemonIgnore();
237
+ }
224
238
  if (serverResult.status === 'error') {
225
239
  s.stop(red(`✗ ${serverResult.message}`));
226
240
  // Give a targeted hint for common failure modes
@@ -653,11 +667,10 @@ async function microservicesFlow(config, opts) {
653
667
  child.stderr?.on('data', capture);
654
668
  // Wait up to 30 s for the status file to appear
655
669
  const routes = await waitForServiceStatusFile(statusFile, 30000);
656
- // Kill service process
657
- try {
658
- child.kill('SIGTERM');
659
- }
660
- catch { }
670
+ // Kill service process (and its full descendant tree — see killProcessTree)
671
+ if (child.pid)
672
+ killProcessTree(child.pid);
673
+ await killOrphanFromEaddrinuse(output);
661
674
  tryUnlink((0, path_1.join)(cwd, statusFile));
662
675
  if (routes && routes.length > 0) {
663
676
  const prefix = gatewayPrefixes[svc.name];
@@ -759,6 +772,124 @@ async function microservicesFlow(config, opts) {
759
772
  }[approval.status];
760
773
  (0, prompts_1.outro)(outroMsg);
761
774
  }
775
+ // ─── Server subprocess ────────────────────────────────────────────────────────
776
+ /**
777
+ * Kill the whole process tree rooted at `pid`, not just that one process.
778
+ *
779
+ * The dev server is started via `spawn(command, { shell: true })`, so `pid`
780
+ * is the shell (cmd.exe /c "npm run dev") — `child.kill()` only signals that
781
+ * shell. On Windows, that leaves the real descendants (npm → nodemon → node)
782
+ * running and still holding the port, so the very next run immediately hits
783
+ * EADDRINUSE. `taskkill /T` kills the shell's entire descendant tree instead.
784
+ * On POSIX this isn't needed the same way (SIGTERM propagates to the shell's
785
+ * job, and the analyzer process itself exits cleanly), so a plain kill there
786
+ * is enough.
787
+ */
788
+ function killProcessTree(pid) {
789
+ if (process.platform === 'win32') {
790
+ try {
791
+ (0, child_process_1.spawn)('taskkill', ['/pid', String(pid), '/T', '/F'], { stdio: 'ignore' });
792
+ }
793
+ catch { }
794
+ }
795
+ else {
796
+ try {
797
+ process.kill(pid, 'SIGTERM');
798
+ }
799
+ catch { }
800
+ }
801
+ }
802
+ /**
803
+ * nodemon manages its own child process in a way that doesn't always show
804
+ * up as a descendant of the shell pid `killProcessTree` targets —
805
+ * `taskkill /T` walks the OS-reported parent/child tree, but nodemon's
806
+ * actual worker sometimes isn't linked into that tree on Windows, so it
807
+ * survives our kill and keeps holding its port. That port then collides
808
+ * with the very next run (EADDRINUSE), which gets killed the same
809
+ * incomplete way, and orphans keep piling up across retries.
810
+ *
811
+ * Rather than guess at process relationships, read the actual port back
812
+ * out of "EADDRINUSE...:<port>" in the crashed server's own output (Node
813
+ * always includes it) and kill whatever OS-level listener currently holds
814
+ * that port — this is exact, not a heuristic, and clears the pileup so the
815
+ * next run isn't born already colliding with the previous one.
816
+ */
817
+ function killOrphanFromEaddrinuse(serverOutput) {
818
+ return new Promise(resolve => {
819
+ if (process.platform !== 'win32')
820
+ return resolve();
821
+ const match = serverOutput.match(/EADDRINUSE.*?:(\d+)\b/);
822
+ if (!match)
823
+ return resolve();
824
+ const port = match[1];
825
+ const script = `Get-NetTCPConnection -LocalPort ${port} -State Listen -ErrorAction SilentlyContinue | ForEach-Object { Stop-Process -Id $_.OwningProcess -Force -ErrorAction SilentlyContinue }`;
826
+ try {
827
+ // Must be awaited (not fire-and-forget) — the CLI process exits right
828
+ // after calling this, and an un-awaited spawn() can get cut off
829
+ // before PowerShell finishes actually killing anything, silently
830
+ // undoing the whole point of this cleanup.
831
+ const ps = (0, child_process_1.spawn)('powershell', ['-NoProfile', '-Command', script], { stdio: 'ignore' });
832
+ ps.on('exit', () => resolve());
833
+ ps.on('error', () => resolve());
834
+ // Don't let a hung PowerShell block the CLI from ever exiting.
835
+ setTimeout(resolve, 5000);
836
+ }
837
+ catch {
838
+ resolve();
839
+ }
840
+ });
841
+ }
842
+ const NODEMON_IGNORE_PATTERNS = ['.apiforge-status.json', '.apiforge-last-run.json', '.env.local'];
843
+ /**
844
+ * If the project depends on nodemon, make sure it won't watch our own
845
+ * .apiforge-status.json / .apiforge-last-run.json / .env.local writes —
846
+ * nodemon's default watch glob includes *.json, so without this it restarts
847
+ * the app mid-analysis (racing the old and new process for the port) every
848
+ * time analyze() writes its completion signal.
849
+ *
850
+ * Uses nodemon's own `.nodemonignore` file (read automatically if present,
851
+ * no config changes needed). Creates it if absent, or appends any missing
852
+ * patterns if it already exists — then returns a function that restores
853
+ * whatever was there before (or removes the file if we created it).
854
+ */
855
+ function suppressNodemonSelfRestart(cwd) {
856
+ try {
857
+ const pkg = JSON.parse((0, fs_1.readFileSync)((0, path_1.join)(cwd, 'package.json'), 'utf-8'));
858
+ const usesNodemon = !!(pkg.dependencies?.nodemon || pkg.devDependencies?.nodemon);
859
+ if (!usesNodemon)
860
+ return () => { };
861
+ }
862
+ catch {
863
+ return () => { };
864
+ }
865
+ const ignorePath = (0, path_1.join)(cwd, '.nodemonignore');
866
+ const existed = (0, fs_1.existsSync)(ignorePath);
867
+ const original = existed ? (0, fs_1.readFileSync)(ignorePath, 'utf-8') : '';
868
+ const existingLines = new Set(original.split('\n').map(l => l.trim()).filter(Boolean));
869
+ const missing = NODEMON_IGNORE_PATTERNS.filter(p => !existingLines.has(p));
870
+ if (missing.length === 0)
871
+ return () => { };
872
+ try {
873
+ const next = existed
874
+ ? original.trimEnd() + '\n' + missing.join('\n') + '\n'
875
+ : missing.join('\n') + '\n';
876
+ (0, fs_1.writeFileSync)(ignorePath, next, 'utf-8');
877
+ }
878
+ catch {
879
+ return () => { };
880
+ }
881
+ return () => {
882
+ try {
883
+ if (existed) {
884
+ (0, fs_1.writeFileSync)(ignorePath, original, 'utf-8');
885
+ }
886
+ else {
887
+ (0, fs_1.unlinkSync)(ignorePath);
888
+ }
889
+ }
890
+ catch { }
891
+ };
892
+ }
762
893
  function startAndWaitForAnalysis(command, timeoutMs) {
763
894
  return new Promise(resolve => {
764
895
  const statusPath = (0, path_1.join)(process.cwd(), STATUS_FILE);
@@ -785,11 +916,11 @@ function startAndWaitForAnalysis(command, timeoutMs) {
785
916
  resolved = true;
786
917
  clearInterval(poller);
787
918
  clearTimeout(timeout);
788
- try {
789
- child.kill('SIGTERM');
790
- }
791
- catch { }
792
- resolve({ ...result, serverOutput: serverOutput.trim() });
919
+ if (child.pid)
920
+ killProcessTree(child.pid);
921
+ killOrphanFromEaddrinuse(serverOutput).then(() => {
922
+ resolve({ ...result, serverOutput: serverOutput.trim() });
923
+ });
793
924
  }
794
925
  // Poll for the status file every 500 ms
795
926
  const poller = setInterval(() => {
@@ -899,6 +1030,28 @@ function removeInjectedCode(filePath) {
899
1030
  return false;
900
1031
  }
901
1032
  }
1033
+ // ─── Entry file detection ─────────────────────────────────────────────────────
1034
+ /**
1035
+ * If `candidate` looks like compiled build output (dist/build/out/lib) and a
1036
+ * TypeScript source with the same base name exists under src/, prefer that
1037
+ * instead of the build artifact.
1038
+ *
1039
+ * Injecting into dist/index.js is worse than pointless when the project has
1040
+ * a watch compiler (tsc --watch, common in a "dev" script alongside
1041
+ * nodemon watching that same dist folder): the compiler immediately
1042
+ * overwrites the file with a fresh, uninjected build, and that overwrite
1043
+ * itself looks like a file change to nodemon, which restarts the server —
1044
+ * colliding with the still-running instance that had the injected snippet
1045
+ * and racing it for the port (EADDRINUSE). The injected code also can't be
1046
+ * cleaned up afterward because tsc already erased it before cleanup ran.
1047
+ */
1048
+ function preferSourceOverBuildOutput(cwd, candidate) {
1049
+ const match = candidate.match(/^(?:dist|build|out|lib)[\\/](.+)\.(?:js|mjs|cjs)$/);
1050
+ if (!match)
1051
+ return candidate;
1052
+ const srcCandidate = (0, path_1.join)('src', `${match[1]}.ts`);
1053
+ return (0, fs_1.existsSync)((0, path_1.join)(cwd, srcCandidate)) ? srcCandidate : candidate;
1054
+ }
902
1055
  /** Look for a server entry inside a single directory (checks src/ then root). */
903
1056
  function findEntryInDir(absDir) {
904
1057
  const names = ['index', 'server', 'app', 'main'];
@@ -924,12 +1077,12 @@ function detectMainEntry(cwd) {
924
1077
  try {
925
1078
  const pkg = JSON.parse((0, fs_1.readFileSync)((0, path_1.join)(cwd, 'package.json'), 'utf-8'));
926
1079
  if (pkg.main && (0, fs_1.existsSync)((0, path_1.join)(cwd, pkg.main)))
927
- return pkg.main;
1080
+ return preferSourceOverBuildOutput(cwd, pkg.main);
928
1081
  for (const s of ['start', 'dev', 'serve']) {
929
1082
  const cmd = pkg.scripts?.[s] ?? '';
930
- const m = cmd.match(/(?:node|tsx|ts-node|nodemon)\s+([^\s&|;]+\.(js|ts|mjs|cjs))/);
1083
+ const m = cmd.match(/(?:node|tsx|ts-node|nodemon)\s+(?:-\S+\s+)*([^\s&|;]+\.(js|ts|mjs|cjs))/);
931
1084
  if (m && (0, fs_1.existsSync)((0, path_1.join)(cwd, m[1])))
932
- return m[1];
1085
+ return preferSourceOverBuildOutput(cwd, m[1]);
933
1086
  }
934
1087
  }
935
1088
  catch { }
@@ -1020,12 +1173,12 @@ function detectEntryFile(archType) {
1020
1173
  try {
1021
1174
  const pkg = JSON.parse((0, fs_1.readFileSync)((0, path_1.join)(cwd, 'package.json'), 'utf-8'));
1022
1175
  if (pkg.main && (0, fs_1.existsSync)((0, path_1.join)(cwd, pkg.main)))
1023
- return pkg.main;
1176
+ return preferSourceOverBuildOutput(cwd, pkg.main);
1024
1177
  for (const s of ['start', 'dev', 'serve']) {
1025
1178
  const cmd = pkg.scripts?.[s] ?? '';
1026
- const m = cmd.match(/(?:node|tsx|ts-node|nodemon)\s+([^\s&|;]+\.(js|ts|mjs|cjs))/);
1179
+ const m = cmd.match(/(?:node|tsx|ts-node|nodemon)\s+(?:-\S+\s+)*([^\s&|;]+\.(js|ts|mjs|cjs))/);
1027
1180
  if (m && (0, fs_1.existsSync)((0, path_1.join)(cwd, m[1])))
1028
- return m[1];
1181
+ return preferSourceOverBuildOutput(cwd, m[1]);
1029
1182
  }
1030
1183
  }
1031
1184
  catch { }
@@ -1120,11 +1273,37 @@ function detectScripts() {
1120
1273
  return [];
1121
1274
  }
1122
1275
  }
1276
+ /** Reads `field: 'value'` out of an already-injected APIForge block. */
1277
+ function extractInjectedField(block, field) {
1278
+ const m = new RegExp(`${field}:\\s*'([^']*)'`).exec(block);
1279
+ return m?.[1];
1280
+ }
1123
1281
  function injectCode(filePath, projectName, apiKey, baseUrl, backendUrl, architectureType, collectionOpts) {
1124
1282
  const fullPath = (0, path_1.join)(process.cwd(), filePath);
1125
- const content = (0, fs_1.readFileSync)(fullPath, 'utf-8');
1126
- if (content.includes('apiforge-analyzer')) {
1127
- return { alreadyInjected: true };
1283
+ let content = (0, fs_1.readFileSync)(fullPath, 'utf-8');
1284
+ const startIdx = content.indexOf('// APIForge');
1285
+ let wasStale = false;
1286
+ if (startIdx !== -1) {
1287
+ const afterStart = content.slice(startIdx);
1288
+ const endMatch = /\.catch\s*\(\s*\(\s*\)\s*=>\s*\{?\s*\}?\s*\)\s*\n?/.exec(afterStart);
1289
+ const block = endMatch ? afterStart.slice(0, endMatch.index + endMatch[0].length) : afterStart;
1290
+ const existingKey = extractInjectedField(block, 'apiKey');
1291
+ const existingName = extractInjectedField(block, 'projectName');
1292
+ // Same project name and API key as last time — nothing to do.
1293
+ if (existingKey === apiKey && existingName === projectName) {
1294
+ return { alreadyInjected: true };
1295
+ }
1296
+ // Prompt answers changed since this file was last configured. Silently
1297
+ // keeping the old block meant a stale (sometimes since-deleted) API key
1298
+ // stayed hardcoded forever — every later run kept uploading with it no
1299
+ // matter what was typed at the prompt. Re-inject fresh instead.
1300
+ if (endMatch) {
1301
+ const endIdx = startIdx + endMatch.index + endMatch[0].length;
1302
+ content = (content.slice(0, startIdx) + content.slice(endIdx))
1303
+ .replace(/\n{3,}/g, '\n\n')
1304
+ .trimEnd() + '\n';
1305
+ wasStale = true;
1306
+ }
1128
1307
  }
1129
1308
  const isESM = detectESM(filePath, content);
1130
1309
  const appVar = detectAppVariable(content) ?? 'app';
@@ -1150,7 +1329,7 @@ function injectCode(filePath, projectName, apiKey, baseUrl, backendUrl, architec
1150
1329
  injectionPoint = 'end of file';
1151
1330
  }
1152
1331
  (0, fs_1.writeFileSync)(fullPath, newContent, 'utf-8');
1153
- return { alreadyInjected: false, injectionPoint, linesAdded: snippet.split('\n').length };
1332
+ return { alreadyInjected: false, injectionPoint, linesAdded: snippet.split('\n').length, updated: wasStale };
1154
1333
  }
1155
1334
  /**
1156
1335
  * Write (or overwrite) the API key in .env.local.
@@ -1216,24 +1395,37 @@ function buildSnippet(projectName, apiKey, baseUrl, backendUrl, appVar, isESM, a
1216
1395
  ].join('\n');
1217
1396
  // Collection mode: some services only mount routes inside an async bootstrap
1218
1397
  // gated behind a DB/broker connect (which may hang/fail outside Docker).
1219
- // Poll briefly for app._router to be populated (Express-shaped apps only)
1220
- // before giving up and collecting whatever is there — bounded well under
1221
- // the CLI's 30s per-service timeout so a hung connect still surfaces.
1398
+ //
1399
+ // Express exposes no "I'm done registering routes" signal, so for
1400
+ // Express-shaped apps we poll app._router.stack briefly before giving up
1401
+ // and collecting whatever is there — bounded well under the CLI's 30s
1402
+ // per-service timeout so a hung connect still surfaces.
1403
+ //
1404
+ // Fastify DOES expose a real completion signal (`app.ready()`, resolved
1405
+ // once every plugin/route has finished registering), so Fastify apps use
1406
+ // that instead of the Express-shaped poll — polling `._router.stack` on a
1407
+ // Fastify instance always looks "empty" (Fastify doesn't have that
1408
+ // property), which used to make hasRoutes evaluate true immediately and
1409
+ // fire analyze() before async-registered routes existed yet.
1222
1410
  const runAnalyze = `APIForge.analyze(${appVar}, {\n${opts}\n })`;
1223
1411
  const call = collectionOpts
1224
1412
  ? [
1225
- ` let __apiforgeTries = 0`,
1226
- ` const __apiforgeTryCollect = () => {`,
1227
- ` const hasRoutes = !${appVar} || !${appVar}._router`,
1228
- ` ? true`,
1229
- ` : Array.isArray(${appVar}._router.stack) && ${appVar}._router.stack.length > 0`,
1230
- ` if (hasRoutes || __apiforgeTries++ >= 26) {`,
1231
- ` ${runAnalyze}`,
1232
- ` } else {`,
1233
- ` setTimeout(__apiforgeTryCollect, 300)`,
1413
+ ` if (${appVar} && typeof ${appVar}.ready === 'function') {`,
1414
+ ` ${appVar}.ready().then(() => { ${runAnalyze} }).catch(() => { ${runAnalyze} })`,
1415
+ ` } else {`,
1416
+ ` let __apiforgeTries = 0`,
1417
+ ` const __apiforgeTryCollect = () => {`,
1418
+ ` const hasRoutes = !${appVar} || !${appVar}._router`,
1419
+ ` ? true`,
1420
+ ` : Array.isArray(${appVar}._router.stack) && ${appVar}._router.stack.length > 0`,
1421
+ ` if (hasRoutes || __apiforgeTries++ >= 26) {`,
1422
+ ` ${runAnalyze}`,
1423
+ ` } else {`,
1424
+ ` setTimeout(__apiforgeTryCollect, 300)`,
1425
+ ` }`,
1234
1426
  ` }`,
1427
+ ` __apiforgeTryCollect()`,
1235
1428
  ` }`,
1236
- ` __apiforgeTryCollect()`,
1237
1429
  ].join('\n')
1238
1430
  : ` ${runAnalyze}`;
1239
1431
  if (isESM) {
@@ -1268,17 +1460,23 @@ function detectESM(filePath, content) {
1268
1460
  return /^(?:import|export)\s+/m.test(content);
1269
1461
  }
1270
1462
  function detectAppVariable(content) {
1463
+ // Optional TS type annotation between the variable name and `=`, e.g.
1464
+ // `const app: FastifyInstance = fastify()` — common in Fastify+TS
1465
+ // projects (Fastify has first-class TS typings, unlike bare Express).
1466
+ // Without this, none of the patterns below matched a type-annotated
1467
+ // Fastify app at all.
1468
+ const typeAnn = `(?:\\s*:\\s*[\\w.$]+(?:<[^>]*>)?)?`;
1271
1469
  const patterns = [
1272
- /(?:const|let|var)\s+(\w+)\s*=\s*express\s*(?:\.default)?\s*\(/,
1273
- /(?:const|let|var)\s+(\w+)\s*=\s*(?:fastify|Fastify)\s*\(/,
1274
- /(?:const|let|var)\s+(\w+)\s*=\s*new\s+Koa\s*\(/,
1275
- /(?:const|let|var)\s+(\w+)\s*=\s*(?:Router|createApp|createServer)\s*\(/,
1470
+ new RegExp(`(?:const|let|var)\\s+(\\w+)${typeAnn}\\s*=\\s*express\\s*(?:\\.default)?\\s*\\(`),
1471
+ new RegExp(`(?:const|let|var)\\s+(\\w+)${typeAnn}\\s*=\\s*(?:fastify|Fastify)\\s*\\(`),
1472
+ new RegExp(`(?:const|let|var)\\s+(\\w+)${typeAnn}\\s*=\\s*new\\s+Koa\\s*\\(`),
1473
+ new RegExp(`(?:const|let|var)\\s+(\\w+)${typeAnn}\\s*=\\s*(?:Router|createApp|createServer)\\s*\\(`),
1276
1474
  // Inline `require('fastify')(...)` / `require('express')(...)` — the app
1277
1475
  // factory is never bound to a local name, only its return value is. This
1278
1476
  // is at least as common as the named-import style above (it's the pattern
1279
1477
  // in Fastify's own project generator output), and the patterns above never
1280
1478
  // match it because there's no `fastify`/`express` identifier in the source.
1281
- /(?:const|let|var)\s+(\w+)\s*=\s*require\s*\(\s*['"](?:fastify|express)['"]\s*\)\s*(?:\.default\s*)?\(/,
1479
+ new RegExp(`(?:const|let|var)\\s+(\\w+)${typeAnn}\\s*=\\s*require\\s*\\(\\s*['"](?:fastify|express)['"]\\s*\\)\\s*(?:\\.default\\s*)?\\(`),
1282
1480
  ];
1283
1481
  for (const re of patterns) {
1284
1482
  const m = content.match(re);
package/dist/esm/cli.js CHANGED
@@ -200,7 +200,9 @@ async function main() {
200
200
  return `${yellow('◌')} Already configured — no changes made`;
201
201
  }
202
202
  return [
203
- `${green('✓')} Patched ${bold(entryFile)} ${dim(`+${linesAdded} lines`)}`,
203
+ result.updated
204
+ ? `${green('✓')} Updated ${bold(entryFile)} ${dim('(project name/API key changed since last run)')}`
205
+ : `${green('✓')} Patched ${bold(entryFile)} ${dim(`+${linesAdded} lines`)}`,
204
206
  ` ${green('✓')} API key saved to ${bold('.env.local')} ${dim('(gitignored)')}`,
205
207
  ].join('\n');
206
208
  },
@@ -215,7 +217,19 @@ async function main() {
215
217
  ].join('\n'), 'Running');
216
218
  const s = spinner();
217
219
  s.start('Starting server and analyzing routes…');
218
- const serverResult = await startAndWaitForAnalysis(runCommand, 90000);
220
+ // If the dev command runs under nodemon, our own STATUS_FILE/LOCK_FILE
221
+ // writes (both plain .json files inside the project root) are themselves
222
+ // inside nodemon's default watch glob — writing them mid-analysis makes
223
+ // nodemon restart the very server we're trying to analyze, racing the old
224
+ // and new process for the port (EADDRINUSE) and hanging the whole run.
225
+ const restoreNodemonIgnore = suppressNodemonSelfRestart(process.cwd());
226
+ let serverResult;
227
+ try {
228
+ serverResult = await startAndWaitForAnalysis(runCommand, 90000);
229
+ }
230
+ finally {
231
+ restoreNodemonIgnore();
232
+ }
219
233
  if (serverResult.status === 'error') {
220
234
  s.stop(red(`✗ ${serverResult.message}`));
221
235
  // Give a targeted hint for common failure modes
@@ -648,11 +662,10 @@ async function microservicesFlow(config, opts) {
648
662
  child.stderr?.on('data', capture);
649
663
  // Wait up to 30 s for the status file to appear
650
664
  const routes = await waitForServiceStatusFile(statusFile, 30000);
651
- // Kill service process
652
- try {
653
- child.kill('SIGTERM');
654
- }
655
- catch { }
665
+ // Kill service process (and its full descendant tree — see killProcessTree)
666
+ if (child.pid)
667
+ killProcessTree(child.pid);
668
+ await killOrphanFromEaddrinuse(output);
656
669
  tryUnlink(join(cwd, statusFile));
657
670
  if (routes && routes.length > 0) {
658
671
  const prefix = gatewayPrefixes[svc.name];
@@ -754,6 +767,124 @@ async function microservicesFlow(config, opts) {
754
767
  }[approval.status];
755
768
  outro(outroMsg);
756
769
  }
770
+ // ─── Server subprocess ────────────────────────────────────────────────────────
771
+ /**
772
+ * Kill the whole process tree rooted at `pid`, not just that one process.
773
+ *
774
+ * The dev server is started via `spawn(command, { shell: true })`, so `pid`
775
+ * is the shell (cmd.exe /c "npm run dev") — `child.kill()` only signals that
776
+ * shell. On Windows, that leaves the real descendants (npm → nodemon → node)
777
+ * running and still holding the port, so the very next run immediately hits
778
+ * EADDRINUSE. `taskkill /T` kills the shell's entire descendant tree instead.
779
+ * On POSIX this isn't needed the same way (SIGTERM propagates to the shell's
780
+ * job, and the analyzer process itself exits cleanly), so a plain kill there
781
+ * is enough.
782
+ */
783
+ function killProcessTree(pid) {
784
+ if (process.platform === 'win32') {
785
+ try {
786
+ spawn('taskkill', ['/pid', String(pid), '/T', '/F'], { stdio: 'ignore' });
787
+ }
788
+ catch { }
789
+ }
790
+ else {
791
+ try {
792
+ process.kill(pid, 'SIGTERM');
793
+ }
794
+ catch { }
795
+ }
796
+ }
797
+ /**
798
+ * nodemon manages its own child process in a way that doesn't always show
799
+ * up as a descendant of the shell pid `killProcessTree` targets —
800
+ * `taskkill /T` walks the OS-reported parent/child tree, but nodemon's
801
+ * actual worker sometimes isn't linked into that tree on Windows, so it
802
+ * survives our kill and keeps holding its port. That port then collides
803
+ * with the very next run (EADDRINUSE), which gets killed the same
804
+ * incomplete way, and orphans keep piling up across retries.
805
+ *
806
+ * Rather than guess at process relationships, read the actual port back
807
+ * out of "EADDRINUSE...:<port>" in the crashed server's own output (Node
808
+ * always includes it) and kill whatever OS-level listener currently holds
809
+ * that port — this is exact, not a heuristic, and clears the pileup so the
810
+ * next run isn't born already colliding with the previous one.
811
+ */
812
+ function killOrphanFromEaddrinuse(serverOutput) {
813
+ return new Promise(resolve => {
814
+ if (process.platform !== 'win32')
815
+ return resolve();
816
+ const match = serverOutput.match(/EADDRINUSE.*?:(\d+)\b/);
817
+ if (!match)
818
+ return resolve();
819
+ const port = match[1];
820
+ const script = `Get-NetTCPConnection -LocalPort ${port} -State Listen -ErrorAction SilentlyContinue | ForEach-Object { Stop-Process -Id $_.OwningProcess -Force -ErrorAction SilentlyContinue }`;
821
+ try {
822
+ // Must be awaited (not fire-and-forget) — the CLI process exits right
823
+ // after calling this, and an un-awaited spawn() can get cut off
824
+ // before PowerShell finishes actually killing anything, silently
825
+ // undoing the whole point of this cleanup.
826
+ const ps = spawn('powershell', ['-NoProfile', '-Command', script], { stdio: 'ignore' });
827
+ ps.on('exit', () => resolve());
828
+ ps.on('error', () => resolve());
829
+ // Don't let a hung PowerShell block the CLI from ever exiting.
830
+ setTimeout(resolve, 5000);
831
+ }
832
+ catch {
833
+ resolve();
834
+ }
835
+ });
836
+ }
837
+ const NODEMON_IGNORE_PATTERNS = ['.apiforge-status.json', '.apiforge-last-run.json', '.env.local'];
838
+ /**
839
+ * If the project depends on nodemon, make sure it won't watch our own
840
+ * .apiforge-status.json / .apiforge-last-run.json / .env.local writes —
841
+ * nodemon's default watch glob includes *.json, so without this it restarts
842
+ * the app mid-analysis (racing the old and new process for the port) every
843
+ * time analyze() writes its completion signal.
844
+ *
845
+ * Uses nodemon's own `.nodemonignore` file (read automatically if present,
846
+ * no config changes needed). Creates it if absent, or appends any missing
847
+ * patterns if it already exists — then returns a function that restores
848
+ * whatever was there before (or removes the file if we created it).
849
+ */
850
+ function suppressNodemonSelfRestart(cwd) {
851
+ try {
852
+ const pkg = JSON.parse(readFileSync(join(cwd, 'package.json'), 'utf-8'));
853
+ const usesNodemon = !!(pkg.dependencies?.nodemon || pkg.devDependencies?.nodemon);
854
+ if (!usesNodemon)
855
+ return () => { };
856
+ }
857
+ catch {
858
+ return () => { };
859
+ }
860
+ const ignorePath = join(cwd, '.nodemonignore');
861
+ const existed = existsSync(ignorePath);
862
+ const original = existed ? readFileSync(ignorePath, 'utf-8') : '';
863
+ const existingLines = new Set(original.split('\n').map(l => l.trim()).filter(Boolean));
864
+ const missing = NODEMON_IGNORE_PATTERNS.filter(p => !existingLines.has(p));
865
+ if (missing.length === 0)
866
+ return () => { };
867
+ try {
868
+ const next = existed
869
+ ? original.trimEnd() + '\n' + missing.join('\n') + '\n'
870
+ : missing.join('\n') + '\n';
871
+ writeFileSync(ignorePath, next, 'utf-8');
872
+ }
873
+ catch {
874
+ return () => { };
875
+ }
876
+ return () => {
877
+ try {
878
+ if (existed) {
879
+ writeFileSync(ignorePath, original, 'utf-8');
880
+ }
881
+ else {
882
+ unlinkSync(ignorePath);
883
+ }
884
+ }
885
+ catch { }
886
+ };
887
+ }
757
888
  function startAndWaitForAnalysis(command, timeoutMs) {
758
889
  return new Promise(resolve => {
759
890
  const statusPath = join(process.cwd(), STATUS_FILE);
@@ -780,11 +911,11 @@ function startAndWaitForAnalysis(command, timeoutMs) {
780
911
  resolved = true;
781
912
  clearInterval(poller);
782
913
  clearTimeout(timeout);
783
- try {
784
- child.kill('SIGTERM');
785
- }
786
- catch { }
787
- resolve({ ...result, serverOutput: serverOutput.trim() });
914
+ if (child.pid)
915
+ killProcessTree(child.pid);
916
+ killOrphanFromEaddrinuse(serverOutput).then(() => {
917
+ resolve({ ...result, serverOutput: serverOutput.trim() });
918
+ });
788
919
  }
789
920
  // Poll for the status file every 500 ms
790
921
  const poller = setInterval(() => {
@@ -894,6 +1025,28 @@ function removeInjectedCode(filePath) {
894
1025
  return false;
895
1026
  }
896
1027
  }
1028
+ // ─── Entry file detection ─────────────────────────────────────────────────────
1029
+ /**
1030
+ * If `candidate` looks like compiled build output (dist/build/out/lib) and a
1031
+ * TypeScript source with the same base name exists under src/, prefer that
1032
+ * instead of the build artifact.
1033
+ *
1034
+ * Injecting into dist/index.js is worse than pointless when the project has
1035
+ * a watch compiler (tsc --watch, common in a "dev" script alongside
1036
+ * nodemon watching that same dist folder): the compiler immediately
1037
+ * overwrites the file with a fresh, uninjected build, and that overwrite
1038
+ * itself looks like a file change to nodemon, which restarts the server —
1039
+ * colliding with the still-running instance that had the injected snippet
1040
+ * and racing it for the port (EADDRINUSE). The injected code also can't be
1041
+ * cleaned up afterward because tsc already erased it before cleanup ran.
1042
+ */
1043
+ function preferSourceOverBuildOutput(cwd, candidate) {
1044
+ const match = candidate.match(/^(?:dist|build|out|lib)[\\/](.+)\.(?:js|mjs|cjs)$/);
1045
+ if (!match)
1046
+ return candidate;
1047
+ const srcCandidate = join('src', `${match[1]}.ts`);
1048
+ return existsSync(join(cwd, srcCandidate)) ? srcCandidate : candidate;
1049
+ }
897
1050
  /** Look for a server entry inside a single directory (checks src/ then root). */
898
1051
  function findEntryInDir(absDir) {
899
1052
  const names = ['index', 'server', 'app', 'main'];
@@ -919,12 +1072,12 @@ function detectMainEntry(cwd) {
919
1072
  try {
920
1073
  const pkg = JSON.parse(readFileSync(join(cwd, 'package.json'), 'utf-8'));
921
1074
  if (pkg.main && existsSync(join(cwd, pkg.main)))
922
- return pkg.main;
1075
+ return preferSourceOverBuildOutput(cwd, pkg.main);
923
1076
  for (const s of ['start', 'dev', 'serve']) {
924
1077
  const cmd = pkg.scripts?.[s] ?? '';
925
- const m = cmd.match(/(?:node|tsx|ts-node|nodemon)\s+([^\s&|;]+\.(js|ts|mjs|cjs))/);
1078
+ const m = cmd.match(/(?:node|tsx|ts-node|nodemon)\s+(?:-\S+\s+)*([^\s&|;]+\.(js|ts|mjs|cjs))/);
926
1079
  if (m && existsSync(join(cwd, m[1])))
927
- return m[1];
1080
+ return preferSourceOverBuildOutput(cwd, m[1]);
928
1081
  }
929
1082
  }
930
1083
  catch { }
@@ -1015,12 +1168,12 @@ function detectEntryFile(archType) {
1015
1168
  try {
1016
1169
  const pkg = JSON.parse(readFileSync(join(cwd, 'package.json'), 'utf-8'));
1017
1170
  if (pkg.main && existsSync(join(cwd, pkg.main)))
1018
- return pkg.main;
1171
+ return preferSourceOverBuildOutput(cwd, pkg.main);
1019
1172
  for (const s of ['start', 'dev', 'serve']) {
1020
1173
  const cmd = pkg.scripts?.[s] ?? '';
1021
- const m = cmd.match(/(?:node|tsx|ts-node|nodemon)\s+([^\s&|;]+\.(js|ts|mjs|cjs))/);
1174
+ const m = cmd.match(/(?:node|tsx|ts-node|nodemon)\s+(?:-\S+\s+)*([^\s&|;]+\.(js|ts|mjs|cjs))/);
1022
1175
  if (m && existsSync(join(cwd, m[1])))
1023
- return m[1];
1176
+ return preferSourceOverBuildOutput(cwd, m[1]);
1024
1177
  }
1025
1178
  }
1026
1179
  catch { }
@@ -1115,11 +1268,37 @@ function detectScripts() {
1115
1268
  return [];
1116
1269
  }
1117
1270
  }
1271
+ /** Reads `field: 'value'` out of an already-injected APIForge block. */
1272
+ function extractInjectedField(block, field) {
1273
+ const m = new RegExp(`${field}:\\s*'([^']*)'`).exec(block);
1274
+ return m?.[1];
1275
+ }
1118
1276
  function injectCode(filePath, projectName, apiKey, baseUrl, backendUrl, architectureType, collectionOpts) {
1119
1277
  const fullPath = join(process.cwd(), filePath);
1120
- const content = readFileSync(fullPath, 'utf-8');
1121
- if (content.includes('apiforge-analyzer')) {
1122
- return { alreadyInjected: true };
1278
+ let content = readFileSync(fullPath, 'utf-8');
1279
+ const startIdx = content.indexOf('// APIForge');
1280
+ let wasStale = false;
1281
+ if (startIdx !== -1) {
1282
+ const afterStart = content.slice(startIdx);
1283
+ const endMatch = /\.catch\s*\(\s*\(\s*\)\s*=>\s*\{?\s*\}?\s*\)\s*\n?/.exec(afterStart);
1284
+ const block = endMatch ? afterStart.slice(0, endMatch.index + endMatch[0].length) : afterStart;
1285
+ const existingKey = extractInjectedField(block, 'apiKey');
1286
+ const existingName = extractInjectedField(block, 'projectName');
1287
+ // Same project name and API key as last time — nothing to do.
1288
+ if (existingKey === apiKey && existingName === projectName) {
1289
+ return { alreadyInjected: true };
1290
+ }
1291
+ // Prompt answers changed since this file was last configured. Silently
1292
+ // keeping the old block meant a stale (sometimes since-deleted) API key
1293
+ // stayed hardcoded forever — every later run kept uploading with it no
1294
+ // matter what was typed at the prompt. Re-inject fresh instead.
1295
+ if (endMatch) {
1296
+ const endIdx = startIdx + endMatch.index + endMatch[0].length;
1297
+ content = (content.slice(0, startIdx) + content.slice(endIdx))
1298
+ .replace(/\n{3,}/g, '\n\n')
1299
+ .trimEnd() + '\n';
1300
+ wasStale = true;
1301
+ }
1123
1302
  }
1124
1303
  const isESM = detectESM(filePath, content);
1125
1304
  const appVar = detectAppVariable(content) ?? 'app';
@@ -1145,7 +1324,7 @@ function injectCode(filePath, projectName, apiKey, baseUrl, backendUrl, architec
1145
1324
  injectionPoint = 'end of file';
1146
1325
  }
1147
1326
  writeFileSync(fullPath, newContent, 'utf-8');
1148
- return { alreadyInjected: false, injectionPoint, linesAdded: snippet.split('\n').length };
1327
+ return { alreadyInjected: false, injectionPoint, linesAdded: snippet.split('\n').length, updated: wasStale };
1149
1328
  }
1150
1329
  /**
1151
1330
  * Write (or overwrite) the API key in .env.local.
@@ -1211,24 +1390,37 @@ function buildSnippet(projectName, apiKey, baseUrl, backendUrl, appVar, isESM, a
1211
1390
  ].join('\n');
1212
1391
  // Collection mode: some services only mount routes inside an async bootstrap
1213
1392
  // gated behind a DB/broker connect (which may hang/fail outside Docker).
1214
- // Poll briefly for app._router to be populated (Express-shaped apps only)
1215
- // before giving up and collecting whatever is there — bounded well under
1216
- // the CLI's 30s per-service timeout so a hung connect still surfaces.
1393
+ //
1394
+ // Express exposes no "I'm done registering routes" signal, so for
1395
+ // Express-shaped apps we poll app._router.stack briefly before giving up
1396
+ // and collecting whatever is there — bounded well under the CLI's 30s
1397
+ // per-service timeout so a hung connect still surfaces.
1398
+ //
1399
+ // Fastify DOES expose a real completion signal (`app.ready()`, resolved
1400
+ // once every plugin/route has finished registering), so Fastify apps use
1401
+ // that instead of the Express-shaped poll — polling `._router.stack` on a
1402
+ // Fastify instance always looks "empty" (Fastify doesn't have that
1403
+ // property), which used to make hasRoutes evaluate true immediately and
1404
+ // fire analyze() before async-registered routes existed yet.
1217
1405
  const runAnalyze = `APIForge.analyze(${appVar}, {\n${opts}\n })`;
1218
1406
  const call = collectionOpts
1219
1407
  ? [
1220
- ` let __apiforgeTries = 0`,
1221
- ` const __apiforgeTryCollect = () => {`,
1222
- ` const hasRoutes = !${appVar} || !${appVar}._router`,
1223
- ` ? true`,
1224
- ` : Array.isArray(${appVar}._router.stack) && ${appVar}._router.stack.length > 0`,
1225
- ` if (hasRoutes || __apiforgeTries++ >= 26) {`,
1226
- ` ${runAnalyze}`,
1227
- ` } else {`,
1228
- ` setTimeout(__apiforgeTryCollect, 300)`,
1408
+ ` if (${appVar} && typeof ${appVar}.ready === 'function') {`,
1409
+ ` ${appVar}.ready().then(() => { ${runAnalyze} }).catch(() => { ${runAnalyze} })`,
1410
+ ` } else {`,
1411
+ ` let __apiforgeTries = 0`,
1412
+ ` const __apiforgeTryCollect = () => {`,
1413
+ ` const hasRoutes = !${appVar} || !${appVar}._router`,
1414
+ ` ? true`,
1415
+ ` : Array.isArray(${appVar}._router.stack) && ${appVar}._router.stack.length > 0`,
1416
+ ` if (hasRoutes || __apiforgeTries++ >= 26) {`,
1417
+ ` ${runAnalyze}`,
1418
+ ` } else {`,
1419
+ ` setTimeout(__apiforgeTryCollect, 300)`,
1420
+ ` }`,
1229
1421
  ` }`,
1422
+ ` __apiforgeTryCollect()`,
1230
1423
  ` }`,
1231
- ` __apiforgeTryCollect()`,
1232
1424
  ].join('\n')
1233
1425
  : ` ${runAnalyze}`;
1234
1426
  if (isESM) {
@@ -1263,17 +1455,23 @@ function detectESM(filePath, content) {
1263
1455
  return /^(?:import|export)\s+/m.test(content);
1264
1456
  }
1265
1457
  function detectAppVariable(content) {
1458
+ // Optional TS type annotation between the variable name and `=`, e.g.
1459
+ // `const app: FastifyInstance = fastify()` — common in Fastify+TS
1460
+ // projects (Fastify has first-class TS typings, unlike bare Express).
1461
+ // Without this, none of the patterns below matched a type-annotated
1462
+ // Fastify app at all.
1463
+ const typeAnn = `(?:\\s*:\\s*[\\w.$]+(?:<[^>]*>)?)?`;
1266
1464
  const patterns = [
1267
- /(?:const|let|var)\s+(\w+)\s*=\s*express\s*(?:\.default)?\s*\(/,
1268
- /(?:const|let|var)\s+(\w+)\s*=\s*(?:fastify|Fastify)\s*\(/,
1269
- /(?:const|let|var)\s+(\w+)\s*=\s*new\s+Koa\s*\(/,
1270
- /(?:const|let|var)\s+(\w+)\s*=\s*(?:Router|createApp|createServer)\s*\(/,
1465
+ new RegExp(`(?:const|let|var)\\s+(\\w+)${typeAnn}\\s*=\\s*express\\s*(?:\\.default)?\\s*\\(`),
1466
+ new RegExp(`(?:const|let|var)\\s+(\\w+)${typeAnn}\\s*=\\s*(?:fastify|Fastify)\\s*\\(`),
1467
+ new RegExp(`(?:const|let|var)\\s+(\\w+)${typeAnn}\\s*=\\s*new\\s+Koa\\s*\\(`),
1468
+ new RegExp(`(?:const|let|var)\\s+(\\w+)${typeAnn}\\s*=\\s*(?:Router|createApp|createServer)\\s*\\(`),
1271
1469
  // Inline `require('fastify')(...)` / `require('express')(...)` — the app
1272
1470
  // factory is never bound to a local name, only its return value is. This
1273
1471
  // is at least as common as the named-import style above (it's the pattern
1274
1472
  // in Fastify's own project generator output), and the patterns above never
1275
1473
  // match it because there's no `fastify`/`express` identifier in the source.
1276
- /(?:const|let|var)\s+(\w+)\s*=\s*require\s*\(\s*['"](?:fastify|express)['"]\s*\)\s*(?:\.default\s*)?\(/,
1474
+ new RegExp(`(?:const|let|var)\\s+(\\w+)${typeAnn}\\s*=\\s*require\\s*\\(\\s*['"](?:fastify|express)['"]\\s*\\)\\s*(?:\\.default\\s*)?\\(`),
1277
1475
  ];
1278
1476
  for (const re of patterns) {
1279
1477
  const m = content.match(re);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "apiforge-analyzer",
3
- "version": "1.3.3",
4
- "description": "Universal API documentation generator for Express, Fastify, Next.js, Vite, React Router, and Node.js",
3
+ "version": "1.3.6",
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",
7
7
  "module": "./dist/esm/index.js",
@@ -54,14 +54,14 @@
54
54
  },
55
55
  "dependencies": {
56
56
  "@clack/prompts": "^0.9.1",
57
- "axios": "^1.6.0"
57
+ "axios": "^1.6.0",
58
+ "ts-morph": "^28.0.0"
58
59
  },
59
60
  "devDependencies": {
60
61
  "@types/express": "^4.17.21",
61
62
  "@types/node": "^20.0.0",
62
63
  "fastify": "^5.12.1",
63
- "ts-morph": "^28.0.0",
64
64
  "typescript": "^5.3.0",
65
65
  "zod": "^4.5.4"
66
66
  }
67
- }
67
+ }