apiforge-analyzer 1.1.0 → 1.3.0

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,298 +1,896 @@
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, readdirSync } from 'fs';
4
4
  import { join } from 'path';
5
- // ─── ANSI helpers ─────────────────────────────────────────────────────────────
5
+ import { spawn } from 'child_process';
6
+ import axios from 'axios';
7
+ import { detectArchitectureType } from './arch-detect.js';
8
+ import { uploadRoutes } from './uploader.js';
9
+ // ─── ANSI colour helpers ──────────────────────────────────────────────────────
6
10
  const r = '\x1b[0m';
7
11
  const b = '\x1b[1m';
8
12
  const d = '\x1b[2m';
9
13
  const cy = '\x1b[36m';
10
14
  const gn = '\x1b[32m';
11
15
  const yl = '\x1b[33m';
12
- const mg = '\x1b[35m';
16
+ const rd = '\x1b[31m';
13
17
  const wh = '\x1b[97m';
14
18
  const bold = (s) => `${b}${s}${r}`;
15
19
  const dim = (s) => `${d}${s}${r}`;
16
20
  const cyan = (s) => `${cy}${s}${r}`;
17
21
  const green = (s) => `${gn}${s}${r}`;
18
22
  const yellow = (s) => `${yl}${s}${r}`;
19
- const magenta = (s) => `${mg}${s}${r}`;
23
+ const red = (s) => `${rd}${s}${r}`;
20
24
  const white = (s) => `${wh}${s}${r}`;
21
- // ─── Brand header ─────────────────────────────────────────────────────────────
25
+ const VERSION = '1.3.0';
26
+ // Built-in URLs — users never need to configure these
27
+ const BACKEND_PROD = 'https://apiforgeapi.brainfogagency.com';
28
+ const DASHBOARD_PROD = 'https://apiforge.brainfogagency.com/dashboard';
29
+ // Local overrides — only used when testing APIForge itself
30
+ const BACKEND_LOCAL = 'http://localhost:4000';
31
+ const DASHBOARD_LOCAL = 'http://localhost:3000/dashboard';
32
+ const STATUS_FILE = '.apiforge-status.json';
33
+ const LOCK_FILE = '.apiforge-last-run.json';
34
+ // ─── Header ───────────────────────────────────────────────────────────────────
22
35
  function printHeader() {
23
- const lines = [
36
+ console.log([
24
37
  '',
25
38
  ` ${cyan('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━')}`,
26
39
  '',
27
- ` ${bold(white('⚡ APIForge'))} ${dim('v1.1.0')}`,
40
+ ` ${bold(white('⚡ APIForge'))} ${dim(`v${VERSION}`)}`,
28
41
  ` ${dim('AI-powered API documentation, auto-generated')}`,
29
42
  '',
30
43
  ` ${dim('Your entire API, documented in under 60 seconds.')}`,
31
44
  '',
32
45
  ` ${cyan('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━')}`,
33
46
  '',
34
- ];
35
- console.log(lines.join('\n'));
47
+ ].join('\n'));
36
48
  }
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 ─────────────────────────────────────────────────────────────────────
49
+ // ─── Entry point ─────────────────────────────────────────────────────────────
46
50
  async function main() {
47
- process.stdout.write('\x1Bc'); // clear screen
51
+ process.stdout.write('\x1Bc');
48
52
  printHeader();
49
- intro(bold(` ${cyan('⚡')} APIForge Setup `));
50
- // ── Questions (grouped so cancel is handled once) ──────────────────────────
53
+ intro(bold(` ${cyan('⚡')} APIForge Analyzer `));
54
+ // ── Collect setup answers ─────────────────────────────────────────────────
51
55
  const answers = await group({
52
56
  projectName: () => text({
53
- message: 'Name your project',
54
- placeholder: 'My Awesome API',
55
- validate: (v) => (!v.trim() ? 'Project name cannot be empty' : undefined),
57
+ message: 'Project name',
58
+ placeholder: 'My API',
59
+ validate: v => (!v.trim() ? 'Required' : undefined),
56
60
  }),
57
61
  apiKey: () => password({
58
62
  message: `APIForge API key ${dim('(get one at apiforge.brainfogagency.com/dashboard)')}`,
59
- validate: (v) => {
63
+ validate: v => {
60
64
  if (!v.trim())
61
- return 'API key is required';
65
+ return 'Required';
62
66
  if (!v.trim().startsWith('afg_'))
63
67
  return 'Key must start with afg_';
64
68
  },
65
69
  }),
66
70
  baseUrl: () => text({
67
- message: 'Your server base URL',
71
+ message: 'Your server base URL (for documentation)',
68
72
  placeholder: 'https://api.myapp.com',
69
73
  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
- ],
74
+ validate: v => (!v.trim() ? 'Required' : undefined),
85
75
  }),
86
76
  }, {
87
77
  onCancel: () => {
88
- cancel(dim('Setup cancelled. Come back when you\'re ready.'));
78
+ cancel(dim('Cancelled.'));
89
79
  process.exit(0);
90
80
  },
91
81
  });
92
82
  if (isCancel(answers))
93
83
  process.exit(0);
94
- const { projectName, apiKey, baseUrl, method } = answers;
95
- log.info(dim('─────────────────────────────────────────────────'));
96
- 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)}`);
99
- 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()}`;
84
+ const { projectName, apiKey, baseUrl } = answers;
85
+ // ── Select backend ────────────────────────────────────────────────────────
86
+ const backendChoice = await select({
87
+ message: 'Send routes to',
88
+ options: [
89
+ {
90
+ value: 'production',
91
+ label: `${bold('Production')} ${dim('→')} ${dim(BACKEND_PROD)}`,
92
+ hint: 'recommended',
113
93
  },
114
- });
115
- if (isCancel(custom)) {
116
- cancel('Setup cancelled.');
117
- process.exit(0);
94
+ {
95
+ value: 'local',
96
+ label: `${bold('Local')} ${dim('→')} ${dim(BACKEND_LOCAL)}`,
97
+ hint: 'for testing APIForge locally',
98
+ },
99
+ ],
100
+ });
101
+ if (isCancel(backendChoice)) {
102
+ cancel('Cancelled.');
103
+ process.exit(0);
104
+ }
105
+ const isLocal = backendChoice === 'local';
106
+ const backendUrl = isLocal ? BACKEND_LOCAL : BACKEND_PROD;
107
+ const dashboardUrl = isLocal ? DASHBOARD_LOCAL : DASHBOARD_PROD;
108
+ // ── Detect architecture type ─────────────────────────────────────────────
109
+ const archResult = detectArchitectureType(process.cwd());
110
+ const archLabel = {
111
+ 'monolithic': cyan('Monolithic'),
112
+ 'modular-monolithic': cyan('Modular Monolithic'),
113
+ 'microservices': cyan('Microservices'),
114
+ };
115
+ // ── Full multi-service mode when apiforge.config.json exists ─────────────
116
+ if (archResult.type === 'microservices') {
117
+ let microConfig = loadMicroConfig();
118
+ // Auto-create apiforge.config.json if not present
119
+ if (!microConfig) {
120
+ microConfig = await buildAndSaveConfig(projectName);
121
+ }
122
+ if (microConfig && microConfig.services.length > 0) {
123
+ log.info(dim('─────────────────────────────────────────────────'));
124
+ log.info(`${dim('Project:')} ${bold(projectName)}`);
125
+ log.info(`${dim('Backend:')} ${cyan(backendUrl)}`);
126
+ log.info(`${dim('Architecture:')} ${archLabel['microservices']}`);
127
+ log.info(`${dim('Services:')} ${bold(String(microConfig.services.length))} ${dim('(from apiforge.config.json)')}`);
128
+ log.info(dim('─────────────────────────────────────────────────'));
129
+ await microservicesFlow(microConfig, { projectName, apiKey, baseUrl, backendUrl, dashboardUrl });
130
+ return;
118
131
  }
119
- entryFile = custom.trim();
120
132
  }
121
- else {
122
- entryFile = preDetected;
133
+ // ── Single-service: ask for run command ───────────────────────────────────
134
+ const scripts = detectScripts();
135
+ const scriptOptions = scripts.map(s => ({
136
+ value: s.command,
137
+ label: `${bold(s.name)} ${dim('→')} ${dim(s.command)}`,
138
+ }));
139
+ if (scriptOptions.length === 0) {
140
+ log.warn('No scripts found in package.json — please ensure you have a start/dev script.');
141
+ cancel('No run script available.');
142
+ process.exit(1);
143
+ }
144
+ const runCommand = await select({
145
+ message: 'Which command starts your server?',
146
+ options: scriptOptions,
147
+ });
148
+ if (isCancel(runCommand)) {
149
+ cancel('Cancelled.');
150
+ process.exit(0);
151
+ }
152
+ if (archResult.type === 'microservices') {
153
+ note([
154
+ `${yellow('Microservices architecture detected.')}`,
155
+ '',
156
+ `${dim('Add an')} ${bold('apiforge.config.json')} ${dim('to document all services automatically:')}`,
157
+ '',
158
+ ` ${cyan('{ "services": [{ "name": "auth", "dir": "./services/auth" }] }')}`,
159
+ '',
160
+ `${dim('For now, analyzing the single service started by the selected command.')}`,
161
+ ].join('\n'), 'Multi-service tip');
162
+ }
163
+ log.info(dim('─────────────────────────────────────────────────'));
164
+ log.info(`${dim('Project:')} ${bold(projectName)}`);
165
+ log.info(`${dim('Command:')} ${bold(runCommand)}`);
166
+ log.info(`${dim('Backend:')} ${cyan(backendUrl)}`);
167
+ log.info(`${dim('Architecture:')} ${archLabel[archResult.type]}`);
168
+ log.info(dim('─────────────────────────────────────────────────'));
169
+ if (archResult.moduleNames.length > 0) {
170
+ log.info(`${dim('Modules found:')} ${archResult.moduleNames.map(m => bold(m)).join(', ')}`);
123
171
  }
124
- // ── Multi-step animated tasks ──────────────────────────────────────────────
172
+ // ── Detect & patch entry file ─────────────────────────────────────────────
173
+ const entryFile = detectEntryFile(archResult.type) ?? await askEntryFile(archResult.type);
174
+ let injected = false;
175
+ let linesAdded = 0;
125
176
  await tasks([
126
177
  {
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',
178
+ title: 'Scanning project',
138
179
  task: async (msg) => {
139
180
  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');
181
+ msg(`Found entry file: ${entryFile}`);
182
+ await sleep(150);
183
+ return `${green('✓')} Entry: ${bold(entryFile)}`;
153
184
  },
154
185
  },
155
186
  {
156
187
  title: 'Patching server file',
157
188
  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';
189
+ msg('Injecting analyzer…');
190
+ await sleep(100);
191
+ // Clear stale status + lock files before starting
192
+ tryUnlink(join(process.cwd(), STATUS_FILE));
193
+ tryUnlink(join(process.cwd(), LOCK_FILE));
194
+ // Store API key in .env.local — never embed it in source code
195
+ persistApiKeyToEnv(apiKey);
196
+ const result = injectCode(entryFile, projectName, apiKey, baseUrl, backendUrl, archResult.type);
197
+ injected = !result.alreadyInjected;
164
198
  linesAdded = result.linesAdded ?? 0;
165
- msg(result.alreadyInjected ? 'Already configured' : `Writing to ${detectedEntry}`);
166
- await sleep(300);
167
199
  if (result.alreadyInjected) {
168
- return `${yellow('◌')} Already set up — no changes made`;
200
+ return `${yellow('◌')} Already configured — no changes made`;
169
201
  }
170
202
  return [
171
- `${green('✓')} Patched: ${bold(detectedEntry)} ${dim(`+${linesAdded} lines`)}`,
172
- ` At: ${dim(injectionPoint)}`,
203
+ `${green('✓')} Patched ${bold(entryFile)} ${dim(`+${linesAdded} lines`)}`,
204
+ ` ${green('✓')} API key saved to ${bold('.env.local')} ${dim('(gitignored)')}`,
173
205
  ].join('\n');
174
206
  },
175
207
  },
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
208
  ]);
193
- // ── Summary note ───────────────────────────────────────────────────────────
194
- if (wasAlreadySetup) {
209
+ // ── Start server + wait for analysis ──────────────────────────────────────
210
+ note([
211
+ bold('Starting your server…'),
212
+ '',
213
+ ` ${dim('Command:')} ${cyan(runCommand)}`,
214
+ ` ${dim('Watching')} .apiforge-status.json ${dim('for completion signal')}`,
215
+ ].join('\n'), 'Running');
216
+ const s = spinner();
217
+ s.start('Starting server and analyzing routes…');
218
+ const serverResult = await startAndWaitForAnalysis(runCommand, 90000);
219
+ if (serverResult.status === 'error') {
220
+ s.stop(red(`✗ ${serverResult.message}`));
221
+ // Give a targeted hint for common failure modes
222
+ if (serverResult.message.includes('API key')) {
223
+ log.warn(yellow('Hint: The CLI wrote your key to .env.local — but your server may not load it.'));
224
+ log.warn(yellow(' Add this at the top of your entry file before calling analyze():'));
225
+ log.info(` ${cyan("require('dotenv').config({ path: '.env.local' })")}`);
226
+ log.warn(yellow(' Or install dotenv: npm install dotenv'));
227
+ }
228
+ else if (serverResult.message.includes('No routes')) {
229
+ log.warn(yellow('Hint: Make sure all routes are registered before analyze() is called.'));
230
+ log.warn(yellow(' The analyze() call should come AFTER all router.use() / app.get() etc.'));
231
+ }
232
+ else if (serverResult.message.includes('Timed out')) {
233
+ log.warn(yellow('Hint: Your server might be taking too long to start, or the analyzer'));
234
+ log.warn(yellow(' is not being called. Check your server logs for APIForge messages.'));
235
+ }
236
+ await offerCleanup(entryFile, injected);
237
+ outro(red('Analysis failed.'));
238
+ process.exit(1);
239
+ }
240
+ s.stop(green(`✓ ${serverResult.routeCount} routes submitted to APIForge`));
241
+ // ── Poll for dashboard approval ───────────────────────────────────────────
242
+ const { pendingImportId } = serverResult;
243
+ note([
244
+ bold('Open your dashboard to review:'),
245
+ '',
246
+ ` ${cyan(dashboardUrl)}`,
247
+ '',
248
+ dim(`In "Import JSON" → "Pending CLI Imports"`),
249
+ dim('Accept to start AI processing · Deny to discard.'),
250
+ ].join('\n'), 'Action required');
251
+ const poll = spinner();
252
+ poll.start('Waiting for your decision in the dashboard… (5 min timeout)');
253
+ const approval = await pollForApproval(pendingImportId, apiKey, backendUrl, 5 * 60000);
254
+ if (approval.status === 'approved') {
255
+ poll.stop(green('✓ Accepted — AI processing started!'));
195
256
  note([
196
- bold('Already good to go!'),
257
+ bold('Your API is being documented.'),
197
258
  '',
198
- dim(`APIForge is already configured in ${bold(detectedEntry)}.`),
199
- dim('No changes were made.'),
200
- ].join('\n'), 'Status');
259
+ ` ${dim('Track progress:')} ${cyan(dashboardUrl)}`,
260
+ ` ${dim('Import ID:')} ${dim(pendingImportId)}`,
261
+ ].join('\n'), 'Processing');
201
262
  }
202
- else {
203
- const snippetPreview = getSnippetPreview(method, detectedAppVar);
263
+ else if (approval.status === 'denied') {
264
+ poll.stop(red('✗ Import denied by dashboard.'));
204
265
  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)')}`,
209
- '',
210
- dim('Injected snippet:'),
266
+ ` ${bold('You denied this import from the dashboard.')}`,
211
267
  '',
212
- ...snippetPreview.map(line => ` ${cyan('│')} ${dim(line)}`),
213
- ].join('\n'), 'Patch summary');
268
+ ` ${dim('No documentation was generated.')}`,
269
+ ` ${dim('Run')} ${cyan('npx apiforge')} ${dim('again whenever you\'re ready.')}`,
270
+ ].join('\n'), 'Denied');
214
271
  }
215
- // ── Next steps + outro ─────────────────────────────────────────────────────
216
- if (method === 'json') {
272
+ else {
273
+ poll.stop(yellow('◌ Timed out — no decision made within 5 minutes.'));
217
274
  note([
218
- bold('What to do next:'),
275
+ ` ${dim('Your import is still pending:')}`,
276
+ ` ${cyan(dashboardUrl)}`,
219
277
  '',
220
- ` ${dim('1.')} Start your server once:`,
221
- ` ${cyan(getStartCommand())}`,
278
+ ` ${dim('Import ID:')} ${cyan(pendingImportId)}`,
279
+ ` ${dim('You can still accept or deny it from the dashboard.')}`,
280
+ ].join('\n'), 'Pending');
281
+ }
282
+ // ── Cleanup ───────────────────────────────────────────────────────────────
283
+ await offerCleanup(entryFile, injected);
284
+ tryUnlink(join(process.cwd(), STATUS_FILE));
285
+ tryUnlink(join(process.cwd(), LOCK_FILE));
286
+ const outroMsg = {
287
+ approved: `${green('✓')} Done! View your AI docs at ${cyan(dashboardUrl)}`,
288
+ denied: `${red('✗')} Import denied. No changes were made.`,
289
+ timeout: `${yellow('◌')} Timed out. Your import is still pending at ${cyan(dashboardUrl)}`,
290
+ }[approval.status];
291
+ outro(outroMsg);
292
+ }
293
+ /**
294
+ * Scan the project for services and interactively write apiforge.config.json.
295
+ * Called when microservices arch is detected but no config file exists yet.
296
+ */
297
+ async function buildAndSaveConfig(projectName) {
298
+ const cwd = process.cwd();
299
+ // Collect services from filesystem scan (same logic as findServiceEntries but raw)
300
+ const discovered = [];
301
+ for (const parentDir of ['services', 'apps', 'packages', 'microservices', 'src/services']) {
302
+ const parentPath = join(cwd, parentDir);
303
+ if (!existsSync(parentPath))
304
+ continue;
305
+ try {
306
+ const subdirs = readdirSync(parentPath, { withFileTypes: true }).filter(d => d.isDirectory());
307
+ for (const subdir of subdirs) {
308
+ const entry = findEntryInDir(join(parentPath, subdir.name));
309
+ if (entry) {
310
+ discovered.push({ name: subdir.name, dir: `./${parentDir}/${subdir.name}` });
311
+ }
312
+ }
313
+ }
314
+ catch { }
315
+ if (discovered.length > 0)
316
+ break;
317
+ }
318
+ if (discovered.length === 0)
319
+ return null;
320
+ note([
321
+ bold(`Found ${discovered.length} service${discovered.length > 1 ? 's' : ''}:`),
322
+ '',
323
+ ...discovered.map(s => ` ${dim('·')} ${bold(s.name)} ${dim('→')} ${dim(s.dir)}`),
324
+ '',
325
+ `${dim('apiforge.config.json will be created so you never have to set this up again.')}`,
326
+ ].join('\n'), 'Auto-detected services');
327
+ const shouldCreate = await confirm({
328
+ message: `Create apiforge.config.json with these ${discovered.length} services?`,
329
+ initialValue: true,
330
+ });
331
+ if (isCancel(shouldCreate) || !shouldCreate)
332
+ return null;
333
+ const config = {
334
+ projectName,
335
+ architectureType: 'microservices',
336
+ services: discovered.map(s => ({ name: s.name, dir: s.dir })),
337
+ };
338
+ writeFileSync(join(cwd, 'apiforge.config.json'), JSON.stringify(config, null, 2) + '\n', 'utf-8');
339
+ log.success(green(`✓ Created apiforge.config.json (${discovered.length} services)`));
340
+ log.info(dim(' Commit this file so teammates can run apiforge without re-setup.'));
341
+ return { services: discovered };
342
+ }
343
+ function loadMicroConfig() {
344
+ for (const cfgName of ['apiforge.config.json', '.apiforge.json']) {
345
+ try {
346
+ const cfg = JSON.parse(readFileSync(join(process.cwd(), cfgName), 'utf-8'));
347
+ if (Array.isArray(cfg.services) && cfg.services.length > 0) {
348
+ return { projectName: cfg.projectName, services: cfg.services };
349
+ }
350
+ }
351
+ catch { }
352
+ }
353
+ return null;
354
+ }
355
+ function serviceNodeCommand(entryRelPath) {
356
+ if (entryRelPath.endsWith('.ts')) {
357
+ const hasTsx = existsSync(join(process.cwd(), 'node_modules/.bin/tsx'));
358
+ const hasTsNode = existsSync(join(process.cwd(), 'node_modules/.bin/ts-node'));
359
+ if (hasTsx)
360
+ return `node_modules/.bin/tsx ${entryRelPath}`;
361
+ if (hasTsNode)
362
+ return `node_modules/.bin/ts-node ${entryRelPath}`;
363
+ return `npx --yes tsx ${entryRelPath}`;
364
+ }
365
+ return `node ${entryRelPath}`;
366
+ }
367
+ function waitForServiceStatusFile(statusFile, timeoutMs) {
368
+ return new Promise(resolve => {
369
+ const fullPath = join(process.cwd(), statusFile);
370
+ const poller = setInterval(() => {
371
+ try {
372
+ if (!existsSync(fullPath))
373
+ return;
374
+ const data = JSON.parse(readFileSync(fullPath, 'utf-8'));
375
+ if (data.status === 'collected' && Array.isArray(data.routes)) {
376
+ clearInterval(poller);
377
+ clearTimeout(timer);
378
+ resolve(data.routes);
379
+ }
380
+ }
381
+ catch { }
382
+ }, 500);
383
+ const timer = setTimeout(() => {
384
+ clearInterval(poller);
385
+ resolve(null);
386
+ }, timeoutMs);
387
+ });
388
+ }
389
+ async function microservicesFlow(config, opts) {
390
+ const cwd = process.cwd();
391
+ const allRoutes = [];
392
+ const serviceMeta = [];
393
+ const injectedFiles = [];
394
+ // Clear any stale per-service status files
395
+ for (const svc of config.services) {
396
+ tryUnlink(join(cwd, `.apiforge-status-${svc.name}.json`));
397
+ }
398
+ tryUnlink(join(cwd, STATUS_FILE));
399
+ tryUnlink(join(cwd, LOCK_FILE));
400
+ persistApiKeyToEnv(opts.apiKey);
401
+ note([
402
+ bold(`Analyzing ${config.services.length} service${config.services.length > 1 ? 's' : ''}…`),
403
+ '',
404
+ ...config.services.map(s => ` ${dim('·')} ${bold(s.name)} ${dim('→')} ${dim(s.dir)}`),
405
+ '',
406
+ dim('Each service is started individually and its routes are collected.'),
407
+ ].join('\n'), 'Multi-service analysis');
408
+ for (const svc of config.services) {
409
+ const svcSpin = spinner();
410
+ svcSpin.start(`${bold(svc.name)} — starting…`);
411
+ const svcDir = svc.dir.replace(/^\.\//, '');
412
+ // Resolve entry file
413
+ const relEntry = svc.entryFile ?? findEntryInDir(join(cwd, svcDir));
414
+ if (!relEntry) {
415
+ svcSpin.stop(yellow(`◌ ${svc.name} — no entry file found, skipping`));
416
+ continue;
417
+ }
418
+ const fullEntry = join(svcDir, relEntry);
419
+ const statusFile = `.apiforge-status-${svc.name}.json`;
420
+ // Inject collection snippet (upload: false, writes to per-service status file)
421
+ injectCode(fullEntry, opts.projectName, opts.apiKey, opts.baseUrl, opts.backendUrl, 'microservices', {
422
+ serviceName: svc.name,
423
+ statusFile,
424
+ });
425
+ injectedFiles.push(fullEntry);
426
+ // Start service with plain node (or tsx for .ts files)
427
+ const startCmd = svc.startCommand ?? serviceNodeCommand(fullEntry);
428
+ const child = spawn(startCmd, {
429
+ cwd,
430
+ stdio: 'ignore',
431
+ shell: true,
432
+ env: { ...process.env },
433
+ detached: false,
434
+ });
435
+ // Wait up to 30 s for the status file to appear
436
+ const routes = await waitForServiceStatusFile(statusFile, 30000);
437
+ // Kill service process
438
+ try {
439
+ child.kill('SIGTERM');
440
+ }
441
+ catch { }
442
+ tryUnlink(join(cwd, statusFile));
443
+ if (routes && routes.length > 0) {
444
+ const annotated = routes.map((r) => ({ ...r, service: svc.name }));
445
+ allRoutes.push(...annotated);
446
+ serviceMeta.push({ name: svc.name, baseUrl: svc.baseUrl });
447
+ svcSpin.stop(green(`✓ ${svc.name} — ${routes.length} routes collected`));
448
+ }
449
+ else {
450
+ svcSpin.stop(yellow(`◌ ${svc.name} — no routes collected (timeout or error)`));
451
+ }
452
+ // Remove injected code
453
+ removeInjectedCode(fullEntry);
454
+ injectedFiles.splice(injectedFiles.indexOf(fullEntry), 1);
455
+ }
456
+ // Safety: clean up any remaining injections (e.g. if process was interrupted)
457
+ for (const f of injectedFiles)
458
+ removeInjectedCode(f);
459
+ if (allRoutes.length === 0) {
460
+ log.error(red('No routes were collected from any service.'));
461
+ outro(red('Analysis failed.'));
462
+ process.exit(1);
463
+ }
464
+ // Upload aggregated routes
465
+ const uploadSpin = spinner();
466
+ uploadSpin.start(`Uploading ${allRoutes.length} routes from ${serviceMeta.length} service${serviceMeta.length > 1 ? 's' : ''}…`);
467
+ const uploadResult = await uploadRoutes(allRoutes, {
468
+ projectName: opts.projectName,
469
+ apiKey: opts.apiKey,
470
+ baseUrl: opts.baseUrl,
471
+ apiForgeUrl: opts.backendUrl,
472
+ architectureType: 'microservices',
473
+ services: serviceMeta,
474
+ });
475
+ if (!uploadResult) {
476
+ uploadSpin.stop(red('✗ Upload failed'));
477
+ outro(red('Analysis failed.'));
478
+ process.exit(1);
479
+ }
480
+ uploadSpin.stop(green(`✓ ${uploadResult.routeCount} routes submitted across ${serviceMeta.length} service${serviceMeta.length > 1 ? 's' : ''}`));
481
+ // ── Poll for dashboard approval ───────────────────────────────────────────
482
+ note([
483
+ bold('Open your dashboard to review:'),
484
+ '',
485
+ ` ${cyan(opts.dashboardUrl)}`,
486
+ '',
487
+ dim('In "Import JSON" → "Pending CLI Imports"'),
488
+ dim('Accept to start AI processing · Deny to discard.'),
489
+ ].join('\n'), 'Action required');
490
+ const poll = spinner();
491
+ poll.start('Waiting for your decision in the dashboard… (5 min timeout)');
492
+ const approval = await pollForApproval(uploadResult.pendingImportId, opts.apiKey, opts.backendUrl, 5 * 60000);
493
+ const statusMsg = {
494
+ approved: green('✓ Accepted — AI processing started!'),
495
+ denied: red('✗ Import denied by dashboard.'),
496
+ timeout: yellow('◌ Timed out — no decision made within 5 minutes.'),
497
+ }[approval.status];
498
+ poll.stop(statusMsg);
499
+ if (approval.status === 'approved') {
500
+ note([
501
+ bold('Your API is being documented.'),
222
502
  '',
223
- ` ${dim('2.')} Upload ${bold('api-export.json')} to the dashboard:`,
224
- ` ${cyan('https://apiforge.brainfogagency.com/dashboard')}`,
503
+ ` ${dim('Track progress:')} ${cyan(opts.dashboardUrl)}`,
504
+ ` ${dim('Import ID:')} ${dim(uploadResult.pendingImportId)}`,
505
+ ].join('\n'), 'Processing');
506
+ }
507
+ else if (approval.status === 'denied') {
508
+ note([
509
+ ` ${bold('You denied this import from the dashboard.')}`,
225
510
  '',
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.`);
511
+ ` ${dim('No documentation was generated.')}`,
512
+ ` ${dim('Run')} ${cyan('npx apiforge')} ${dim('again whenever you\'re ready.')}`,
513
+ ].join('\n'), 'Denied');
229
514
  }
230
515
  else {
231
516
  note([
232
- bold('What to do next:'),
233
- '',
234
- ` ${dim('1.')} Start your server as normal:`,
235
- ` ${cyan(getStartCommand())}`,
236
- '',
237
- ` ${dim('2.')} APIForge detects all routes automatically.`,
517
+ ` ${dim('Your import is still pending:')}`,
518
+ ` ${cyan(opts.dashboardUrl)}`,
238
519
  '',
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.`);
520
+ ` ${dim('Import ID:')} ${cyan(uploadResult.pendingImportId)}`,
521
+ ` ${dim('You can still accept or deny it from the dashboard.')}`,
522
+ ].join('\n'), 'Pending');
243
523
  }
524
+ tryUnlink(join(cwd, STATUS_FILE));
525
+ tryUnlink(join(cwd, LOCK_FILE));
526
+ const outroMsg = {
527
+ approved: `${green('✓')} Done! View your AI docs at ${cyan(opts.dashboardUrl)}`,
528
+ denied: `${red('✗')} Import denied. No changes were made.`,
529
+ timeout: `${yellow('◌')} Timed out. Your import is still pending at ${cyan(opts.dashboardUrl)}`,
530
+ }[approval.status];
531
+ outro(outroMsg);
244
532
  }
245
- // ─── Entry file detection ─────────────────────────────────────────────────────
246
- function detectEntryFile() {
247
- const cwd = process.cwd();
533
+ function startAndWaitForAnalysis(command, timeoutMs) {
534
+ return new Promise(resolve => {
535
+ const statusPath = join(process.cwd(), STATUS_FILE);
536
+ const child = spawn(command, {
537
+ cwd: process.cwd(),
538
+ stdio: 'ignore',
539
+ shell: true,
540
+ env: { ...process.env },
541
+ detached: false,
542
+ });
543
+ let resolved = false;
544
+ function finish(result) {
545
+ if (resolved)
546
+ return;
547
+ resolved = true;
548
+ clearInterval(poller);
549
+ clearTimeout(timeout);
550
+ try {
551
+ child.kill('SIGTERM');
552
+ }
553
+ catch { }
554
+ resolve(result);
555
+ }
556
+ // Poll for the status file every 500 ms
557
+ const poller = setInterval(() => {
558
+ try {
559
+ if (!existsSync(statusPath))
560
+ return;
561
+ const data = JSON.parse(readFileSync(statusPath, 'utf-8'));
562
+ if (data.status === 'submitted') {
563
+ finish({
564
+ status: 'submitted',
565
+ pendingImportId: data.pendingImportId ?? '',
566
+ routeCount: data.routeCount ?? 0,
567
+ message: `${data.routeCount ?? 0} routes submitted`,
568
+ });
569
+ }
570
+ else if (data.status === 'error') {
571
+ finish({
572
+ status: 'error',
573
+ pendingImportId: '',
574
+ routeCount: 0,
575
+ message: data.error ?? 'Upload failed',
576
+ });
577
+ }
578
+ }
579
+ catch { }
580
+ }, 500);
581
+ const timeout = setTimeout(() => {
582
+ finish({
583
+ status: 'error',
584
+ pendingImportId: '',
585
+ routeCount: 0,
586
+ message: 'Timed out waiting for analysis (90 s). Is the server starting correctly?',
587
+ });
588
+ }, timeoutMs);
589
+ child.on('error', err => {
590
+ finish({ status: 'error', pendingImportId: '', routeCount: 0, message: err.message });
591
+ });
592
+ child.on('exit', (code, signal) => {
593
+ if (!resolved && signal !== 'SIGTERM' && code !== 0) {
594
+ finish({
595
+ status: 'error',
596
+ pendingImportId: '',
597
+ routeCount: 0,
598
+ message: `Server exited unexpectedly (code ${code})`,
599
+ });
600
+ }
601
+ });
602
+ });
603
+ }
604
+ // ─── Backend polling ──────────────────────────────────────────────────────────
605
+ async function pollForApproval(pendingImportId, apiKey, backendUrl, timeoutMs) {
606
+ const deadline = Date.now() + timeoutMs;
607
+ while (Date.now() < deadline) {
608
+ try {
609
+ const res = await axios.get(`${backendUrl}/api/cli/${pendingImportId}/status`, {
610
+ headers: { 'X-API-Key': apiKey },
611
+ timeout: 8000,
612
+ });
613
+ const { status } = res.data?.data ?? {};
614
+ if (status === 'approved')
615
+ return { status: 'approved' };
616
+ if (status === 'denied')
617
+ return { status: 'denied' };
618
+ }
619
+ catch { }
620
+ await sleep(3000);
621
+ }
622
+ return { status: 'timeout' };
623
+ }
624
+ // ─── Cleanup helpers ──────────────────────────────────────────────────────────
625
+ async function offerCleanup(entryFile, wasInjected) {
626
+ if (!wasInjected)
627
+ return;
628
+ const shouldClean = await confirm({
629
+ message: `Remove APIForge code changes from ${bold(entryFile)}?`,
630
+ initialValue: true,
631
+ });
632
+ if (isCancel(shouldClean) || !shouldClean)
633
+ return;
634
+ const removed = removeInjectedCode(entryFile);
635
+ if (removed) {
636
+ log.success(green(`✓ Removed code changes from ${entryFile}`));
637
+ }
638
+ else {
639
+ log.warn(yellow('Could not auto-remove — please delete the APIForge block manually.'));
640
+ }
641
+ }
642
+ function removeInjectedCode(filePath) {
643
+ const fullPath = join(process.cwd(), filePath);
644
+ try {
645
+ const content = readFileSync(fullPath, 'utf-8');
646
+ const startIdx = content.indexOf('// APIForge');
647
+ if (startIdx === -1)
648
+ return false;
649
+ const afterStart = content.slice(startIdx);
650
+ const endMatch = /\.catch\s*\(\s*\(\s*\)\s*=>\s*\{?\s*\}?\s*\)\s*\n?/.exec(afterStart);
651
+ if (!endMatch)
652
+ return false;
653
+ const endIdx = startIdx + endMatch.index + endMatch[0].length;
654
+ const cleaned = (content.slice(0, startIdx) + content.slice(endIdx))
655
+ .replace(/\n{3,}/g, '\n\n')
656
+ .trimEnd() + '\n';
657
+ writeFileSync(fullPath, cleaned, 'utf-8');
658
+ return true;
659
+ }
660
+ catch {
661
+ return false;
662
+ }
663
+ }
664
+ /** Look for a server entry inside a single directory (checks src/ then root). */
665
+ function findEntryInDir(absDir) {
666
+ const names = ['index', 'server', 'app', 'main'];
667
+ const exts = ['ts', 'js', 'mjs', 'cjs'];
668
+ for (const depth of ['src', '']) {
669
+ for (const name of names) {
670
+ for (const ext of exts) {
671
+ const rel = depth ? join(depth, `${name}.${ext}`) : `${name}.${ext}`;
672
+ if (existsSync(join(absDir, rel)))
673
+ return rel;
674
+ }
675
+ }
676
+ }
677
+ return null;
678
+ }
679
+ /**
680
+ * Detect the project's root/main entry point — package.json main,
681
+ * common gateway directories, then flat root candidates.
682
+ * Returns a relative path or null.
683
+ */
684
+ function detectMainEntry(cwd) {
685
+ // 1. package.json main / start / dev script
248
686
  try {
249
687
  const pkg = JSON.parse(readFileSync(join(cwd, 'package.json'), 'utf-8'));
250
688
  if (pkg.main && existsSync(join(cwd, pkg.main)))
251
689
  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))/);
690
+ for (const s of ['start', 'dev', 'serve']) {
691
+ const cmd = pkg.scripts?.[s] ?? '';
692
+ const m = cmd.match(/(?:node|tsx|ts-node|nodemon)\s+([^\s&|;]+\.(js|ts|mjs|cjs))/);
255
693
  if (m && existsSync(join(cwd, m[1])))
256
694
  return m[1];
257
695
  }
258
696
  }
259
697
  catch { }
260
- const candidates = [
261
- 'index.ts', 'server.ts', 'app.ts',
262
- 'index.js', 'server.js', 'app.js',
263
- 'src/index.ts', 'src/server.ts', 'src/app.ts',
264
- 'src/index.js', 'src/server.js', 'src/app.js',
265
- ];
266
- for (const c of candidates)
698
+ // 2. Common gateway/proxy directory (e.g. api-gateway/)
699
+ for (const gatewayDir of ['api-gateway', 'gateway', 'proxy', 'api-proxy']) {
700
+ const entry = findEntryInDir(join(cwd, gatewayDir));
701
+ if (entry)
702
+ return join(gatewayDir, entry);
703
+ }
704
+ // 3. Flat root candidates
705
+ for (const c of [
706
+ 'index.ts', 'server.ts', 'app.ts', 'main.ts',
707
+ 'index.js', 'server.js', 'app.js', 'main.js',
708
+ 'src/index.ts', 'src/server.ts', 'src/app.ts', 'src/main.ts',
709
+ 'src/index.js', 'src/server.js', 'src/app.js', 'src/main.js',
710
+ ]) {
267
711
  if (existsSync(join(cwd, c)))
268
712
  return c;
713
+ }
269
714
  return null;
270
715
  }
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';
716
+ /**
717
+ * Scan the project for per-service entry files.
718
+ * Prepends the root main entry (if any) so it appears first in the select list.
719
+ * Reads apiforge.config.json first; falls back to common service parent dirs.
720
+ */
721
+ function findServiceEntries() {
722
+ const cwd = process.cwd();
723
+ const results = [];
724
+ // 0. Root / main entry — shown first so the user always sees it
725
+ const mainEntry = detectMainEntry(cwd);
726
+ if (mainEntry) {
727
+ results.push({ label: `[ Main entry ] → ${mainEntry}`, path: mainEntry });
728
+ }
729
+ // 1. Authoritative list from apiforge.config.json / .apiforge.json
730
+ for (const cfgName of ['apiforge.config.json', '.apiforge.json']) {
731
+ try {
732
+ const cfg = JSON.parse(readFileSync(join(cwd, cfgName), 'utf-8'));
733
+ if (!Array.isArray(cfg.services) || cfg.services.length === 0)
734
+ continue;
735
+ for (const svc of cfg.services) {
736
+ const dir = svc.dir.replace(/^\.\//, '');
737
+ const entry = findEntryInDir(join(cwd, dir));
738
+ if (!entry)
739
+ continue;
740
+ const relPath = join(dir, entry);
741
+ // Skip if same path as the main entry already added
742
+ if (relPath !== mainEntry) {
743
+ results.push({ label: `${svc.name} → ${relPath}`, path: relPath });
744
+ }
745
+ }
746
+ if (results.length > 0)
747
+ return results;
748
+ }
749
+ catch { }
750
+ }
751
+ // 2. Filesystem scan of common service parent dirs
752
+ for (const parentDir of ['services', 'apps', 'packages', 'src/services', 'microservices']) {
753
+ const parentPath = join(cwd, parentDir);
754
+ if (!existsSync(parentPath))
755
+ continue;
756
+ try {
757
+ const subdirs = readdirSync(parentPath, { withFileTypes: true }).filter(d => d.isDirectory());
758
+ for (const subdir of subdirs) {
759
+ const entry = findEntryInDir(join(parentPath, subdir.name));
760
+ if (!entry)
761
+ continue;
762
+ const relPath = join(parentDir, subdir.name, entry);
763
+ if (relPath !== mainEntry) {
764
+ results.push({ label: `${subdir.name} → ${relPath}`, path: relPath });
765
+ }
766
+ }
767
+ }
768
+ catch { }
769
+ if (results.length > 1)
770
+ return results; // >1 because main entry is already at index 0
771
+ }
772
+ return results;
773
+ }
774
+ function detectEntryFile(archType) {
775
+ const cwd = process.cwd();
776
+ // For microservices: auto-pick only when exactly one service entry is found
777
+ if (archType === 'microservices') {
778
+ const entries = findServiceEntries();
779
+ return entries.length === 1 ? entries[0].path : null;
780
+ }
781
+ // Package.json hints
782
+ try {
783
+ const pkg = JSON.parse(readFileSync(join(cwd, 'package.json'), 'utf-8'));
784
+ if (pkg.main && existsSync(join(cwd, pkg.main)))
785
+ return pkg.main;
786
+ for (const s of ['start', 'dev', 'serve']) {
787
+ const cmd = pkg.scripts?.[s] ?? '';
788
+ const m = cmd.match(/(?:node|tsx|ts-node|nodemon)\s+([^\s&|;]+\.(js|ts|mjs|cjs))/);
789
+ if (m && existsSync(join(cwd, m[1])))
790
+ return m[1];
791
+ }
792
+ }
793
+ catch { }
794
+ // Flat file candidates — root → src/ → compiled output → misc
795
+ const candidates = [
796
+ 'index.ts', 'server.ts', 'app.ts', 'main.ts',
797
+ 'index.js', 'server.js', 'app.js', 'main.js',
798
+ 'src/index.ts', 'src/server.ts', 'src/app.ts', 'src/main.ts',
799
+ 'src/index.js', 'src/server.js', 'src/app.js', 'src/main.js',
800
+ 'dist/index.js', 'dist/server.js', 'dist/main.js',
801
+ 'build/index.js', 'build/server.js', 'build/main.js',
802
+ 'lib/index.js', 'lib/server.js',
803
+ 'server/index.ts', 'server/index.js',
804
+ 'api/index.ts', 'api/index.js',
805
+ 'src/server/index.ts', 'src/server/index.js',
806
+ 'lambda.ts', 'lambda.js', 'handler.ts', 'handler.js',
807
+ ];
808
+ return candidates.find(c => existsSync(join(cwd, c))) ?? null;
809
+ }
810
+ async function askEntryFile(archType) {
811
+ if (archType === 'microservices') {
812
+ const entries = findServiceEntries();
813
+ if (entries.length > 0) {
814
+ const choice = await select({
815
+ message: 'Which service entry file do you want to analyze?',
816
+ options: [
817
+ ...entries.map(e => ({ value: e.path, label: e.label })),
818
+ { value: '__manual', label: 'Enter path manually…' },
819
+ ],
820
+ });
821
+ if (isCancel(choice)) {
822
+ cancel('Cancelled.');
823
+ process.exit(0);
824
+ }
825
+ if (choice !== '__manual')
826
+ return choice;
827
+ }
828
+ else {
829
+ log.warn('Could not find service entry files in services/, apps/, or packages/.');
830
+ }
831
+ }
832
+ else {
833
+ log.warn('Could not auto-detect your server entry file.');
834
+ }
835
+ const custom = await text({
836
+ message: 'Path to your server entry file',
837
+ placeholder: 'src/index.ts',
838
+ validate: v => {
839
+ if (!v.trim())
840
+ return 'Required';
841
+ if (!existsSync(join(process.cwd(), v.trim())))
842
+ return `Not found: ${v.trim()}`;
843
+ },
844
+ });
845
+ if (isCancel(custom)) {
846
+ cancel('Cancelled.');
847
+ process.exit(0);
848
+ }
849
+ return custom.trim();
850
+ }
851
+ function detectScripts() {
852
+ try {
853
+ const pkg = JSON.parse(readFileSync(join(process.cwd(), 'package.json'), 'utf-8'));
854
+ const scripts = pkg.scripts ?? {};
855
+ const preferred = ['dev', 'start', 'serve', 'run', 'develop'];
856
+ const result = [];
857
+ // Preferred scripts first
858
+ for (const name of preferred) {
859
+ if (scripts[name])
860
+ result.push({ name, command: `npm run ${name}` });
861
+ }
862
+ // Scripts that directly invoke a node runtime
863
+ for (const [name, cmd] of Object.entries(scripts)) {
864
+ if (preferred.includes(name))
865
+ continue;
866
+ if (/node|tsx|ts-node|nodemon|server/i.test(cmd)) {
867
+ result.push({ name, command: `npm run ${name}` });
868
+ }
869
+ }
870
+ // Workspace per-service scripts: start:*, dev:*, serve:* patterns
871
+ // (e.g. "dev:auth", "start:wallet" common in npm/pnpm monorepos)
872
+ for (const [name] of Object.entries(scripts)) {
873
+ if (preferred.includes(name))
874
+ continue;
875
+ if (/^(?:start|dev|serve|run):[a-z0-9_-]+$/i.test(name) && !result.some(r => r.name === name)) {
876
+ result.push({ name, command: `npm run ${name}` });
877
+ }
878
+ }
879
+ return result;
880
+ }
881
+ catch {
882
+ return [];
883
+ }
286
884
  }
287
- function injectCode(filePath, projectName, apiKey, baseUrl, method) {
885
+ function injectCode(filePath, projectName, apiKey, baseUrl, backendUrl, architectureType, collectionOpts) {
288
886
  const fullPath = join(process.cwd(), filePath);
289
887
  const content = readFileSync(fullPath, 'utf-8');
290
888
  if (content.includes('apiforge-analyzer')) {
291
- return { message: 'Already configured', alreadyInjected: true };
889
+ return { alreadyInjected: true };
292
890
  }
293
891
  const isESM = detectESM(filePath, content);
294
892
  const appVar = detectAppVariable(content) ?? 'app';
295
- const snippet = getCodeSnippet(projectName, apiKey, baseUrl, method, appVar, isESM);
893
+ const snippet = buildSnippet(projectName, apiKey, baseUrl, backendUrl, appVar, isESM, architectureType, collectionOpts);
296
894
  const listenRe = /\b(?:app|server|httpServer|http)\s*\.listen\s*\(/;
297
895
  const listenMatch = listenRe.exec(content);
298
896
  let newContent;
@@ -307,58 +905,69 @@ function injectCode(filePath, projectName, apiKey, baseUrl, method) {
307
905
  injectionPoint = 'end of file';
308
906
  }
309
907
  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
- };
908
+ return { alreadyInjected: false, injectionPoint, linesAdded: snippet.split('\n').length };
318
909
  }
319
- function detectESM(filePath, content) {
910
+ /**
911
+ * Write (or overwrite) the API key in .env.local.
912
+ * Always overwrites so the key stays in sync with what the user entered —
913
+ * keeping a stale key would cause submit/poll key mismatch and 401s.
914
+ */
915
+ function persistApiKeyToEnv(apiKey) {
916
+ const envPath = join(process.cwd(), '.env.local');
917
+ const marker = 'APIFORGE_API_KEY=';
320
918
  try {
321
- const pkg = JSON.parse(readFileSync(join(process.cwd(), 'package.json'), 'utf-8'));
322
- if (pkg.type === 'module')
323
- return true;
919
+ let content = existsSync(envPath) ? readFileSync(envPath, 'utf-8') : '';
920
+ if (content.includes(marker)) {
921
+ // Replace existing value — regex matches the whole line
922
+ content = content.replace(/^APIFORGE_API_KEY=.*/m, `${marker}${apiKey}`);
923
+ }
924
+ else {
925
+ content = content.trimEnd() + `\n# APIForge CLI key (safe to delete after analysis)\n${marker}${apiKey}\n`;
926
+ }
927
+ writeFileSync(envPath, content, 'utf-8');
324
928
  }
325
929
  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);
930
+ addToGitignore('.env.local');
931
+ addToGitignore('.apiforge-status.json');
932
+ addToGitignore('.apiforge-last-run.json');
331
933
  }
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;
934
+ function addToGitignore(pattern) {
935
+ const giPath = join(process.cwd(), '.gitignore');
936
+ try {
937
+ const current = existsSync(giPath) ? readFileSync(giPath, 'utf-8') : '';
938
+ if (!current.split('\n').some(l => l.trim() === pattern)) {
939
+ writeFileSync(giPath, current.trimEnd() + `\n${pattern}\n`, 'utf-8');
940
+ }
941
+ }
942
+ catch { }
343
943
  }
344
- // ─── Snippet builder ──────────────────────────────────────────────────────────
345
- function getCodeSnippet(projectName, apiKey, baseUrl, method, appVar, isESM) {
346
- const isJson = method === 'json';
347
- const opts = isJson
944
+ function buildSnippet(projectName, apiKey, baseUrl, backendUrl, appVar, isESM, architectureType, collectionOpts) {
945
+ // The API key is embedded directly — this snippet is TEMPORARY (auto-removed after analysis).
946
+ // Hardcoding guarantees the same key is used for both submit and CLI status polling,
947
+ // eliminating the mismatch that causes 401s when .env.local already has a different key.
948
+ const isProd = backendUrl === BACKEND_PROD;
949
+ // Collection mode (multi-service): upload: false, write to per-service status file
950
+ const opts = collectionOpts
348
951
  ? [
349
952
  ` apiKey: '${apiKey}',`,
350
953
  ` projectName: '${projectName}',`,
351
954
  ` baseUrl: '${baseUrl}',`,
352
- ` saveJson: true,`,
353
- ` jsonPath: './api-export.json',`,
955
+ ` service: '${collectionOpts.serviceName}',`,
956
+ ` statusFile: '${collectionOpts.statusFile}',`,
957
+ ` architectureType: 'microservices',`,
354
958
  ` upload: false,`,
959
+ ` runOnce: true,`,
355
960
  ].join('\n')
356
961
  : [
357
962
  ` apiKey: '${apiKey}',`,
358
963
  ` projectName: '${projectName}',`,
359
964
  ` baseUrl: '${baseUrl}',`,
360
- ` apiForgeUrl: 'https://apiforge.brainfogagency.com',`,
965
+ ...(isProd ? [] : [` apiForgeUrl: '${backendUrl}',`]),
966
+ ...(architectureType && architectureType !== 'monolithic'
967
+ ? [` architectureType: '${architectureType}',`]
968
+ : []),
361
969
  ` upload: true,`,
970
+ ` runOnce: true,`,
362
971
  ].join('\n');
363
972
  if (isESM) {
364
973
  return [
@@ -382,36 +991,46 @@ function getCodeSnippet(projectName, apiKey, baseUrl, method, appVar, isESM) {
382
991
  '',
383
992
  ].join('\n');
384
993
  }
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
- ` …`,
994
+ function detectESM(filePath, content) {
995
+ try {
996
+ const pkg = JSON.parse(readFileSync(join(process.cwd(), 'package.json'), 'utf-8'));
997
+ if (pkg.type === 'module')
998
+ return true;
999
+ }
1000
+ catch { }
1001
+ if (filePath.endsWith('.mjs'))
1002
+ return true;
1003
+ if (filePath.endsWith('.cjs'))
1004
+ return false;
1005
+ return /^(?:import|export)\s+/m.test(content);
1006
+ }
1007
+ function detectAppVariable(content) {
1008
+ const patterns = [
1009
+ /(?:const|let|var)\s+(\w+)\s*=\s*express\s*(?:\.default)?\s*\(/,
1010
+ /(?:const|let|var)\s+(\w+)\s*=\s*(?:fastify|Fastify)\s*\(/,
1011
+ /(?:const|let|var)\s+(\w+)\s*=\s*new\s+Koa\s*\(/,
1012
+ /(?:const|let|var)\s+(\w+)\s*=\s*(?:Router|createApp|createServer)\s*\(/,
394
1013
  ];
1014
+ for (const re of patterns) {
1015
+ const m = content.match(re);
1016
+ if (m)
1017
+ return m[1];
1018
+ }
1019
+ return null;
395
1020
  }
396
- /** Best-guess start command from package.json scripts. */
397
- function getStartCommand() {
1021
+ // ─── Utilities ────────────────────────────────────────────────────────────────
1022
+ function tryUnlink(p) {
398
1023
  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';
1024
+ if (existsSync(p))
1025
+ unlinkSync(p);
406
1026
  }
407
1027
  catch { }
408
- return 'node ' + (detectedEntry || 'index.js');
409
1028
  }
410
- // ─── Util ─────────────────────────────────────────────────────────────────────
411
1029
  function sleep(ms) {
412
- return new Promise((r) => setTimeout(r, ms));
1030
+ return new Promise(res => setTimeout(res, ms));
413
1031
  }
414
- main().catch((err) => {
1032
+ // ─── Boot ─────────────────────────────────────────────────────────────────────
1033
+ main().catch(err => {
415
1034
  console.error(err);
416
1035
  process.exit(1);
417
1036
  });