apiforge-analyzer 1.2.1 → 1.3.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/README.md +166 -197
- package/dist/cjs/arch-detect.js +154 -0
- package/dist/cjs/cli.js +547 -46
- package/dist/cjs/export.js +2 -0
- package/dist/cjs/frameworks/nestjs.js +38 -0
- package/dist/cjs/index.js +41 -3
- package/dist/cjs/modules/express.js +65 -0
- package/dist/cjs/modules/nestjs.js +50 -0
- package/dist/cjs/uploader.js +3 -1
- package/dist/esm/arch-detect.js +151 -0
- package/dist/esm/cli.js +548 -47
- package/dist/esm/export.js +2 -0
- package/dist/esm/frameworks/nestjs.js +35 -0
- package/dist/esm/index.js +39 -3
- package/dist/esm/modules/express.js +60 -0
- package/dist/esm/modules/nestjs.js +47 -0
- package/dist/esm/uploader.js +3 -1
- package/dist/types/arch-detect.d.ts +9 -0
- package/dist/types/export.d.ts +2 -0
- package/dist/types/frameworks/nestjs.d.ts +10 -0
- package/dist/types/index.d.ts +11 -1
- package/dist/types/modules/express.d.ts +22 -0
- package/dist/types/modules/nestjs.d.ts +6 -0
- package/dist/types/uploader.d.ts +7 -0
- package/package.json +3 -2
package/dist/esm/cli.js
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
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';
|
|
3
|
+
import { readFileSync, existsSync, writeFileSync, unlinkSync, readdirSync } from 'fs';
|
|
4
4
|
import { join } from 'path';
|
|
5
5
|
import { spawn } from 'child_process';
|
|
6
6
|
import axios from 'axios';
|
|
7
|
+
import { detectArchitectureType } from './arch-detect.js';
|
|
8
|
+
import { uploadRoutes } from './uploader.js';
|
|
7
9
|
// ─── ANSI colour helpers ──────────────────────────────────────────────────────
|
|
8
10
|
const r = '\x1b[0m';
|
|
9
11
|
const b = '\x1b[1m';
|
|
@@ -20,7 +22,7 @@ const green = (s) => `${gn}${s}${r}`;
|
|
|
20
22
|
const yellow = (s) => `${yl}${s}${r}`;
|
|
21
23
|
const red = (s) => `${rd}${s}${r}`;
|
|
22
24
|
const white = (s) => `${wh}${s}${r}`;
|
|
23
|
-
const VERSION = '1.
|
|
25
|
+
const VERSION = '1.3.1';
|
|
24
26
|
// Built-in URLs — users never need to configure these
|
|
25
27
|
const BACKEND_PROD = 'https://apiforgeapi.brainfogagency.com';
|
|
26
28
|
const DASHBOARD_PROD = 'https://apiforge.brainfogagency.com/dashboard';
|
|
@@ -80,25 +82,6 @@ async function main() {
|
|
|
80
82
|
if (isCancel(answers))
|
|
81
83
|
process.exit(0);
|
|
82
84
|
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
85
|
// ── Select backend ────────────────────────────────────────────────────────
|
|
103
86
|
const backendChoice = await select({
|
|
104
87
|
message: 'Send routes to',
|
|
@@ -122,13 +105,72 @@ async function main() {
|
|
|
122
105
|
const isLocal = backendChoice === 'local';
|
|
123
106
|
const backendUrl = isLocal ? BACKEND_LOCAL : BACKEND_PROD;
|
|
124
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;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
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
|
+
}
|
|
125
163
|
log.info(dim('─────────────────────────────────────────────────'));
|
|
126
|
-
log.info(`${dim('Project:')}
|
|
127
|
-
log.info(`${dim('Command:')}
|
|
128
|
-
log.info(`${dim('Backend:')}
|
|
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]}`);
|
|
129
168
|
log.info(dim('─────────────────────────────────────────────────'));
|
|
169
|
+
if (archResult.moduleNames.length > 0) {
|
|
170
|
+
log.info(`${dim('Modules found:')} ${archResult.moduleNames.map(m => bold(m)).join(', ')}`);
|
|
171
|
+
}
|
|
130
172
|
// ── Detect & patch entry file ─────────────────────────────────────────────
|
|
131
|
-
const entryFile = detectEntryFile() ?? await askEntryFile();
|
|
173
|
+
const entryFile = detectEntryFile(archResult.type) ?? await askEntryFile(archResult.type);
|
|
132
174
|
let injected = false;
|
|
133
175
|
let linesAdded = 0;
|
|
134
176
|
await tasks([
|
|
@@ -151,7 +193,7 @@ async function main() {
|
|
|
151
193
|
tryUnlink(join(process.cwd(), LOCK_FILE));
|
|
152
194
|
// Store API key in .env.local — never embed it in source code
|
|
153
195
|
persistApiKeyToEnv(apiKey);
|
|
154
|
-
const result = injectCode(entryFile, projectName, apiKey, baseUrl, backendUrl);
|
|
196
|
+
const result = injectCode(entryFile, projectName, apiKey, baseUrl, backendUrl, archResult.type);
|
|
155
197
|
injected = !result.alreadyInjected;
|
|
156
198
|
linesAdded = result.linesAdded ?? 0;
|
|
157
199
|
if (result.alreadyInjected) {
|
|
@@ -248,6 +290,288 @@ async function main() {
|
|
|
248
290
|
}[approval.status];
|
|
249
291
|
outro(outroMsg);
|
|
250
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
|
+
function hasDockerCompose(cwd) {
|
|
390
|
+
return ['docker-compose.yml', 'docker-compose.yaml', 'compose.yml', 'compose.yaml']
|
|
391
|
+
.some(f => existsSync(join(cwd, f)));
|
|
392
|
+
}
|
|
393
|
+
async function microservicesFlow(config, opts) {
|
|
394
|
+
const cwd = process.cwd();
|
|
395
|
+
const allRoutes = [];
|
|
396
|
+
const serviceMeta = [];
|
|
397
|
+
const injectedFiles = [];
|
|
398
|
+
// Clear any stale per-service status files
|
|
399
|
+
for (const svc of config.services) {
|
|
400
|
+
tryUnlink(join(cwd, `.apiforge-status-${svc.name}.json`));
|
|
401
|
+
}
|
|
402
|
+
tryUnlink(join(cwd, STATUS_FILE));
|
|
403
|
+
tryUnlink(join(cwd, LOCK_FILE));
|
|
404
|
+
persistApiKeyToEnv(opts.apiKey);
|
|
405
|
+
if (hasDockerCompose(cwd)) {
|
|
406
|
+
note([
|
|
407
|
+
`${yellow('Docker Compose detected — APIForge will NOT start, build, or touch Docker.')}`,
|
|
408
|
+
'',
|
|
409
|
+
dim('Each service below is run directly with node/tsx on this machine, just'),
|
|
410
|
+
dim('long enough to capture its routes, then reverted. Your containers (if'),
|
|
411
|
+
dim('running) are left completely alone — no ports, network, or images are used.'),
|
|
412
|
+
'',
|
|
413
|
+
bold('For this to work:'),
|
|
414
|
+
` ${dim('·')} ${bold('npm install')} ${dim('has been run at the project root (deps must resolve outside Docker)')}`,
|
|
415
|
+
` ${dim('·')} a readable ${bold('.env')} ${dim('at the project root (same values Docker passes via env_file)')}`,
|
|
416
|
+
'',
|
|
417
|
+
dim("Routes are captured before RabbitMQ/Redis/DB connections resolve, so those don't need to be reachable."),
|
|
418
|
+
].join('\n'), 'Docker Compose project');
|
|
419
|
+
const proceed = await confirm({
|
|
420
|
+
message: 'Run services locally to collect routes?',
|
|
421
|
+
initialValue: true,
|
|
422
|
+
});
|
|
423
|
+
if (isCancel(proceed) || !proceed) {
|
|
424
|
+
cancel(dim('Cancelled. Run `npm install` at the project root, then re-run apiforge.'));
|
|
425
|
+
process.exit(0);
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
note([
|
|
429
|
+
bold(`Analyzing ${config.services.length} service${config.services.length > 1 ? 's' : ''}…`),
|
|
430
|
+
'',
|
|
431
|
+
...config.services.map(s => ` ${dim('·')} ${bold(s.name)} ${dim('→')} ${dim(s.dir)}`),
|
|
432
|
+
'',
|
|
433
|
+
dim('Each service is started individually and its routes are collected.'),
|
|
434
|
+
].join('\n'), 'Multi-service analysis');
|
|
435
|
+
for (const svc of config.services) {
|
|
436
|
+
const svcSpin = spinner();
|
|
437
|
+
svcSpin.start(`${bold(svc.name)} — starting…`);
|
|
438
|
+
const svcDir = svc.dir.replace(/^\.\//, '');
|
|
439
|
+
// Resolve entry file
|
|
440
|
+
const relEntry = svc.entryFile ?? findEntryInDir(join(cwd, svcDir));
|
|
441
|
+
if (!relEntry) {
|
|
442
|
+
svcSpin.stop(yellow(`◌ ${svc.name} — no entry file found, skipping`));
|
|
443
|
+
continue;
|
|
444
|
+
}
|
|
445
|
+
const fullEntry = join(svcDir, relEntry);
|
|
446
|
+
const statusFile = `.apiforge-status-${svc.name}.json`;
|
|
447
|
+
// Inject collection snippet (upload: false, writes to per-service status file)
|
|
448
|
+
injectCode(fullEntry, opts.projectName, opts.apiKey, opts.baseUrl, opts.backendUrl, 'microservices', {
|
|
449
|
+
serviceName: svc.name,
|
|
450
|
+
statusFile,
|
|
451
|
+
});
|
|
452
|
+
injectedFiles.push(fullEntry);
|
|
453
|
+
// Start service with plain node (or tsx for .ts files)
|
|
454
|
+
const startCmd = svc.startCommand ?? serviceNodeCommand(fullEntry);
|
|
455
|
+
const child = spawn(startCmd, {
|
|
456
|
+
cwd,
|
|
457
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
458
|
+
shell: true,
|
|
459
|
+
env: { ...process.env },
|
|
460
|
+
detached: false,
|
|
461
|
+
});
|
|
462
|
+
// Capture output so failures are debuggable without re-running the service by hand.
|
|
463
|
+
// Bounded to the last ~4000 chars so a chatty/looping service can't blow up memory.
|
|
464
|
+
let output = '';
|
|
465
|
+
const MAX_OUTPUT = 4000;
|
|
466
|
+
const capture = (chunk) => {
|
|
467
|
+
output += chunk.toString();
|
|
468
|
+
if (output.length > MAX_OUTPUT)
|
|
469
|
+
output = output.slice(-MAX_OUTPUT);
|
|
470
|
+
};
|
|
471
|
+
child.stdout?.on('data', capture);
|
|
472
|
+
child.stderr?.on('data', capture);
|
|
473
|
+
// Wait up to 30 s for the status file to appear
|
|
474
|
+
const routes = await waitForServiceStatusFile(statusFile, 30000);
|
|
475
|
+
// Kill service process
|
|
476
|
+
try {
|
|
477
|
+
child.kill('SIGTERM');
|
|
478
|
+
}
|
|
479
|
+
catch { }
|
|
480
|
+
tryUnlink(join(cwd, statusFile));
|
|
481
|
+
if (routes && routes.length > 0) {
|
|
482
|
+
const annotated = routes.map((r) => ({ ...r, service: svc.name }));
|
|
483
|
+
allRoutes.push(...annotated);
|
|
484
|
+
serviceMeta.push({ name: svc.name, baseUrl: svc.baseUrl });
|
|
485
|
+
svcSpin.stop(green(`✓ ${svc.name} — ${routes.length} routes collected`));
|
|
486
|
+
}
|
|
487
|
+
else {
|
|
488
|
+
svcSpin.stop(yellow(`◌ ${svc.name} — no routes collected (timeout or error)`));
|
|
489
|
+
const trimmed = output.trim();
|
|
490
|
+
if (trimmed) {
|
|
491
|
+
log.error(dim(` ${bold(svc.name)} output:\n` + trimmed.split('\n').slice(-20).map(l => ` ${l}`).join('\n')));
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
// Remove injected code
|
|
495
|
+
removeInjectedCode(fullEntry);
|
|
496
|
+
injectedFiles.splice(injectedFiles.indexOf(fullEntry), 1);
|
|
497
|
+
}
|
|
498
|
+
// Safety: clean up any remaining injections (e.g. if process was interrupted)
|
|
499
|
+
for (const f of injectedFiles)
|
|
500
|
+
removeInjectedCode(f);
|
|
501
|
+
if (allRoutes.length === 0) {
|
|
502
|
+
log.error(red('No routes were collected from any service.'));
|
|
503
|
+
outro(red('Analysis failed.'));
|
|
504
|
+
process.exit(1);
|
|
505
|
+
}
|
|
506
|
+
// Upload aggregated routes
|
|
507
|
+
const uploadSpin = spinner();
|
|
508
|
+
uploadSpin.start(`Uploading ${allRoutes.length} routes from ${serviceMeta.length} service${serviceMeta.length > 1 ? 's' : ''}…`);
|
|
509
|
+
const uploadResult = await uploadRoutes(allRoutes, {
|
|
510
|
+
projectName: opts.projectName,
|
|
511
|
+
apiKey: opts.apiKey,
|
|
512
|
+
baseUrl: opts.baseUrl,
|
|
513
|
+
apiForgeUrl: opts.backendUrl,
|
|
514
|
+
architectureType: 'microservices',
|
|
515
|
+
services: serviceMeta,
|
|
516
|
+
});
|
|
517
|
+
if (!uploadResult) {
|
|
518
|
+
uploadSpin.stop(red('✗ Upload failed'));
|
|
519
|
+
outro(red('Analysis failed.'));
|
|
520
|
+
process.exit(1);
|
|
521
|
+
}
|
|
522
|
+
uploadSpin.stop(green(`✓ ${uploadResult.routeCount} routes submitted across ${serviceMeta.length} service${serviceMeta.length > 1 ? 's' : ''}`));
|
|
523
|
+
// ── Poll for dashboard approval ───────────────────────────────────────────
|
|
524
|
+
note([
|
|
525
|
+
bold('Open your dashboard to review:'),
|
|
526
|
+
'',
|
|
527
|
+
` ${cyan(opts.dashboardUrl)}`,
|
|
528
|
+
'',
|
|
529
|
+
dim('In "Import JSON" → "Pending CLI Imports"'),
|
|
530
|
+
dim('Accept to start AI processing · Deny to discard.'),
|
|
531
|
+
].join('\n'), 'Action required');
|
|
532
|
+
const poll = spinner();
|
|
533
|
+
poll.start('Waiting for your decision in the dashboard… (5 min timeout)');
|
|
534
|
+
const approval = await pollForApproval(uploadResult.pendingImportId, opts.apiKey, opts.backendUrl, 5 * 60000);
|
|
535
|
+
const statusMsg = {
|
|
536
|
+
approved: green('✓ Accepted — AI processing started!'),
|
|
537
|
+
denied: red('✗ Import denied by dashboard.'),
|
|
538
|
+
timeout: yellow('◌ Timed out — no decision made within 5 minutes.'),
|
|
539
|
+
}[approval.status];
|
|
540
|
+
poll.stop(statusMsg);
|
|
541
|
+
if (approval.status === 'approved') {
|
|
542
|
+
note([
|
|
543
|
+
bold('Your API is being documented.'),
|
|
544
|
+
'',
|
|
545
|
+
` ${dim('Track progress:')} ${cyan(opts.dashboardUrl)}`,
|
|
546
|
+
` ${dim('Import ID:')} ${dim(uploadResult.pendingImportId)}`,
|
|
547
|
+
].join('\n'), 'Processing');
|
|
548
|
+
}
|
|
549
|
+
else if (approval.status === 'denied') {
|
|
550
|
+
note([
|
|
551
|
+
` ${bold('You denied this import from the dashboard.')}`,
|
|
552
|
+
'',
|
|
553
|
+
` ${dim('No documentation was generated.')}`,
|
|
554
|
+
` ${dim('Run')} ${cyan('npx apiforge')} ${dim('again whenever you\'re ready.')}`,
|
|
555
|
+
].join('\n'), 'Denied');
|
|
556
|
+
}
|
|
557
|
+
else {
|
|
558
|
+
note([
|
|
559
|
+
` ${dim('Your import is still pending:')}`,
|
|
560
|
+
` ${cyan(opts.dashboardUrl)}`,
|
|
561
|
+
'',
|
|
562
|
+
` ${dim('Import ID:')} ${cyan(uploadResult.pendingImportId)}`,
|
|
563
|
+
` ${dim('You can still accept or deny it from the dashboard.')}`,
|
|
564
|
+
].join('\n'), 'Pending');
|
|
565
|
+
}
|
|
566
|
+
tryUnlink(join(cwd, STATUS_FILE));
|
|
567
|
+
tryUnlink(join(cwd, LOCK_FILE));
|
|
568
|
+
const outroMsg = {
|
|
569
|
+
approved: `${green('✓')} Done! View your AI docs at ${cyan(opts.dashboardUrl)}`,
|
|
570
|
+
denied: `${red('✗')} Import denied. No changes were made.`,
|
|
571
|
+
timeout: `${yellow('◌')} Timed out. Your import is still pending at ${cyan(opts.dashboardUrl)}`,
|
|
572
|
+
}[approval.status];
|
|
573
|
+
outro(outroMsg);
|
|
574
|
+
}
|
|
251
575
|
function startAndWaitForAnalysis(command, timeoutMs) {
|
|
252
576
|
return new Promise(resolve => {
|
|
253
577
|
const statusPath = join(process.cwd(), STATUS_FILE);
|
|
@@ -379,9 +703,124 @@ function removeInjectedCode(filePath) {
|
|
|
379
703
|
return false;
|
|
380
704
|
}
|
|
381
705
|
}
|
|
382
|
-
|
|
383
|
-
function
|
|
706
|
+
/** Look for a server entry inside a single directory (checks src/ then root). */
|
|
707
|
+
function findEntryInDir(absDir) {
|
|
708
|
+
const names = ['index', 'server', 'app', 'main'];
|
|
709
|
+
const exts = ['ts', 'js', 'mjs', 'cjs'];
|
|
710
|
+
for (const depth of ['src', '']) {
|
|
711
|
+
for (const name of names) {
|
|
712
|
+
for (const ext of exts) {
|
|
713
|
+
const rel = depth ? join(depth, `${name}.${ext}`) : `${name}.${ext}`;
|
|
714
|
+
if (existsSync(join(absDir, rel)))
|
|
715
|
+
return rel;
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
return null;
|
|
720
|
+
}
|
|
721
|
+
/**
|
|
722
|
+
* Detect the project's root/main entry point — package.json main,
|
|
723
|
+
* common gateway directories, then flat root candidates.
|
|
724
|
+
* Returns a relative path or null.
|
|
725
|
+
*/
|
|
726
|
+
function detectMainEntry(cwd) {
|
|
727
|
+
// 1. package.json main / start / dev script
|
|
728
|
+
try {
|
|
729
|
+
const pkg = JSON.parse(readFileSync(join(cwd, 'package.json'), 'utf-8'));
|
|
730
|
+
if (pkg.main && existsSync(join(cwd, pkg.main)))
|
|
731
|
+
return pkg.main;
|
|
732
|
+
for (const s of ['start', 'dev', 'serve']) {
|
|
733
|
+
const cmd = pkg.scripts?.[s] ?? '';
|
|
734
|
+
const m = cmd.match(/(?:node|tsx|ts-node|nodemon)\s+([^\s&|;]+\.(js|ts|mjs|cjs))/);
|
|
735
|
+
if (m && existsSync(join(cwd, m[1])))
|
|
736
|
+
return m[1];
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
catch { }
|
|
740
|
+
// 2. Common gateway/proxy directory (e.g. api-gateway/)
|
|
741
|
+
for (const gatewayDir of ['api-gateway', 'gateway', 'proxy', 'api-proxy']) {
|
|
742
|
+
const entry = findEntryInDir(join(cwd, gatewayDir));
|
|
743
|
+
if (entry)
|
|
744
|
+
return join(gatewayDir, entry);
|
|
745
|
+
}
|
|
746
|
+
// 3. Flat root candidates
|
|
747
|
+
for (const c of [
|
|
748
|
+
'index.ts', 'server.ts', 'app.ts', 'main.ts',
|
|
749
|
+
'index.js', 'server.js', 'app.js', 'main.js',
|
|
750
|
+
'src/index.ts', 'src/server.ts', 'src/app.ts', 'src/main.ts',
|
|
751
|
+
'src/index.js', 'src/server.js', 'src/app.js', 'src/main.js',
|
|
752
|
+
]) {
|
|
753
|
+
if (existsSync(join(cwd, c)))
|
|
754
|
+
return c;
|
|
755
|
+
}
|
|
756
|
+
return null;
|
|
757
|
+
}
|
|
758
|
+
/**
|
|
759
|
+
* Scan the project for per-service entry files.
|
|
760
|
+
* Prepends the root main entry (if any) so it appears first in the select list.
|
|
761
|
+
* Reads apiforge.config.json first; falls back to common service parent dirs.
|
|
762
|
+
*/
|
|
763
|
+
function findServiceEntries() {
|
|
764
|
+
const cwd = process.cwd();
|
|
765
|
+
const results = [];
|
|
766
|
+
// 0. Root / main entry — shown first so the user always sees it
|
|
767
|
+
const mainEntry = detectMainEntry(cwd);
|
|
768
|
+
if (mainEntry) {
|
|
769
|
+
results.push({ label: `[ Main entry ] → ${mainEntry}`, path: mainEntry });
|
|
770
|
+
}
|
|
771
|
+
// 1. Authoritative list from apiforge.config.json / .apiforge.json
|
|
772
|
+
for (const cfgName of ['apiforge.config.json', '.apiforge.json']) {
|
|
773
|
+
try {
|
|
774
|
+
const cfg = JSON.parse(readFileSync(join(cwd, cfgName), 'utf-8'));
|
|
775
|
+
if (!Array.isArray(cfg.services) || cfg.services.length === 0)
|
|
776
|
+
continue;
|
|
777
|
+
for (const svc of cfg.services) {
|
|
778
|
+
const dir = svc.dir.replace(/^\.\//, '');
|
|
779
|
+
const entry = findEntryInDir(join(cwd, dir));
|
|
780
|
+
if (!entry)
|
|
781
|
+
continue;
|
|
782
|
+
const relPath = join(dir, entry);
|
|
783
|
+
// Skip if same path as the main entry already added
|
|
784
|
+
if (relPath !== mainEntry) {
|
|
785
|
+
results.push({ label: `${svc.name} → ${relPath}`, path: relPath });
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
if (results.length > 0)
|
|
789
|
+
return results;
|
|
790
|
+
}
|
|
791
|
+
catch { }
|
|
792
|
+
}
|
|
793
|
+
// 2. Filesystem scan of common service parent dirs
|
|
794
|
+
for (const parentDir of ['services', 'apps', 'packages', 'src/services', 'microservices']) {
|
|
795
|
+
const parentPath = join(cwd, parentDir);
|
|
796
|
+
if (!existsSync(parentPath))
|
|
797
|
+
continue;
|
|
798
|
+
try {
|
|
799
|
+
const subdirs = readdirSync(parentPath, { withFileTypes: true }).filter(d => d.isDirectory());
|
|
800
|
+
for (const subdir of subdirs) {
|
|
801
|
+
const entry = findEntryInDir(join(parentPath, subdir.name));
|
|
802
|
+
if (!entry)
|
|
803
|
+
continue;
|
|
804
|
+
const relPath = join(parentDir, subdir.name, entry);
|
|
805
|
+
if (relPath !== mainEntry) {
|
|
806
|
+
results.push({ label: `${subdir.name} → ${relPath}`, path: relPath });
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
catch { }
|
|
811
|
+
if (results.length > 1)
|
|
812
|
+
return results; // >1 because main entry is already at index 0
|
|
813
|
+
}
|
|
814
|
+
return results;
|
|
815
|
+
}
|
|
816
|
+
function detectEntryFile(archType) {
|
|
384
817
|
const cwd = process.cwd();
|
|
818
|
+
// For microservices: auto-pick only when exactly one service entry is found
|
|
819
|
+
if (archType === 'microservices') {
|
|
820
|
+
const entries = findServiceEntries();
|
|
821
|
+
return entries.length === 1 ? entries[0].path : null;
|
|
822
|
+
}
|
|
823
|
+
// Package.json hints
|
|
385
824
|
try {
|
|
386
825
|
const pkg = JSON.parse(readFileSync(join(cwd, 'package.json'), 'utf-8'));
|
|
387
826
|
if (pkg.main && existsSync(join(cwd, pkg.main)))
|
|
@@ -394,16 +833,47 @@ function detectEntryFile() {
|
|
|
394
833
|
}
|
|
395
834
|
}
|
|
396
835
|
catch { }
|
|
836
|
+
// Flat file candidates — root → src/ → compiled output → misc
|
|
397
837
|
const candidates = [
|
|
398
|
-
'index.ts', 'server.ts', 'app.ts',
|
|
399
|
-
'index.js', 'server.js', 'app.js',
|
|
400
|
-
'src/index.ts', 'src/server.ts', 'src/app.ts',
|
|
401
|
-
'src/index.js', 'src/server.js', 'src/app.js',
|
|
838
|
+
'index.ts', 'server.ts', 'app.ts', 'main.ts',
|
|
839
|
+
'index.js', 'server.js', 'app.js', 'main.js',
|
|
840
|
+
'src/index.ts', 'src/server.ts', 'src/app.ts', 'src/main.ts',
|
|
841
|
+
'src/index.js', 'src/server.js', 'src/app.js', 'src/main.js',
|
|
842
|
+
'dist/index.js', 'dist/server.js', 'dist/main.js',
|
|
843
|
+
'build/index.js', 'build/server.js', 'build/main.js',
|
|
844
|
+
'lib/index.js', 'lib/server.js',
|
|
845
|
+
'server/index.ts', 'server/index.js',
|
|
846
|
+
'api/index.ts', 'api/index.js',
|
|
847
|
+
'src/server/index.ts', 'src/server/index.js',
|
|
848
|
+
'lambda.ts', 'lambda.js', 'handler.ts', 'handler.js',
|
|
402
849
|
];
|
|
403
850
|
return candidates.find(c => existsSync(join(cwd, c))) ?? null;
|
|
404
851
|
}
|
|
405
|
-
async function askEntryFile() {
|
|
406
|
-
|
|
852
|
+
async function askEntryFile(archType) {
|
|
853
|
+
if (archType === 'microservices') {
|
|
854
|
+
const entries = findServiceEntries();
|
|
855
|
+
if (entries.length > 0) {
|
|
856
|
+
const choice = await select({
|
|
857
|
+
message: 'Which service entry file do you want to analyze?',
|
|
858
|
+
options: [
|
|
859
|
+
...entries.map(e => ({ value: e.path, label: e.label })),
|
|
860
|
+
{ value: '__manual', label: 'Enter path manually…' },
|
|
861
|
+
],
|
|
862
|
+
});
|
|
863
|
+
if (isCancel(choice)) {
|
|
864
|
+
cancel('Cancelled.');
|
|
865
|
+
process.exit(0);
|
|
866
|
+
}
|
|
867
|
+
if (choice !== '__manual')
|
|
868
|
+
return choice;
|
|
869
|
+
}
|
|
870
|
+
else {
|
|
871
|
+
log.warn('Could not find service entry files in services/, apps/, or packages/.');
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
else {
|
|
875
|
+
log.warn('Could not auto-detect your server entry file.');
|
|
876
|
+
}
|
|
407
877
|
const custom = await text({
|
|
408
878
|
message: 'Path to your server entry file',
|
|
409
879
|
placeholder: 'src/index.ts',
|
|
@@ -431,7 +901,7 @@ function detectScripts() {
|
|
|
431
901
|
if (scripts[name])
|
|
432
902
|
result.push({ name, command: `npm run ${name}` });
|
|
433
903
|
}
|
|
434
|
-
//
|
|
904
|
+
// Scripts that directly invoke a node runtime
|
|
435
905
|
for (const [name, cmd] of Object.entries(scripts)) {
|
|
436
906
|
if (preferred.includes(name))
|
|
437
907
|
continue;
|
|
@@ -439,13 +909,22 @@ function detectScripts() {
|
|
|
439
909
|
result.push({ name, command: `npm run ${name}` });
|
|
440
910
|
}
|
|
441
911
|
}
|
|
912
|
+
// Workspace per-service scripts: start:*, dev:*, serve:* patterns
|
|
913
|
+
// (e.g. "dev:auth", "start:wallet" common in npm/pnpm monorepos)
|
|
914
|
+
for (const [name] of Object.entries(scripts)) {
|
|
915
|
+
if (preferred.includes(name))
|
|
916
|
+
continue;
|
|
917
|
+
if (/^(?:start|dev|serve|run):[a-z0-9_-]+$/i.test(name) && !result.some(r => r.name === name)) {
|
|
918
|
+
result.push({ name, command: `npm run ${name}` });
|
|
919
|
+
}
|
|
920
|
+
}
|
|
442
921
|
return result;
|
|
443
922
|
}
|
|
444
923
|
catch {
|
|
445
924
|
return [];
|
|
446
925
|
}
|
|
447
926
|
}
|
|
448
|
-
function injectCode(filePath, projectName, apiKey, baseUrl, backendUrl) {
|
|
927
|
+
function injectCode(filePath, projectName, apiKey, baseUrl, backendUrl, architectureType, collectionOpts) {
|
|
449
928
|
const fullPath = join(process.cwd(), filePath);
|
|
450
929
|
const content = readFileSync(fullPath, 'utf-8');
|
|
451
930
|
if (content.includes('apiforge-analyzer')) {
|
|
@@ -453,9 +932,16 @@ function injectCode(filePath, projectName, apiKey, baseUrl, backendUrl) {
|
|
|
453
932
|
}
|
|
454
933
|
const isESM = detectESM(filePath, content);
|
|
455
934
|
const appVar = detectAppVariable(content) ?? 'app';
|
|
456
|
-
const snippet = buildSnippet(projectName, apiKey, baseUrl, backendUrl, appVar, isESM);
|
|
935
|
+
const snippet = buildSnippet(projectName, apiKey, baseUrl, backendUrl, appVar, isESM, architectureType, collectionOpts);
|
|
936
|
+
// Collection mode (microservices): append at end of file instead of before
|
|
937
|
+
// .listen(). Route mounting (app.use(...)) happens synchronously at module
|
|
938
|
+
// load; .listen() is often gated behind an async bootstrap (DB/broker
|
|
939
|
+
// connect) that can hang or fail outside its container/network. Appending
|
|
940
|
+
// at the end runs right after the synchronous portion of the module —
|
|
941
|
+
// independent of whether that bootstrap ever finishes — so route capture
|
|
942
|
+
// doesn't require RabbitMQ/Redis/DB to be reachable.
|
|
457
943
|
const listenRe = /\b(?:app|server|httpServer|http)\s*\.listen\s*\(/;
|
|
458
|
-
const listenMatch = listenRe.exec(content);
|
|
944
|
+
const listenMatch = collectionOpts ? undefined : listenRe.exec(content);
|
|
459
945
|
let newContent;
|
|
460
946
|
let injectionPoint;
|
|
461
947
|
if (listenMatch?.index !== undefined) {
|
|
@@ -504,19 +990,34 @@ function addToGitignore(pattern) {
|
|
|
504
990
|
}
|
|
505
991
|
catch { }
|
|
506
992
|
}
|
|
507
|
-
function buildSnippet(projectName, apiKey, baseUrl, backendUrl, appVar, isESM) {
|
|
993
|
+
function buildSnippet(projectName, apiKey, baseUrl, backendUrl, appVar, isESM, architectureType, collectionOpts) {
|
|
508
994
|
// The API key is embedded directly — this snippet is TEMPORARY (auto-removed after analysis).
|
|
509
995
|
// Hardcoding guarantees the same key is used for both submit and CLI status polling,
|
|
510
996
|
// eliminating the mismatch that causes 401s when .env.local already has a different key.
|
|
511
997
|
const isProd = backendUrl === BACKEND_PROD;
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
998
|
+
// Collection mode (multi-service): upload: false, write to per-service status file
|
|
999
|
+
const opts = collectionOpts
|
|
1000
|
+
? [
|
|
1001
|
+
` apiKey: '${apiKey}',`,
|
|
1002
|
+
` projectName: '${projectName}',`,
|
|
1003
|
+
` baseUrl: '${baseUrl}',`,
|
|
1004
|
+
` service: '${collectionOpts.serviceName}',`,
|
|
1005
|
+
` statusFile: '${collectionOpts.statusFile}',`,
|
|
1006
|
+
` architectureType: 'microservices',`,
|
|
1007
|
+
` upload: false,`,
|
|
1008
|
+
` runOnce: true,`,
|
|
1009
|
+
].join('\n')
|
|
1010
|
+
: [
|
|
1011
|
+
` apiKey: '${apiKey}',`,
|
|
1012
|
+
` projectName: '${projectName}',`,
|
|
1013
|
+
` baseUrl: '${baseUrl}',`,
|
|
1014
|
+
...(isProd ? [] : [` apiForgeUrl: '${backendUrl}',`]),
|
|
1015
|
+
...(architectureType && architectureType !== 'monolithic'
|
|
1016
|
+
? [` architectureType: '${architectureType}',`]
|
|
1017
|
+
: []),
|
|
1018
|
+
` upload: true,`,
|
|
1019
|
+
` runOnce: true,`,
|
|
1020
|
+
].join('\n');
|
|
520
1021
|
if (isESM) {
|
|
521
1022
|
return [
|
|
522
1023
|
`// APIForge — https://apiforge.brainfogagency.com`,
|