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.
package/dist/esm/cli.js CHANGED
@@ -1,245 +1,382 @@
1
1
  #!/usr/bin/env node
2
- import { group, intro, outro, text, select, password, tasks, note, log, isCancel, cancel, } from '@clack/prompts';
3
- import { readFileSync, existsSync, writeFileSync } from 'fs';
2
+ import { group, intro, outro, text, select, password, confirm, tasks, note, log, isCancel, cancel, spinner, } from '@clack/prompts';
3
+ import { readFileSync, existsSync, writeFileSync, unlinkSync } from 'fs';
4
4
  import { join } from 'path';
5
- // ─── ANSI helpers ─────────────────────────────────────────────────────────────
5
+ import { spawn } from 'child_process';
6
+ import axios from 'axios';
7
+ // ─── ANSI colour helpers ──────────────────────────────────────────────────────
6
8
  const r = '\x1b[0m';
7
9
  const b = '\x1b[1m';
8
10
  const d = '\x1b[2m';
9
11
  const cy = '\x1b[36m';
10
12
  const gn = '\x1b[32m';
11
13
  const yl = '\x1b[33m';
12
- const mg = '\x1b[35m';
14
+ const rd = '\x1b[31m';
13
15
  const wh = '\x1b[97m';
14
16
  const bold = (s) => `${b}${s}${r}`;
15
17
  const dim = (s) => `${d}${s}${r}`;
16
18
  const cyan = (s) => `${cy}${s}${r}`;
17
19
  const green = (s) => `${gn}${s}${r}`;
18
20
  const yellow = (s) => `${yl}${s}${r}`;
19
- const magenta = (s) => `${mg}${s}${r}`;
21
+ const red = (s) => `${rd}${s}${r}`;
20
22
  const white = (s) => `${wh}${s}${r}`;
21
- // ─── Brand header ─────────────────────────────────────────────────────────────
23
+ const VERSION = '1.2.0';
24
+ // Built-in URLs — users never need to configure these
25
+ const BACKEND_PROD = 'https://apiforgeapi.brainfogagency.com';
26
+ const DASHBOARD_PROD = 'https://apiforge.brainfogagency.com/dashboard';
27
+ // Local overrides — only used when testing APIForge itself
28
+ const BACKEND_LOCAL = 'http://localhost:4000';
29
+ const DASHBOARD_LOCAL = 'http://localhost:3000/dashboard';
30
+ const STATUS_FILE = '.apiforge-status.json';
31
+ const LOCK_FILE = '.apiforge-last-run.json';
32
+ // ─── Header ───────────────────────────────────────────────────────────────────
22
33
  function printHeader() {
23
- const lines = [
34
+ console.log([
24
35
  '',
25
36
  ` ${cyan('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━')}`,
26
37
  '',
27
- ` ${bold(white('⚡ APIForge'))} ${dim('v1.1.0')}`,
38
+ ` ${bold(white('⚡ APIForge'))} ${dim(`v${VERSION}`)}`,
28
39
  ` ${dim('AI-powered API documentation, auto-generated')}`,
29
40
  '',
30
41
  ` ${dim('Your entire API, documented in under 60 seconds.')}`,
31
42
  '',
32
43
  ` ${cyan('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━')}`,
33
44
  '',
34
- ];
35
- console.log(lines.join('\n'));
45
+ ].join('\n'));
36
46
  }
37
- // ─── Shared state across tasks ────────────────────────────────────────────────
38
- let detectedEntry = '';
39
- let detectedAppVar = 'app';
40
- let detectedIsESM = false;
41
- let detectedFramework = 'unknown';
42
- let injectionPoint = '';
43
- let wasAlreadySetup = false;
44
- let linesAdded = 0;
45
- // ─── Main ─────────────────────────────────────────────────────────────────────
47
+ // ─── Entry point ─────────────────────────────────────────────────────────────
46
48
  async function main() {
47
- process.stdout.write('\x1Bc'); // clear screen
49
+ process.stdout.write('\x1Bc');
48
50
  printHeader();
49
- intro(bold(` ${cyan('⚡')} APIForge Setup `));
50
- // ── Questions (grouped so cancel is handled once) ──────────────────────────
51
+ intro(bold(` ${cyan('⚡')} APIForge Analyzer `));
52
+ // ── Collect setup answers ─────────────────────────────────────────────────
51
53
  const answers = await group({
52
54
  projectName: () => text({
53
- message: 'Name your project',
54
- placeholder: 'My Awesome API',
55
- validate: (v) => (!v.trim() ? 'Project name cannot be empty' : undefined),
55
+ message: 'Project name',
56
+ placeholder: 'My API',
57
+ validate: v => (!v.trim() ? 'Required' : undefined),
56
58
  }),
57
59
  apiKey: () => password({
58
60
  message: `APIForge API key ${dim('(get one at apiforge.brainfogagency.com/dashboard)')}`,
59
- validate: (v) => {
61
+ validate: v => {
60
62
  if (!v.trim())
61
- return 'API key is required';
63
+ return 'Required';
62
64
  if (!v.trim().startsWith('afg_'))
63
65
  return 'Key must start with afg_';
64
66
  },
65
67
  }),
66
68
  baseUrl: () => text({
67
- message: 'Your server base URL',
69
+ message: 'Your server base URL (for documentation)',
68
70
  placeholder: 'https://api.myapp.com',
69
71
  initialValue: 'http://localhost:3000',
70
- validate: (v) => (!v.trim() ? 'Base URL is required' : undefined),
71
- }),
72
- method: () => select({
73
- message: 'Sync method',
74
- options: [
75
- {
76
- value: 'runtime',
77
- label: `${bold('Runtime')} ${dim('→')} auto-syncs on every server start`,
78
- hint: 'recommended',
79
- },
80
- {
81
- value: 'json',
82
- label: `${bold('Export JSON')} ${dim('→')} write api-export.json, import to dashboard`,
83
- },
84
- ],
72
+ validate: v => (!v.trim() ? 'Required' : undefined),
85
73
  }),
86
74
  }, {
87
75
  onCancel: () => {
88
- cancel(dim('Setup cancelled. Come back when you\'re ready.'));
76
+ cancel(dim('Cancelled.'));
89
77
  process.exit(0);
90
78
  },
91
79
  });
92
80
  if (isCancel(answers))
93
81
  process.exit(0);
94
- const { projectName, apiKey, baseUrl, method } = answers;
82
+ const { projectName, apiKey, baseUrl } = answers;
83
+ // ── Select run command ────────────────────────────────────────────────────
84
+ const scripts = detectScripts();
85
+ const scriptOptions = scripts.map(s => ({
86
+ value: s.command,
87
+ label: `${bold(s.name)} ${dim('→')} ${dim(s.command)}`,
88
+ }));
89
+ if (scriptOptions.length === 0) {
90
+ log.warn('No scripts found in package.json — please ensure you have a start/dev script.');
91
+ cancel('No run script available.');
92
+ process.exit(1);
93
+ }
94
+ const runCommand = await select({
95
+ message: 'Which command starts your server?',
96
+ options: scriptOptions,
97
+ });
98
+ if (isCancel(runCommand)) {
99
+ cancel('Cancelled.');
100
+ process.exit(0);
101
+ }
102
+ // ── Select backend ────────────────────────────────────────────────────────
103
+ const backendChoice = await select({
104
+ message: 'Send routes to',
105
+ options: [
106
+ {
107
+ value: 'production',
108
+ label: `${bold('Production')} ${dim('→')} ${dim(BACKEND_PROD)}`,
109
+ hint: 'recommended',
110
+ },
111
+ {
112
+ value: 'local',
113
+ label: `${bold('Local')} ${dim('→')} ${dim(BACKEND_LOCAL)}`,
114
+ hint: 'for testing APIForge locally',
115
+ },
116
+ ],
117
+ });
118
+ if (isCancel(backendChoice)) {
119
+ cancel('Cancelled.');
120
+ process.exit(0);
121
+ }
122
+ const isLocal = backendChoice === 'local';
123
+ const backendUrl = isLocal ? BACKEND_LOCAL : BACKEND_PROD;
124
+ const dashboardUrl = isLocal ? DASHBOARD_LOCAL : DASHBOARD_PROD;
95
125
  log.info(dim('─────────────────────────────────────────────────'));
96
126
  log.info(`${dim('Project:')} ${bold(projectName)}`);
97
- log.info(`${dim('Mode:')} ${bold(method === 'runtime' ? 'Runtime sync' : 'Export JSON')}`);
98
- log.info(`${dim('Base URL:')} ${cyan(baseUrl)}`);
127
+ log.info(`${dim('Command:')} ${bold(runCommand)}`);
128
+ log.info(`${dim('Backend:')} ${cyan(backendUrl)}`);
99
129
  log.info(dim('─────────────────────────────────────────────────'));
100
- // ── Need to ask for entry file? Do pre-scan first ─────────────────────────
101
- const preDetected = detectEntryFile();
102
- let entryFile;
103
- if (!preDetected) {
104
- log.warn('Could not auto-detect your server entry file.');
105
- const custom = await text({
106
- message: 'Path to your server entry file',
107
- placeholder: 'src/index.ts',
108
- validate: (v) => {
109
- if (!v.trim())
110
- return 'Required';
111
- if (!existsSync(join(process.cwd(), v.trim())))
112
- return `Not found: ${v.trim()}`;
113
- },
114
- });
115
- if (isCancel(custom)) {
116
- cancel('Setup cancelled.');
117
- process.exit(0);
118
- }
119
- entryFile = custom.trim();
120
- }
121
- else {
122
- entryFile = preDetected;
123
- }
124
- // ── Multi-step animated tasks ──────────────────────────────────────────────
130
+ // ── Detect & patch entry file ─────────────────────────────────────────────
131
+ const entryFile = detectEntryFile() ?? await askEntryFile();
132
+ let injected = false;
133
+ let linesAdded = 0;
125
134
  await tasks([
126
135
  {
127
- title: `Scanning project`,
128
- task: async (msg) => {
129
- await sleep(350);
130
- detectedEntry = entryFile;
131
- msg(`Reading ${detectedEntry}`);
132
- await sleep(250);
133
- return `${green('✓')} Entry file: ${bold(detectedEntry)}`;
134
- },
135
- },
136
- {
137
- title: 'Analysing server file',
136
+ title: 'Scanning project',
138
137
  task: async (msg) => {
139
138
  await sleep(200);
140
- const fullPath = join(process.cwd(), detectedEntry);
141
- const src = readFileSync(fullPath, 'utf-8');
142
- detectedIsESM = detectESM(detectedEntry, src);
143
- detectedAppVar = detectAppVariable(src) ?? 'app';
144
- detectedFramework = detectFramework(src);
145
- msg(`Detected ${detectedFramework}`);
146
- await sleep(300);
147
- const moduleType = detectedIsESM ? 'ESM' : 'CJS';
148
- return [
149
- `${green('✓')} Framework: ${bold(detectedFramework)}`,
150
- ` Module: ${bold(moduleType)}`,
151
- ` App var: ${bold(detectedAppVar)}`,
152
- ].join('\n');
139
+ msg(`Found entry file: ${entryFile}`);
140
+ await sleep(150);
141
+ return `${green('✓')} Entry: ${bold(entryFile)}`;
153
142
  },
154
143
  },
155
144
  {
156
145
  title: 'Patching server file',
157
146
  task: async (msg) => {
158
- await sleep(300);
159
- msg('Generating snippet…');
160
- await sleep(200);
161
- const result = injectCode(detectedEntry, projectName, apiKey, baseUrl, method);
162
- wasAlreadySetup = result.alreadyInjected;
163
- injectionPoint = result.injectionPoint ?? 'end of file';
147
+ msg('Injecting analyzer…');
148
+ await sleep(100);
149
+ // Clear stale status + lock files before starting
150
+ tryUnlink(join(process.cwd(), STATUS_FILE));
151
+ tryUnlink(join(process.cwd(), LOCK_FILE));
152
+ // Store API key in .env.local — never embed it in source code
153
+ persistApiKeyToEnv(apiKey);
154
+ const result = injectCode(entryFile, projectName, apiKey, baseUrl, backendUrl);
155
+ injected = !result.alreadyInjected;
164
156
  linesAdded = result.linesAdded ?? 0;
165
- msg(result.alreadyInjected ? 'Already configured' : `Writing to ${detectedEntry}`);
166
- await sleep(300);
167
157
  if (result.alreadyInjected) {
168
- return `${yellow('◌')} Already set up — no changes made`;
158
+ return `${yellow('◌')} Already configured — no changes made`;
169
159
  }
170
160
  return [
171
- `${green('✓')} Patched: ${bold(detectedEntry)} ${dim(`+${linesAdded} lines`)}`,
172
- ` At: ${dim(injectionPoint)}`,
161
+ `${green('✓')} Patched ${bold(entryFile)} ${dim(`+${linesAdded} lines`)}`,
162
+ ` ${green('✓')} API key saved to ${bold('.env.local')} ${dim('(gitignored)')}`,
173
163
  ].join('\n');
174
164
  },
175
165
  },
176
- {
177
- title: 'Verifying patch',
178
- task: async (msg) => {
179
- await sleep(200);
180
- if (wasAlreadySetup) {
181
- return `${yellow('◌')} Skipped — file was already configured`;
182
- }
183
- const src = readFileSync(join(process.cwd(), detectedEntry), 'utf-8');
184
- if (!src.includes('apiforge-analyzer')) {
185
- throw new Error('Patch verification failed — please add the snippet manually');
186
- }
187
- msg('Confirmed apiforge-analyzer in file');
188
- await sleep(200);
189
- return `${green('✓')} Verified`;
190
- },
191
- },
192
166
  ]);
193
- // ── Summary note ───────────────────────────────────────────────────────────
194
- if (wasAlreadySetup) {
195
- note([
196
- bold('Already good to go!'),
197
- '',
198
- dim(`APIForge is already configured in ${bold(detectedEntry)}.`),
199
- dim('No changes were made.'),
200
- ].join('\n'), 'Status');
167
+ // ── Start server + wait for analysis ──────────────────────────────────────
168
+ note([
169
+ bold('Starting your server…'),
170
+ '',
171
+ ` ${dim('Command:')} ${cyan(runCommand)}`,
172
+ ` ${dim('Watching')} .apiforge-status.json ${dim('for completion signal')}`,
173
+ ].join('\n'), 'Running');
174
+ const s = spinner();
175
+ s.start('Starting server and analyzing routes…');
176
+ const serverResult = await startAndWaitForAnalysis(runCommand, 90000);
177
+ if (serverResult.status === 'error') {
178
+ s.stop(red(`✗ ${serverResult.message}`));
179
+ // Give a targeted hint for common failure modes
180
+ if (serverResult.message.includes('API key')) {
181
+ log.warn(yellow('Hint: The CLI wrote your key to .env.local — but your server may not load it.'));
182
+ log.warn(yellow(' Add this at the top of your entry file before calling analyze():'));
183
+ log.info(` ${cyan("require('dotenv').config({ path: '.env.local' })")}`);
184
+ log.warn(yellow(' Or install dotenv: npm install dotenv'));
185
+ }
186
+ else if (serverResult.message.includes('No routes')) {
187
+ log.warn(yellow('Hint: Make sure all routes are registered before analyze() is called.'));
188
+ log.warn(yellow(' The analyze() call should come AFTER all router.use() / app.get() etc.'));
189
+ }
190
+ else if (serverResult.message.includes('Timed out')) {
191
+ log.warn(yellow('Hint: Your server might be taking too long to start, or the analyzer'));
192
+ log.warn(yellow(' is not being called. Check your server logs for APIForge messages.'));
193
+ }
194
+ await offerCleanup(entryFile, injected);
195
+ outro(red('Analysis failed.'));
196
+ process.exit(1);
201
197
  }
202
- else {
203
- const snippetPreview = getSnippetPreview(method, detectedAppVar);
198
+ s.stop(green(`✓ ${serverResult.routeCount} routes submitted to APIForge`));
199
+ // ── Poll for dashboard approval ───────────────────────────────────────────
200
+ const { pendingImportId } = serverResult;
201
+ note([
202
+ bold('Open your dashboard to review:'),
203
+ '',
204
+ ` ${cyan(dashboardUrl)}`,
205
+ '',
206
+ dim(`In "Import JSON" → "Pending CLI Imports"`),
207
+ dim('Accept to start AI processing · Deny to discard.'),
208
+ ].join('\n'), 'Action required');
209
+ const poll = spinner();
210
+ poll.start('Waiting for your decision in the dashboard… (5 min timeout)');
211
+ const approval = await pollForApproval(pendingImportId, apiKey, backendUrl, 5 * 60000);
212
+ if (approval.status === 'approved') {
213
+ poll.stop(green('✓ Accepted — AI processing started!'));
204
214
  note([
205
- ` ${dim('File')} ${bold(detectedEntry)} ${dim(`+${linesAdded} lines`)}`,
206
- ` ${dim('Mode')} ${bold(method === 'json' ? 'Export JSON' : 'Runtime sync')}`,
207
- ` ${dim('App var')} ${bold(detectedAppVar)} ${dim('(auto-detected)')}`,
208
- ` ${dim('Format')} ${bold(detectedIsESM ? 'ESM (import)' : 'CJS (require)')}`,
215
+ bold('Your API is being documented.'),
209
216
  '',
210
- dim('Injected snippet:'),
211
- '',
212
- ...snippetPreview.map(line => ` ${cyan('│')} ${dim(line)}`),
213
- ].join('\n'), 'Patch summary');
217
+ ` ${dim('Track progress:')} ${cyan(dashboardUrl)}`,
218
+ ` ${dim('Import ID:')} ${dim(pendingImportId)}`,
219
+ ].join('\n'), 'Processing');
214
220
  }
215
- // ── Next steps + outro ─────────────────────────────────────────────────────
216
- if (method === 'json') {
221
+ else if (approval.status === 'denied') {
222
+ poll.stop(red('✗ Import denied by dashboard.'));
217
223
  note([
218
- bold('What to do next:'),
219
- '',
220
- ` ${dim('1.')} Start your server once:`,
221
- ` ${cyan(getStartCommand())}`,
224
+ ` ${bold('You denied this import from the dashboard.')}`,
222
225
  '',
223
- ` ${dim('2.')} Upload ${bold('api-export.json')} to the dashboard:`,
224
- ` ${cyan('https://apiforge.brainfogagency.com/dashboard')}`,
225
- '',
226
- ` ${dim('3.')} Your AI-enhanced docs appear in seconds.`,
227
- ].join('\n'), 'Next steps');
228
- outro(`${green('✓')} All set — run your server to generate the export.`);
226
+ ` ${dim('No documentation was generated.')}`,
227
+ ` ${dim('Run')} ${cyan('npx apiforge')} ${dim('again whenever you\'re ready.')}`,
228
+ ].join('\n'), 'Denied');
229
229
  }
230
230
  else {
231
+ poll.stop(yellow('◌ Timed out — no decision made within 5 minutes.'));
231
232
  note([
232
- bold('What to do next:'),
233
- '',
234
- ` ${dim('1.')} Start your server as normal:`,
235
- ` ${cyan(getStartCommand())}`,
233
+ ` ${dim('Your import is still pending:')}`,
234
+ ` ${cyan(dashboardUrl)}`,
236
235
  '',
237
- ` ${dim('2.')} APIForge detects all routes automatically.`,
238
- '',
239
- ` ${dim('3.')} View your live docs:`,
240
- ` ${cyan('https://apiforge.brainfogagency.com/dashboard')}`,
241
- ].join('\n'), 'Next steps');
242
- outro(`${green('✓')} All set — start your server and docs will sync automatically.`);
236
+ ` ${dim('Import ID:')} ${cyan(pendingImportId)}`,
237
+ ` ${dim('You can still accept or deny it from the dashboard.')}`,
238
+ ].join('\n'), 'Pending');
239
+ }
240
+ // ── Cleanup ───────────────────────────────────────────────────────────────
241
+ await offerCleanup(entryFile, injected);
242
+ tryUnlink(join(process.cwd(), STATUS_FILE));
243
+ tryUnlink(join(process.cwd(), LOCK_FILE));
244
+ const outroMsg = {
245
+ approved: `${green('✓')} Done! View your AI docs at ${cyan(dashboardUrl)}`,
246
+ denied: `${red('✗')} Import denied. No changes were made.`,
247
+ timeout: `${yellow('◌')} Timed out. Your import is still pending at ${cyan(dashboardUrl)}`,
248
+ }[approval.status];
249
+ outro(outroMsg);
250
+ }
251
+ function startAndWaitForAnalysis(command, timeoutMs) {
252
+ return new Promise(resolve => {
253
+ const statusPath = join(process.cwd(), STATUS_FILE);
254
+ const child = spawn(command, {
255
+ cwd: process.cwd(),
256
+ stdio: 'ignore',
257
+ shell: true,
258
+ env: { ...process.env },
259
+ detached: false,
260
+ });
261
+ let resolved = false;
262
+ function finish(result) {
263
+ if (resolved)
264
+ return;
265
+ resolved = true;
266
+ clearInterval(poller);
267
+ clearTimeout(timeout);
268
+ try {
269
+ child.kill('SIGTERM');
270
+ }
271
+ catch { }
272
+ resolve(result);
273
+ }
274
+ // Poll for the status file every 500 ms
275
+ const poller = setInterval(() => {
276
+ try {
277
+ if (!existsSync(statusPath))
278
+ return;
279
+ const data = JSON.parse(readFileSync(statusPath, 'utf-8'));
280
+ if (data.status === 'submitted') {
281
+ finish({
282
+ status: 'submitted',
283
+ pendingImportId: data.pendingImportId ?? '',
284
+ routeCount: data.routeCount ?? 0,
285
+ message: `${data.routeCount ?? 0} routes submitted`,
286
+ });
287
+ }
288
+ else if (data.status === 'error') {
289
+ finish({
290
+ status: 'error',
291
+ pendingImportId: '',
292
+ routeCount: 0,
293
+ message: data.error ?? 'Upload failed',
294
+ });
295
+ }
296
+ }
297
+ catch { }
298
+ }, 500);
299
+ const timeout = setTimeout(() => {
300
+ finish({
301
+ status: 'error',
302
+ pendingImportId: '',
303
+ routeCount: 0,
304
+ message: 'Timed out waiting for analysis (90 s). Is the server starting correctly?',
305
+ });
306
+ }, timeoutMs);
307
+ child.on('error', err => {
308
+ finish({ status: 'error', pendingImportId: '', routeCount: 0, message: err.message });
309
+ });
310
+ child.on('exit', (code, signal) => {
311
+ if (!resolved && signal !== 'SIGTERM' && code !== 0) {
312
+ finish({
313
+ status: 'error',
314
+ pendingImportId: '',
315
+ routeCount: 0,
316
+ message: `Server exited unexpectedly (code ${code})`,
317
+ });
318
+ }
319
+ });
320
+ });
321
+ }
322
+ // ─── Backend polling ──────────────────────────────────────────────────────────
323
+ async function pollForApproval(pendingImportId, apiKey, backendUrl, timeoutMs) {
324
+ const deadline = Date.now() + timeoutMs;
325
+ while (Date.now() < deadline) {
326
+ try {
327
+ const res = await axios.get(`${backendUrl}/api/cli/${pendingImportId}/status`, {
328
+ headers: { 'X-API-Key': apiKey },
329
+ timeout: 8000,
330
+ });
331
+ const { status } = res.data?.data ?? {};
332
+ if (status === 'approved')
333
+ return { status: 'approved' };
334
+ if (status === 'denied')
335
+ return { status: 'denied' };
336
+ }
337
+ catch { }
338
+ await sleep(3000);
339
+ }
340
+ return { status: 'timeout' };
341
+ }
342
+ // ─── Cleanup helpers ──────────────────────────────────────────────────────────
343
+ async function offerCleanup(entryFile, wasInjected) {
344
+ if (!wasInjected)
345
+ return;
346
+ const shouldClean = await confirm({
347
+ message: `Remove APIForge code changes from ${bold(entryFile)}?`,
348
+ initialValue: true,
349
+ });
350
+ if (isCancel(shouldClean) || !shouldClean)
351
+ return;
352
+ const removed = removeInjectedCode(entryFile);
353
+ if (removed) {
354
+ log.success(green(`✓ Removed code changes from ${entryFile}`));
355
+ }
356
+ else {
357
+ log.warn(yellow('Could not auto-remove — please delete the APIForge block manually.'));
358
+ }
359
+ }
360
+ function removeInjectedCode(filePath) {
361
+ const fullPath = join(process.cwd(), filePath);
362
+ try {
363
+ const content = readFileSync(fullPath, 'utf-8');
364
+ const startIdx = content.indexOf('// APIForge');
365
+ if (startIdx === -1)
366
+ return false;
367
+ const afterStart = content.slice(startIdx);
368
+ const endMatch = /\.catch\s*\(\s*\(\s*\)\s*=>\s*\{?\s*\}?\s*\)\s*\n?/.exec(afterStart);
369
+ if (!endMatch)
370
+ return false;
371
+ const endIdx = startIdx + endMatch.index + endMatch[0].length;
372
+ const cleaned = (content.slice(0, startIdx) + content.slice(endIdx))
373
+ .replace(/\n{3,}/g, '\n\n')
374
+ .trimEnd() + '\n';
375
+ writeFileSync(fullPath, cleaned, 'utf-8');
376
+ return true;
377
+ }
378
+ catch {
379
+ return false;
243
380
  }
244
381
  }
245
382
  // ─── Entry file detection ─────────────────────────────────────────────────────
@@ -249,9 +386,9 @@ function detectEntryFile() {
249
386
  const pkg = JSON.parse(readFileSync(join(cwd, 'package.json'), 'utf-8'));
250
387
  if (pkg.main && existsSync(join(cwd, pkg.main)))
251
388
  return pkg.main;
252
- for (const script of ['start', 'dev', 'serve']) {
253
- const cmd = pkg.scripts?.[script] ?? '';
254
- const m = cmd.match(/(?:node|tsx|ts-node)\s+([^\s&|;]+\.(js|ts|mjs|cjs))/);
389
+ for (const s of ['start', 'dev', 'serve']) {
390
+ const cmd = pkg.scripts?.[s] ?? '';
391
+ const m = cmd.match(/(?:node|tsx|ts-node|nodemon)\s+([^\s&|;]+\.(js|ts|mjs|cjs))/);
255
392
  if (m && existsSync(join(cwd, m[1])))
256
393
  return m[1];
257
394
  }
@@ -263,36 +400,60 @@ function detectEntryFile() {
263
400
  'src/index.ts', 'src/server.ts', 'src/app.ts',
264
401
  'src/index.js', 'src/server.js', 'src/app.js',
265
402
  ];
266
- for (const c of candidates)
267
- if (existsSync(join(cwd, c)))
268
- return c;
269
- return null;
403
+ return candidates.find(c => existsSync(join(cwd, c))) ?? null;
270
404
  }
271
- // ─── Framework detection from source ─────────────────────────────────────────
272
- function detectFramework(src) {
273
- if (/require\s*\(\s*['"]express['"]\s*\)|from\s+['"]express['"]/.test(src))
274
- return 'Express';
275
- if (/require\s*\(\s*['"]fastify['"]\s*\)|from\s+['"]fastify['"]/.test(src))
276
- return 'Fastify';
277
- if (/require\s*\(\s*['"]koa['"]\s*\)|from\s+['"]koa['"]/.test(src))
278
- return 'Koa';
279
- if (/require\s*\(\s*['"]hapi['"]\s*\)|from\s+['"]@hapi\/hapi['"]/.test(src))
280
- return 'Hapi';
281
- if (/from\s+['"]next\/server['"]|NextResponse/.test(src))
282
- return 'Next.js';
283
- if (/createServer|http\.createServer/.test(src))
284
- return 'Node HTTP';
285
- return 'Node.js';
405
+ async function askEntryFile() {
406
+ log.warn('Could not auto-detect your server entry file.');
407
+ const custom = await text({
408
+ message: 'Path to your server entry file',
409
+ placeholder: 'src/index.ts',
410
+ validate: v => {
411
+ if (!v.trim())
412
+ return 'Required';
413
+ if (!existsSync(join(process.cwd(), v.trim())))
414
+ return `Not found: ${v.trim()}`;
415
+ },
416
+ });
417
+ if (isCancel(custom)) {
418
+ cancel('Cancelled.');
419
+ process.exit(0);
420
+ }
421
+ return custom.trim();
286
422
  }
287
- function injectCode(filePath, projectName, apiKey, baseUrl, method) {
423
+ function detectScripts() {
424
+ try {
425
+ const pkg = JSON.parse(readFileSync(join(process.cwd(), 'package.json'), 'utf-8'));
426
+ const scripts = pkg.scripts ?? {};
427
+ const preferred = ['dev', 'start', 'serve', 'run', 'develop'];
428
+ const result = [];
429
+ // Preferred scripts first
430
+ for (const name of preferred) {
431
+ if (scripts[name])
432
+ result.push({ name, command: `npm run ${name}` });
433
+ }
434
+ // Then any remaining scripts that look like server starters
435
+ for (const [name, cmd] of Object.entries(scripts)) {
436
+ if (preferred.includes(name))
437
+ continue;
438
+ if (/node|tsx|ts-node|nodemon|server/i.test(cmd)) {
439
+ result.push({ name, command: `npm run ${name}` });
440
+ }
441
+ }
442
+ return result;
443
+ }
444
+ catch {
445
+ return [];
446
+ }
447
+ }
448
+ function injectCode(filePath, projectName, apiKey, baseUrl, backendUrl) {
288
449
  const fullPath = join(process.cwd(), filePath);
289
450
  const content = readFileSync(fullPath, 'utf-8');
290
451
  if (content.includes('apiforge-analyzer')) {
291
- return { message: 'Already configured', alreadyInjected: true };
452
+ return { alreadyInjected: true };
292
453
  }
293
454
  const isESM = detectESM(filePath, content);
294
455
  const appVar = detectAppVariable(content) ?? 'app';
295
- const snippet = getCodeSnippet(projectName, apiKey, baseUrl, method, appVar, isESM);
456
+ const snippet = buildSnippet(projectName, apiKey, baseUrl, backendUrl, appVar, isESM);
296
457
  const listenRe = /\b(?:app|server|httpServer|http)\s*\.listen\s*\(/;
297
458
  const listenMatch = listenRe.exec(content);
298
459
  let newContent;
@@ -307,59 +468,55 @@ function injectCode(filePath, projectName, apiKey, baseUrl, method) {
307
468
  injectionPoint = 'end of file';
308
469
  }
309
470
  writeFileSync(fullPath, newContent, 'utf-8');
310
- const linesAdded = snippet.split('\n').length;
311
- return {
312
- message: `Patched ${filePath}`,
313
- alreadyInjected: false,
314
- appVar,
315
- injectionPoint,
316
- linesAdded,
317
- };
471
+ return { alreadyInjected: false, injectionPoint, linesAdded: snippet.split('\n').length };
318
472
  }
319
- function detectESM(filePath, content) {
473
+ /**
474
+ * Write (or overwrite) the API key in .env.local.
475
+ * Always overwrites so the key stays in sync with what the user entered —
476
+ * keeping a stale key would cause submit/poll key mismatch and 401s.
477
+ */
478
+ function persistApiKeyToEnv(apiKey) {
479
+ const envPath = join(process.cwd(), '.env.local');
480
+ const marker = 'APIFORGE_API_KEY=';
320
481
  try {
321
- const pkg = JSON.parse(readFileSync(join(process.cwd(), 'package.json'), 'utf-8'));
322
- if (pkg.type === 'module')
323
- return true;
482
+ let content = existsSync(envPath) ? readFileSync(envPath, 'utf-8') : '';
483
+ if (content.includes(marker)) {
484
+ // Replace existing value — regex matches the whole line
485
+ content = content.replace(/^APIFORGE_API_KEY=.*/m, `${marker}${apiKey}`);
486
+ }
487
+ else {
488
+ content = content.trimEnd() + `\n# APIForge CLI key (safe to delete after analysis)\n${marker}${apiKey}\n`;
489
+ }
490
+ writeFileSync(envPath, content, 'utf-8');
324
491
  }
325
492
  catch { }
326
- if (filePath.endsWith('.mjs'))
327
- return true;
328
- if (filePath.endsWith('.cjs'))
329
- return false;
330
- return /^(?:import|export)\s+/m.test(content);
493
+ addToGitignore('.env.local');
494
+ addToGitignore('.apiforge-status.json');
495
+ addToGitignore('.apiforge-last-run.json');
331
496
  }
332
- function detectAppVariable(content) {
333
- let m = content.match(/(?:const|let|var)\s+(\w+)\s*=\s*express\s*(?:\.default)?\s*\(/);
334
- if (m)
335
- return m[1];
336
- m = content.match(/(?:const|let|var)\s+(\w+)\s*=\s*(?:fastify|Fastify)\s*\(/);
337
- if (m)
338
- return m[1];
339
- m = content.match(/(?:const|let|var)\s+(\w+)\s*=\s*new\s+Koa\s*\(/);
340
- if (m)
341
- return m[1];
342
- return null;
497
+ function addToGitignore(pattern) {
498
+ const giPath = join(process.cwd(), '.gitignore');
499
+ try {
500
+ const current = existsSync(giPath) ? readFileSync(giPath, 'utf-8') : '';
501
+ if (!current.split('\n').some(l => l.trim() === pattern)) {
502
+ writeFileSync(giPath, current.trimEnd() + `\n${pattern}\n`, 'utf-8');
503
+ }
504
+ }
505
+ catch { }
343
506
  }
344
- // ─── Snippet builder ──────────────────────────────────────────────────────────
345
- function getCodeSnippet(projectName, apiKey, baseUrl, method, appVar, isESM) {
346
- const isJson = method === 'json';
347
- const opts = isJson
348
- ? [
349
- ` apiKey: '${apiKey}',`,
350
- ` projectName: '${projectName}',`,
351
- ` baseUrl: '${baseUrl}',`,
352
- ` saveJson: true,`,
353
- ` jsonPath: './api-export.json',`,
354
- ` upload: false,`,
355
- ].join('\n')
356
- : [
357
- ` apiKey: '${apiKey}',`,
358
- ` projectName: '${projectName}',`,
359
- ` baseUrl: '${baseUrl}',`,
360
- ` apiForgeUrl: 'https://apiforge.brainfogagency.com',`,
361
- ` upload: true,`,
362
- ].join('\n');
507
+ function buildSnippet(projectName, apiKey, baseUrl, backendUrl, appVar, isESM) {
508
+ // The API key is embedded directly — this snippet is TEMPORARY (auto-removed after analysis).
509
+ // Hardcoding guarantees the same key is used for both submit and CLI status polling,
510
+ // eliminating the mismatch that causes 401s when .env.local already has a different key.
511
+ const isProd = backendUrl === BACKEND_PROD;
512
+ const opts = [
513
+ ` apiKey: '${apiKey}',`,
514
+ ` projectName: '${projectName}',`,
515
+ ` baseUrl: '${baseUrl}',`,
516
+ ...(isProd ? [] : [` apiForgeUrl: '${backendUrl}',`]),
517
+ ` upload: true,`,
518
+ ` runOnce: true,`,
519
+ ].join('\n');
363
520
  if (isESM) {
364
521
  return [
365
522
  `// APIForge — https://apiforge.brainfogagency.com`,
@@ -382,36 +539,46 @@ function getCodeSnippet(projectName, apiKey, baseUrl, method, appVar, isESM) {
382
539
  '',
383
540
  ].join('\n');
384
541
  }
385
- /** First 4 lines of the injected snippet for the summary preview. */
386
- function getSnippetPreview(method, appVar) {
387
- const isJson = method === 'json';
388
- return [
389
- `// APIForge — https://apiforge.brainfogagency.com`,
390
- `import('apiforge-analyzer').then(({ default: APIForge }) => {`,
391
- ` APIForge.analyze(${appVar}, {`,
392
- ` ${isJson ? 'saveJson: true, upload: false,' : 'upload: true,'}`,
393
- ` …`,
542
+ function detectESM(filePath, content) {
543
+ try {
544
+ const pkg = JSON.parse(readFileSync(join(process.cwd(), 'package.json'), 'utf-8'));
545
+ if (pkg.type === 'module')
546
+ return true;
547
+ }
548
+ catch { }
549
+ if (filePath.endsWith('.mjs'))
550
+ return true;
551
+ if (filePath.endsWith('.cjs'))
552
+ return false;
553
+ return /^(?:import|export)\s+/m.test(content);
554
+ }
555
+ function detectAppVariable(content) {
556
+ const patterns = [
557
+ /(?:const|let|var)\s+(\w+)\s*=\s*express\s*(?:\.default)?\s*\(/,
558
+ /(?:const|let|var)\s+(\w+)\s*=\s*(?:fastify|Fastify)\s*\(/,
559
+ /(?:const|let|var)\s+(\w+)\s*=\s*new\s+Koa\s*\(/,
560
+ /(?:const|let|var)\s+(\w+)\s*=\s*(?:Router|createApp|createServer)\s*\(/,
394
561
  ];
562
+ for (const re of patterns) {
563
+ const m = content.match(re);
564
+ if (m)
565
+ return m[1];
566
+ }
567
+ return null;
395
568
  }
396
- /** Best-guess start command from package.json scripts. */
397
- function getStartCommand() {
569
+ // ─── Utilities ────────────────────────────────────────────────────────────────
570
+ function tryUnlink(p) {
398
571
  try {
399
- const pkg = JSON.parse(readFileSync(join(process.cwd(), 'package.json'), 'utf-8'));
400
- if (pkg.scripts?.start)
401
- return 'npm start';
402
- if (pkg.scripts?.dev)
403
- return 'npm run dev';
404
- if (pkg.scripts?.serve)
405
- return 'npm run serve';
572
+ if (existsSync(p))
573
+ unlinkSync(p);
406
574
  }
407
575
  catch { }
408
- return 'node ' + (detectedEntry || 'index.js');
409
576
  }
410
- // ─── Util ─────────────────────────────────────────────────────────────────────
411
577
  function sleep(ms) {
412
- return new Promise((r) => setTimeout(r, ms));
578
+ return new Promise(res => setTimeout(res, ms));
413
579
  }
414
- main().catch((err) => {
580
+ // ─── Boot ─────────────────────────────────────────────────────────────────────
581
+ main().catch(err => {
415
582
  console.error(err);
416
583
  process.exit(1);
417
584
  });