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 +238 -40
- package/dist/esm/cli.js +238 -40
- package/package.json +5 -5
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
658
|
-
child.
|
|
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
|
-
|
|
789
|
-
child.
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
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
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
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
|
-
//
|
|
1220
|
-
//
|
|
1221
|
-
//
|
|
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
|
-
`
|
|
1226
|
-
`
|
|
1227
|
-
`
|
|
1228
|
-
`
|
|
1229
|
-
`
|
|
1230
|
-
`
|
|
1231
|
-
`
|
|
1232
|
-
`
|
|
1233
|
-
`
|
|
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
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
653
|
-
child.
|
|
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
|
-
|
|
784
|
-
child.
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
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
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
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
|
-
//
|
|
1215
|
-
//
|
|
1216
|
-
//
|
|
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
|
-
`
|
|
1221
|
-
`
|
|
1222
|
-
`
|
|
1223
|
-
`
|
|
1224
|
-
`
|
|
1225
|
-
`
|
|
1226
|
-
`
|
|
1227
|
-
`
|
|
1228
|
-
`
|
|
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
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
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
|
-
|
|
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.
|
|
4
|
-
"description": "Universal API documentation generator for Express,
|
|
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
|
+
}
|