apiforge-analyzer 1.3.5 → 1.3.7

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
  },
@@ -668,6 +670,7 @@ async function microservicesFlow(config, opts) {
668
670
  // Kill service process (and its full descendant tree — see killProcessTree)
669
671
  if (child.pid)
670
672
  killProcessTree(child.pid);
673
+ await killOrphanFromEaddrinuse(output);
671
674
  tryUnlink((0, path_1.join)(cwd, statusFile));
672
675
  if (routes && routes.length > 0) {
673
676
  const prefix = gatewayPrefixes[svc.name];
@@ -796,6 +799,46 @@ function killProcessTree(pid) {
796
799
  catch { }
797
800
  }
798
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
+ }
799
842
  const NODEMON_IGNORE_PATTERNS = ['.apiforge-status.json', '.apiforge-last-run.json', '.env.local'];
800
843
  /**
801
844
  * If the project depends on nodemon, make sure it won't watch our own
@@ -875,7 +918,9 @@ function startAndWaitForAnalysis(command, timeoutMs) {
875
918
  clearTimeout(timeout);
876
919
  if (child.pid)
877
920
  killProcessTree(child.pid);
878
- resolve({ ...result, serverOutput: serverOutput.trim() });
921
+ killOrphanFromEaddrinuse(serverOutput).then(() => {
922
+ resolve({ ...result, serverOutput: serverOutput.trim() });
923
+ });
879
924
  }
880
925
  // Poll for the status file every 500 ms
881
926
  const poller = setInterval(() => {
@@ -1228,11 +1273,37 @@ function detectScripts() {
1228
1273
  return [];
1229
1274
  }
1230
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
+ }
1231
1281
  function injectCode(filePath, projectName, apiKey, baseUrl, backendUrl, architectureType, collectionOpts) {
1232
1282
  const fullPath = (0, path_1.join)(process.cwd(), filePath);
1233
- const content = (0, fs_1.readFileSync)(fullPath, 'utf-8');
1234
- if (content.includes('apiforge-analyzer')) {
1235
- 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
+ }
1236
1307
  }
1237
1308
  const isESM = detectESM(filePath, content);
1238
1309
  const appVar = detectAppVariable(content) ?? 'app';
@@ -1258,7 +1329,7 @@ function injectCode(filePath, projectName, apiKey, baseUrl, backendUrl, architec
1258
1329
  injectionPoint = 'end of file';
1259
1330
  }
1260
1331
  (0, fs_1.writeFileSync)(fullPath, newContent, 'utf-8');
1261
- return { alreadyInjected: false, injectionPoint, linesAdded: snippet.split('\n').length };
1332
+ return { alreadyInjected: false, injectionPoint, linesAdded: snippet.split('\n').length, updated: wasStale };
1262
1333
  }
1263
1334
  /**
1264
1335
  * Write (or overwrite) the API key in .env.local.
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
  },
@@ -663,6 +665,7 @@ async function microservicesFlow(config, opts) {
663
665
  // Kill service process (and its full descendant tree — see killProcessTree)
664
666
  if (child.pid)
665
667
  killProcessTree(child.pid);
668
+ await killOrphanFromEaddrinuse(output);
666
669
  tryUnlink(join(cwd, statusFile));
667
670
  if (routes && routes.length > 0) {
668
671
  const prefix = gatewayPrefixes[svc.name];
@@ -791,6 +794,46 @@ function killProcessTree(pid) {
791
794
  catch { }
792
795
  }
793
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
+ }
794
837
  const NODEMON_IGNORE_PATTERNS = ['.apiforge-status.json', '.apiforge-last-run.json', '.env.local'];
795
838
  /**
796
839
  * If the project depends on nodemon, make sure it won't watch our own
@@ -870,7 +913,9 @@ function startAndWaitForAnalysis(command, timeoutMs) {
870
913
  clearTimeout(timeout);
871
914
  if (child.pid)
872
915
  killProcessTree(child.pid);
873
- resolve({ ...result, serverOutput: serverOutput.trim() });
916
+ killOrphanFromEaddrinuse(serverOutput).then(() => {
917
+ resolve({ ...result, serverOutput: serverOutput.trim() });
918
+ });
874
919
  }
875
920
  // Poll for the status file every 500 ms
876
921
  const poller = setInterval(() => {
@@ -1223,11 +1268,37 @@ function detectScripts() {
1223
1268
  return [];
1224
1269
  }
1225
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
+ }
1226
1276
  function injectCode(filePath, projectName, apiKey, baseUrl, backendUrl, architectureType, collectionOpts) {
1227
1277
  const fullPath = join(process.cwd(), filePath);
1228
- const content = readFileSync(fullPath, 'utf-8');
1229
- if (content.includes('apiforge-analyzer')) {
1230
- 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
+ }
1231
1302
  }
1232
1303
  const isESM = detectESM(filePath, content);
1233
1304
  const appVar = detectAppVariable(content) ?? 'app';
@@ -1253,7 +1324,7 @@ function injectCode(filePath, projectName, apiKey, baseUrl, backendUrl, architec
1253
1324
  injectionPoint = 'end of file';
1254
1325
  }
1255
1326
  writeFileSync(fullPath, newContent, 'utf-8');
1256
- return { alreadyInjected: false, injectionPoint, linesAdded: snippet.split('\n').length };
1327
+ return { alreadyInjected: false, injectionPoint, linesAdded: snippet.split('\n').length, updated: wasStale };
1257
1328
  }
1258
1329
  /**
1259
1330
  * Write (or overwrite) the API key in .env.local.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "apiforge-analyzer",
3
- "version": "1.3.5",
3
+ "version": "1.3.7",
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",