apiforge-analyzer 1.1.0 → 1.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,65 +1,85 @@
1
+ import { existsSync } from 'fs';
2
+ import { join } from 'path';
3
+ const cwd = () => process.cwd();
4
+ function fileExists(rel) {
5
+ return existsSync(join(cwd(), rel));
6
+ }
7
+ function tryResolve(mod) {
8
+ try {
9
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
10
+ require.resolve(mod, { paths: [cwd()] });
11
+ return true;
12
+ }
13
+ catch {
14
+ return false;
15
+ }
16
+ }
1
17
  /**
2
- * Framework detection utilities
3
- */
4
- /**
5
- * Detect which framework is being used
18
+ * Detect the web framework from the live app instance + filesystem hints.
19
+ * Confidence is used to pick the best match when multiple signals fire.
6
20
  */
7
21
  export function detectFramework(app) {
8
- // Express detection
22
+ // ── Instance-based detection (highest confidence) ─────────────────────────
9
23
  if (app && typeof app === 'function') {
24
+ // Express: _router or lazyrouter
10
25
  if (app._router || (app.lazyrouter && typeof app.lazyrouter === 'function')) {
11
26
  return { name: 'express', confidence: 1.0 };
12
27
  }
13
- }
14
- // Next.js detection
15
- try {
16
- const fs = require('fs');
17
- const path = require('path');
18
- // Check for Next.js Pages Router
19
- if (fs.existsSync(path.join(process.cwd(), 'pages'))) {
20
- const hasApiFolder = fs.existsSync(path.join(process.cwd(), 'pages', 'api'));
21
- if (hasApiFolder) {
22
- return { name: 'nextjs-pages', confidence: 0.9 };
23
- }
24
- }
25
- // Check for Next.js App Router
26
- if (fs.existsSync(path.join(process.cwd(), 'app'))) {
27
- return { name: 'nextjs-app', confidence: 0.9 };
28
+ // Koa: middleware array
29
+ if (Array.isArray(app.middleware) && typeof app.use === 'function' && app.context) {
30
+ return { name: 'koa', confidence: 0.95 };
28
31
  }
29
- // Check for next.config
30
- if (fs.existsSync(path.join(process.cwd(), 'next.config.js')) ||
31
- fs.existsSync(path.join(process.cwd(), 'next.config.mjs'))) {
32
- return { name: 'nextjs-pages', confidence: 0.7 };
33
- }
34
- }
35
- catch (e) {
36
- // Ignore fs errors
37
32
  }
38
- // Vite detection
39
- try {
40
- require.resolve('vite');
41
- return { name: 'vite', confidence: 0.8 };
33
+ // Fastify: inject method and route method
34
+ if (app &&
35
+ typeof app === 'object' &&
36
+ typeof app.inject === 'function' &&
37
+ typeof app.route === 'function') {
38
+ return { name: 'fastify', confidence: 1.0 };
42
39
  }
43
- catch (e) {
44
- // Vite not installed
40
+ // Hapi: server with route + start methods
41
+ if (app &&
42
+ typeof app === 'object' &&
43
+ typeof app.route === 'function' &&
44
+ typeof app.start === 'function' &&
45
+ typeof app.stop === 'function') {
46
+ return { name: 'hapi', confidence: 0.95 };
45
47
  }
46
- // React Router detection
47
- try {
48
- require.resolve('react-router-dom');
49
- return { name: 'react-router', confidence: 0.7 };
48
+ // NestJS application
49
+ if (app &&
50
+ typeof app === 'object' &&
51
+ typeof app.getHttpServer === 'function' &&
52
+ typeof app.listen === 'function') {
53
+ return { name: 'nestjs', confidence: 0.95 };
50
54
  }
51
- catch (e) {
52
- // React Router not installed
55
+ // ── Filesystem detection ──────────────────────────────────────────────────
56
+ // Next.js App Router
57
+ if (fileExists('app') && (fileExists('app/layout.tsx') || fileExists('app/layout.js'))) {
58
+ return { name: 'nextjs-app', confidence: 0.92 };
53
59
  }
54
- // Node HTTP detection
55
- try {
56
- const http = require('http');
57
- if (http) {
58
- return { name: 'node-http', confidence: 0.5 };
59
- }
60
+ // Next.js Pages Router
61
+ if (fileExists('pages/api') || fileExists('src/pages/api')) {
62
+ return { name: 'nextjs-pages', confidence: 0.90 };
60
63
  }
61
- catch (e) {
62
- // HTTP module not available
64
+ if (fileExists('next.config.js') ||
65
+ fileExists('next.config.mjs') ||
66
+ fileExists('next.config.ts')) {
67
+ return { name: 'nextjs-pages', confidence: 0.75 };
63
68
  }
69
+ // ── Dependency-based detection (lowest confidence) ────────────────────────
70
+ if (tryResolve('express'))
71
+ return { name: 'express', confidence: 0.70 };
72
+ if (tryResolve('fastify'))
73
+ return { name: 'fastify', confidence: 0.70 };
74
+ if (tryResolve('koa'))
75
+ return { name: 'koa', confidence: 0.70 };
76
+ if (tryResolve('@hapi/hapi') || tryResolve('hapi'))
77
+ return { name: 'hapi', confidence: 0.70 };
78
+ if (tryResolve('@nestjs/core'))
79
+ return { name: 'nestjs', confidence: 0.70 };
80
+ if (tryResolve('vite'))
81
+ return { name: 'vite', confidence: 0.60 };
82
+ if (tryResolve('react-router-dom'))
83
+ return { name: 'react-router', confidence: 0.55 };
64
84
  return { name: 'unknown', confidence: 0 };
65
85
  }
@@ -1,7 +1,7 @@
1
1
  /**
2
- * Express analyzer — accurate route + payload extraction
2
+ * Express route analyzer — deep extraction of routes, parameters, and auth patterns
3
3
  */
4
- // ─── Path normalisation ───────────────────────────────────────────────────────
4
+ // ─── Path helpers ─────────────────────────────────────────────────────────────
5
5
  function normalizePath(raw) {
6
6
  return raw
7
7
  .replace(/\\\//g, '/')
@@ -10,11 +10,10 @@ function normalizePath(raw) {
10
10
  .replace(/\\\w/g, '')
11
11
  .replace(/[\^\$\?\\]/g, '')
12
12
  .replace(/\/+/g, '/')
13
- .replace(/\/$/, '')
14
- || '/';
13
+ .replace(/\/$/, '') || '/';
15
14
  }
16
15
  function tagFromPath(path) {
17
- const skip = new Set(['api', 'v1', 'v2', 'v3', 'v4']);
16
+ const skip = new Set(['api', 'v1', 'v2', 'v3', 'v4', 'v5']);
18
17
  const meaningful = path
19
18
  .split('/')
20
19
  .filter(Boolean)
@@ -24,18 +23,18 @@ function tagFromPath(path) {
24
23
  function generateSummary(method, path) {
25
24
  const parts = path.split('/').filter(s => s && !s.startsWith(':'));
26
25
  const action = parts[parts.length - 1] || '';
27
- const resource = parts[parts.length - 2] || '';
26
+ const res = parts[parts.length - 2] || '';
28
27
  const map = {
29
- GET: action ? `Get ${action}` : `List ${resource}`,
30
- POST: action ? `Create ${action}` : `Create ${resource}`,
31
- PUT: action ? `Update ${action}` : `Update ${resource}`,
32
- PATCH: action ? `Patch ${action}` : `Patch ${resource}`,
33
- DELETE: action ? `Delete ${action}` : `Delete ${resource}`,
28
+ GET: action ? `Get ${action}` : `List ${res}`,
29
+ POST: action ? `Create ${action}` : `Create ${res}`,
30
+ PUT: action ? `Update ${action}` : `Replace ${res}`,
31
+ PATCH: action ? `Patch ${action}` : `Patch ${res}`,
32
+ DELETE: action ? `Delete ${action}` : `Delete ${res}`,
34
33
  };
35
34
  const base = map[method] || `${method} ${path}`;
36
35
  return (base.charAt(0).toUpperCase() + base.slice(1)).replace(/[-_]/g, ' ');
37
36
  }
38
- // ─── Main analyser ────────────────────────────────────────────────────────────
37
+ // ─── Main export ─────────────────────────────────────────────────────────────
39
38
  export function analyzeExpress(app) {
40
39
  const routes = [];
41
40
  const seen = new Set();
@@ -50,36 +49,45 @@ export function analyzeExpress(app) {
50
49
  console.warn('⚠️ No routes found — ensure routes are registered before analyze()');
51
50
  return routes;
52
51
  }
52
+ walkStack(stack, '', routes, seen);
53
+ return routes;
54
+ }
55
+ function walkStack(stack, basePath, out, seen) {
53
56
  for (const layer of stack) {
54
57
  if (layer.route) {
55
- const r = buildRoute(layer.route, '');
58
+ const r = buildRoute(layer.route, basePath);
56
59
  if (r && !seen.has(r.method + r.path)) {
57
- routes.push(r);
60
+ out.push(r);
58
61
  seen.add(r.method + r.path);
59
62
  }
60
63
  }
61
64
  else if (layer.handle?.stack) {
62
- let base = '';
65
+ // Router or sub-app mounted at a prefix
66
+ let prefix = '';
63
67
  if (layer.regexp?.source)
64
- base = normalizePath(layer.regexp.source);
68
+ prefix = normalizePath(layer.regexp.source);
65
69
  else if (layer.path)
66
- base = normalizePath(layer.path);
70
+ prefix = normalizePath(layer.path);
67
71
  else if (typeof layer.route === 'string')
68
- base = normalizePath(layer.route);
69
- for (const sub of layer.handle.stack) {
70
- if (sub.route) {
71
- const r = buildRoute(sub.route, base);
72
- if (r && !seen.has(r.method + r.path)) {
73
- routes.push(r);
74
- seen.add(r.method + r.path);
75
- }
76
- }
77
- }
72
+ prefix = normalizePath(layer.route);
73
+ const combined = combinePaths(basePath, prefix);
74
+ walkStack(layer.handle.stack, combined, out, seen);
78
75
  }
79
76
  }
80
- return routes;
77
+ }
78
+ function combinePaths(base, suffix) {
79
+ const b = base === '/' ? '' : base;
80
+ const s = suffix === '/' ? '' : suffix;
81
+ return b + s || '/';
81
82
  }
82
83
  // ─── Route builder ────────────────────────────────────────────────────────────
84
+ const AUTH_NAMES = new Set([
85
+ 'authenticateToken', 'authenticate', 'verifyToken', 'requireAuth',
86
+ 'auth', 'protect', 'isAuth', 'checkAuth', 'jwtAuth', 'bearerAuth',
87
+ 'isAuthenticated', 'requireLogin', 'ensureAuth', 'verifyJWT',
88
+ 'checkToken', 'authMiddleware', 'jwt', 'bearer', 'apiKeyAuth',
89
+ 'passport.authenticate',
90
+ ]);
83
91
  function buildRoute(route, basePath) {
84
92
  if (!route.path)
85
93
  return null;
@@ -100,59 +108,44 @@ function buildRoute(route, basePath) {
100
108
  const middleware = stack
101
109
  .map((l) => l.name)
102
110
  .filter((n) => n && n !== '<anonymous>' && n !== 'bound dispatch' && n !== 'bound ');
103
- // ── Join all handler source text ───────────────────────────────────────────
111
+ // ── Join all handler source ────────────────────────────────────────────────
104
112
  const src = stack.map((l) => l.handle?.toString() ?? '').join('\n');
105
- // ── Path params (from route pattern) ──────────────────────────────────────
113
+ // ── Path params from route pattern ─────────────────────────────────────────
106
114
  const pathParams = unique([...fullPath.matchAll(/:(\w+)/g)].map(m => m[1]));
107
115
  // ── Query params ───────────────────────────────────────────────────────────
108
116
  const queryParams = unique([
109
- // req.query.foo
110
117
  ...matchAll(src, /req\.query\.([a-zA-Z0-9_$]+)/g),
111
- // { foo, bar } = req.query / { foo: renamed } = req.query
112
118
  ...destructured(src, /(?:const|let|var)\s+\{([^}]+)\}\s*=\s*req\.query/g),
113
- // req.query['foo']
114
119
  ...matchAll(src, /req\.query\[['"]([a-zA-Z0-9_$]+)['"]\]/g),
115
120
  ]);
116
121
  // ── Request body fields ────────────────────────────────────────────────────
117
122
  const requestBodyFields = unique([
118
- // req.body.foo
119
123
  ...matchAll(src, /req\.body\.([a-zA-Z0-9_$]+)/g),
120
- // { foo, bar: alias, baz = default } = req.body
121
124
  ...destructured(src, /(?:const|let|var)\s+\{([^}]+)\}\s*=\s*req\.body/g),
122
- // req.body['foo']
123
125
  ...matchAll(src, /req\.body\[['"]([a-zA-Z0-9_$]+)['"]\]/g),
124
- // body.foo (when `const body = req.body`)
125
126
  ...bodyAliasFields(src),
126
- // Zod: z.object({ field: z.string() }).parse(req.body)
127
127
  ...zodFields(src),
128
- // Joi: Joi.object({ field: Joi.string() }).validate(req.body)
129
128
  ...joiFields(src),
129
+ ...typeInterfaceFields(src),
130
130
  ]);
131
131
  // ── Header params ──────────────────────────────────────────────────────────
132
132
  const headerParams = unique([
133
133
  ...matchAll(src, /req\.headers\[['"]([a-zA-Z0-9_$-]+)['"]\]/g),
134
134
  ...matchAll(src, /req\.get\s*\(\s*['"]([a-zA-Z0-9_$-]+)['"]\s*\)/g),
135
- // req.headers.authorization, req.headers['x-api-key'], etc.
136
135
  ...matchAll(src, /req\.headers\.([a-zA-Z0-9_$]+)/g),
137
136
  ]);
138
- // ── Response status codes from `res.status(nnn)` ──────────────────────────
137
+ // ── Response status codes ──────────────────────────────────────────────────
139
138
  const responseStatuses = unique([...src.matchAll(/res\.status\s*\(\s*(\d{3})\s*\)/g)].map(m => parseInt(m[1], 10)));
140
139
  // ── Auth detection ─────────────────────────────────────────────────────────
141
- const AUTH_NAMES = new Set([
142
- 'authenticateToken', 'authenticate', 'verifyToken', 'requireAuth', 'auth',
143
- 'protect', 'isAuth', 'checkAuth', 'jwtAuth', 'bearerAuth', 'isAuthenticated',
144
- 'requireLogin', 'ensureAuth', 'verifyJWT', 'checkToken', 'authMiddleware',
145
- 'passport.authenticate', 'jwt', 'bearer',
146
- ]);
147
140
  const hasAuth = middleware.some(m => AUTH_NAMES.has(m) || [...AUTH_NAMES].some(a => m.toLowerCase().includes(a.toLowerCase()))) ||
148
- /req\.user|req\.auth|verifyToken|jwt\.verify|passport\.authenticate/.test(src);
141
+ /req\.user|req\.auth|req\.currentUser|verifyToken|jwt\.verify|passport\.authenticate/.test(src);
149
142
  const method = methods[0];
150
143
  const tag = tagFromPath(fullPath);
151
144
  const summary = generateSummary(method, fullPath);
152
145
  const operationId = `${method.toLowerCase()}_${fullPath
153
146
  .replace(/[^a-zA-Z0-9]/g, '_')
154
147
  .replace(/_+/g, '_')
155
- .replace(/^_|_$/g, '')}`;
148
+ .replace(/^_|_$/, '')}`;
156
149
  return {
157
150
  method,
158
151
  path: fullPath,
@@ -172,49 +165,33 @@ function buildRoute(route, basePath) {
172
165
  };
173
166
  }
174
167
  // ─── Extraction helpers ───────────────────────────────────────────────────────
175
- /** Run a global regex and return capture group 1 for every match. */
176
168
  function matchAll(src, re) {
177
169
  return [...src.matchAll(re)].map(m => m[1]).filter(Boolean);
178
170
  }
179
- /**
180
- * Extract field names from all destructuring patterns like:
181
- * const { a, b: alias, c = default, d: alias2 = default } = req.body
182
- * Runs ALL occurrences of the given regex, not just the first.
183
- */
184
171
  function destructured(src, re) {
185
172
  const results = [];
186
173
  for (const match of src.matchAll(re)) {
187
174
  match[1]
188
175
  .split(',')
189
- .map(s => s.trim())
190
- // handle "alias: original" — we want the local binding (left side)
191
- .map(s => s.split(':')[0].trim())
192
- // strip default value
193
- .map(s => s.split('=')[0].trim())
176
+ .map(s => s.trim().split(':')[0].trim().split('=')[0].trim())
194
177
  .filter(Boolean)
195
178
  .forEach(f => results.push(f));
196
179
  }
197
180
  return results;
198
181
  }
199
- /**
200
- * When a developer writes `const body = req.body` and then accesses `body.foo`.
201
- */
202
182
  function bodyAliasFields(src) {
203
- const aliasMatch = src.match(/(?:const|let|var)\s+(\w+)\s*=\s*req\.body\b/);
204
- if (!aliasMatch)
183
+ const m = src.match(/(?:const|let|var)\s+(\w+)\s*=\s*req\.body\b/);
184
+ if (!m)
205
185
  return [];
206
- const alias = aliasMatch[1];
186
+ const alias = m[1];
207
187
  if (alias === 'body')
208
188
  return matchAll(src, /body\.([a-zA-Z0-9_$]+)/g);
209
189
  const re = new RegExp(`\\b${alias}\\.([a-zA-Z0-9_$]+)`, 'g');
210
190
  return matchAll(src, re);
211
191
  }
212
- /** Extract field names from Zod object schemas. */
213
192
  function zodFields(src) {
214
193
  const results = [];
215
- // z.object({ field: z.xxx(), ... }).parse(req.body) or .safeParse
216
- const zodRe = /z\.object\s*\(\s*\{([^}]+)\}/g;
217
- for (const match of src.matchAll(zodRe)) {
194
+ for (const match of src.matchAll(/z\.object\s*\(\s*\{([^}]+)\}/g)) {
218
195
  match[1]
219
196
  .split(',')
220
197
  .map(s => s.trim().split(':')[0].trim())
@@ -223,12 +200,9 @@ function zodFields(src) {
223
200
  }
224
201
  return results;
225
202
  }
226
- /** Extract field names from Joi object schemas. */
227
203
  function joiFields(src) {
228
204
  const results = [];
229
- // Joi.object({ field: Joi.xxx() }) or yup.object().shape({ field: ... })
230
- const joiRe = /(?:Joi|yup)\.object\s*\(?\s*(?:\.shape\s*\()?\s*\{([^}]+)\}/g;
231
- for (const match of src.matchAll(joiRe)) {
205
+ for (const match of src.matchAll(/(?:Joi|yup)\.object\s*\(?\s*(?:\.shape\s*\()?\s*\{([^}]+)\}/g)) {
232
206
  match[1]
233
207
  .split(',')
234
208
  .map(s => s.trim().split(':')[0].trim())
@@ -237,6 +211,38 @@ function joiFields(src) {
237
211
  }
238
212
  return results;
239
213
  }
214
+ /**
215
+ * Detects TypeScript interface/type fields used as request body type parameters.
216
+ * Handles patterns like:
217
+ * const { name, email } = req.body as CreateUserDto
218
+ * function handler(req: Request<{}, {}, CreateUserBody>) { ... }
219
+ * This runs the interface declaration through a lightweight regex rather than a
220
+ * full TS AST to keep the package zero-dependency.
221
+ */
222
+ function typeInterfaceFields(src) {
223
+ const results = [];
224
+ // Extract type name used as body generic: Request<P, ResBody, BodyType>
225
+ const bodyTypeNames = new Set();
226
+ for (const m of src.matchAll(/Request\s*<[^,]*,[^,]*,\s*([A-Z][a-zA-Z0-9_]*)\s*[,>]/g)) {
227
+ bodyTypeNames.add(m[1]);
228
+ }
229
+ // `req.body as SomeType`
230
+ for (const m of src.matchAll(/req\.body\s+as\s+([A-Z][a-zA-Z0-9_]*)/g)) {
231
+ bodyTypeNames.add(m[1]);
232
+ }
233
+ for (const typeName of bodyTypeNames) {
234
+ // Find interface { field: type; ... } or type alias { field: type }
235
+ const blockRe = new RegExp(`(?:interface|type)\\s+${typeName}\\s*(?:extends[^{]+)?\\{([^}]+)\\}`, 'g');
236
+ for (const match of src.matchAll(blockRe)) {
237
+ match[1]
238
+ .split(/[;,\n]/)
239
+ .map(line => line.trim().split(/[?:]/)[0].trim())
240
+ .filter(f => /^[a-zA-Z_$]/.test(f))
241
+ .forEach(f => results.push(f));
242
+ }
243
+ }
244
+ return results;
245
+ }
240
246
  function unique(items) {
241
247
  return Array.from(new Set(items.filter(Boolean)));
242
248
  }
package/dist/esm/index.js CHANGED
@@ -1,6 +1,8 @@
1
1
  /**
2
- * Main analyzer entry point with dual module support
2
+ * APIForge Analyzer — main entry point (dual ESM/CJS)
3
3
  */
4
+ import { writeFileSync, readFileSync } from 'fs';
5
+ import { join } from 'path';
4
6
  import { analyzeExpress } from './frameworks/express.js';
5
7
  import { analyzeNextPages } from './frameworks/nextjs-pages.js';
6
8
  import { analyzeNextApp } from './frameworks/nextjs-app.js';
@@ -8,84 +10,143 @@ import { analyzeVite } from './frameworks/vite.js';
8
10
  import { analyzeReactRouter } from './frameworks/react-router.js';
9
11
  import { detectFramework } from './detect.js';
10
12
  import { exportToJSON } from './export.js';
11
- import { uploadRoutes } from './uploader.js';
12
- // Global state for debouncing
13
- const analysisState = new Map();
14
- const DEBOUNCE_TIME = 5000;
13
+ import { uploadRoutes, writeStatusFile } from './uploader.js';
14
+ // ─── Env file loader — no dotenv dependency ───────────────────────────────────
15
15
  /**
16
- * Main analyze function with auto-detection
16
+ * Read a single variable from a .env-style file.
17
+ * Plain Node/Express doesn't auto-load .env.local, so we do it ourselves.
18
+ * The package never requires dotenv or any other dep for this.
17
19
  */
20
+ function readEnvFile(varName) {
21
+ const candidates = [
22
+ '.env.local',
23
+ '.env.development.local',
24
+ '.env.development',
25
+ '.env',
26
+ ];
27
+ for (const file of candidates) {
28
+ try {
29
+ const content = readFileSync(join(process.cwd(), file), 'utf-8');
30
+ for (const line of content.split('\n')) {
31
+ const trimmed = line.trim();
32
+ if (trimmed.startsWith('#') || !trimmed.includes('='))
33
+ continue;
34
+ const eqIdx = trimmed.indexOf('=');
35
+ const key = trimmed.slice(0, eqIdx).trim();
36
+ if (key !== varName)
37
+ continue;
38
+ return trimmed.slice(eqIdx + 1).trim().replace(/^['"]|['"]$/g, '');
39
+ }
40
+ }
41
+ catch { }
42
+ }
43
+ return undefined;
44
+ }
45
+ // ─── File-based debounce (survives nodemon restarts) ──────────────────────────
46
+ const DEBOUNCE_FILE = '.apiforge-last-run.json';
47
+ const DEBOUNCE_MS = 30000; // 30 s
48
+ function readLastRunMs() {
49
+ try {
50
+ const raw = readFileSync(join(process.cwd(), DEBOUNCE_FILE), 'utf-8');
51
+ return JSON.parse(raw).timestamp ?? 0;
52
+ }
53
+ catch {
54
+ return 0;
55
+ }
56
+ }
57
+ function stampLastRun() {
58
+ try {
59
+ writeFileSync(join(process.cwd(), DEBOUNCE_FILE), JSON.stringify({ timestamp: Date.now() }), 'utf-8');
60
+ }
61
+ catch { }
62
+ }
63
+ // ─── Main analyze ─────────────────────────────────────────────────────────────
18
64
  export async function analyze(app, options) {
19
- // Skip in production
65
+ // Never run in production
20
66
  if (options.skipInProduction !== false && process.env.NODE_ENV === 'production') {
21
- console.log('⏭️ Skipping analysis in production');
22
67
  return [];
23
68
  }
24
- // Debounce check
69
+ // File-based debounce — survives nodemon restarts
25
70
  if (options.runOnce !== false) {
26
- const cached = analysisState.get(options.projectName);
27
- const now = Date.now();
28
- if (cached && (now - cached.timestamp) < DEBOUNCE_TIME) {
29
- console.log(`⚡ Analysis skipped - already ran ${Math.round((now - cached.timestamp) / 1000)}s ago`);
30
- return cached.routes;
71
+ const elapsed = Date.now() - readLastRunMs();
72
+ if (elapsed < DEBOUNCE_MS) {
73
+ console.log(`⚡ APIForge: skipped (last run ${Math.round(elapsed / 1000)}s ago)`);
74
+ return [];
31
75
  }
76
+ stampLastRun();
77
+ }
78
+ // ── Resolve API key ─────────────────────────────────────────────────────────
79
+ // Priority: explicit option → process.env → .env.local/.env file
80
+ // This means the package works even if the user's server doesn't load dotenv.
81
+ if (!options.apiKey) {
82
+ options.apiKey =
83
+ process.env.APIFORGE_API_KEY ||
84
+ readEnvFile('APIFORGE_API_KEY');
32
85
  }
33
- console.log(`🔍 Analyzing ${options.projectName}...`);
34
- // CRITICAL: Prevent restart loops - force saveJson=false when uploading
86
+ console.log(`🔍 APIForge: analyzing ${options.projectName}…`);
87
+ // Writing saveJson files into the watched dir triggers nodemon restart loops
35
88
  if (options.upload !== false && options.apiKey) {
36
89
  options.saveJson = false;
37
90
  }
91
+ // ── Early failure: upload desired but no API key anywhere ──────────────────
92
+ if (options.upload !== false && !options.apiKey) {
93
+ const msg = 'APIFORGE_API_KEY not found in options, process.env, or .env.local';
94
+ console.error(`❌ APIForge: ${msg}`);
95
+ writeStatusFile('error', { error: msg });
96
+ return [];
97
+ }
38
98
  let routes = [];
39
99
  let framework = options.framework;
40
- // Auto-detect framework if not specified
41
100
  if (!framework) {
42
- const detected = detectFramework(app);
43
- framework = detected.name;
101
+ framework = detectFramework(app).name;
44
102
  console.log(`📦 Detected framework: ${framework}`);
45
103
  }
46
- // Analyze based on framework
47
- switch (framework) {
48
- case 'express':
49
- if (!app) {
50
- console.error('❌ Express app instance required');
104
+ try {
105
+ switch (framework) {
106
+ case 'express':
107
+ if (!app) {
108
+ const msg = 'Express app instance required — pass your app object to analyze()';
109
+ console.error(`❌ ${msg}`);
110
+ writeStatusFile('error', { error: msg });
111
+ return [];
112
+ }
113
+ routes = await analyzeExpress(app);
114
+ break;
115
+ case 'nextjs-pages':
116
+ routes = await analyzeNextPages(options.nextDir || 'pages/api');
117
+ break;
118
+ case 'nextjs-app':
119
+ routes = await analyzeNextApp(options.nextDir || 'app');
120
+ break;
121
+ case 'vite':
122
+ routes = await analyzeVite(options.nextDir || 'vite.config.js');
123
+ break;
124
+ case 'react-router':
125
+ routes = await analyzeReactRouter(options.nextDir || 'src/routes.tsx');
126
+ break;
127
+ default:
128
+ const msg = `Framework "${framework}" not supported yet (express, nextjs-pages, nextjs-app, vite, react-router)`;
129
+ console.warn(`⚠️ APIForge: ${msg}`);
130
+ writeStatusFile('error', { error: msg });
51
131
  return [];
52
- }
53
- routes = await analyzeExpress(app);
54
- break;
55
- case 'nextjs-pages':
56
- routes = await analyzeNextPages(options.nextDir || 'pages/api');
57
- break;
58
- case 'nextjs-app':
59
- routes = await analyzeNextApp(options.nextDir || 'app');
60
- break;
61
- case 'vite':
62
- routes = await analyzeVite(options.nextDir || 'vite.config.js');
63
- break;
64
- case 'react-router':
65
- routes = await analyzeReactRouter(options.nextDir || 'src/routes.tsx');
66
- break;
67
- case 'node-http':
68
- case 'unknown':
69
- default:
70
- console.warn(`⚠️ Framework "${framework}" not supported yet`);
71
- console.log('💡 Supported: express, nextjs-pages, nextjs-app, vite, react-router');
72
- console.log('💡 Tip: Specify framework manually: { framework: "express" }');
73
- return [];
132
+ }
74
133
  }
75
- console.log(`✅ Found ${routes.length} routes`);
76
- // Cache results
77
- if (options.runOnce !== false) {
78
- analysisState.set(options.projectName, {
79
- timestamp: Date.now(),
80
- routes
81
- });
134
+ catch (err) {
135
+ const msg = err.message || 'Unknown error during route extraction';
136
+ console.error(`❌ APIForge analysis error: ${msg}`);
137
+ writeStatusFile('error', { error: msg });
138
+ return [];
82
139
  }
83
- // Save JSON (only if not uploading)
140
+ if (routes.length === 0) {
141
+ const msg = 'No routes found — ensure routes are registered before analyze() is called';
142
+ console.warn(`⚠️ APIForge: ${msg}`);
143
+ writeStatusFile('error', { error: msg });
144
+ return [];
145
+ }
146
+ console.log(`✅ Found ${routes.length} routes`);
84
147
  if (options.saveJson) {
85
- const filePath = options.jsonPath || 'api-export.json';
86
- await exportToJSON(routes, options.projectName, filePath, options.baseUrl);
148
+ await exportToJSON(routes, options.projectName, options.jsonPath || 'api-export.json', options.baseUrl);
87
149
  }
88
- // Upload to APIForge
89
150
  if (options.upload !== false && options.apiKey) {
90
151
  await uploadRoutes(routes, {
91
152
  projectName: options.projectName,
@@ -96,12 +157,11 @@ export async function analyze(app, options) {
96
157
  }
97
158
  return routes;
98
159
  }
99
- // Re-export framework-specific analyzers
160
+ // Re-exports for library consumers
100
161
  export { analyzeExpress, analyzeNextPages, analyzeNextApp, analyzeVite, analyzeReactRouter };
101
162
  export { exportToJSON } from './export.js';
102
163
  export { uploadRoutes } from './uploader.js';
103
164
  export { detectFramework } from './detect.js';
104
- // Default export for CommonJS
105
165
  export default {
106
166
  analyze,
107
167
  analyzeExpress,
@@ -111,5 +171,5 @@ export default {
111
171
  analyzeReactRouter,
112
172
  exportToJSON,
113
173
  uploadRoutes,
114
- detectFramework
174
+ detectFramework,
115
175
  };