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/cjs/cli.js +425 -255
- package/dist/cjs/detect.js +68 -48
- package/dist/cjs/frameworks/express.js +80 -74
- package/dist/cjs/index.js +118 -58
- package/dist/cjs/uploader.js +49 -11
- package/dist/esm/cli.js +424 -257
- package/dist/esm/detect.js +68 -48
- package/dist/esm/frameworks/express.js +80 -74
- package/dist/esm/index.js +120 -60
- package/dist/esm/uploader.js +47 -11
- package/dist/types/detect.d.ts +3 -5
- package/dist/types/frameworks/express.d.ts +2 -2
- package/dist/types/index.d.ts +1 -4
- package/dist/types/uploader.d.ts +10 -2
- package/package.json +1 -1
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
|
-
|
|
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
|
|
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
|
|
21
|
+
const red = (s) => `${rd}${s}${r}`;
|
|
20
22
|
const white = (s) => `${wh}${s}${r}`;
|
|
21
|
-
|
|
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
|
-
|
|
34
|
+
console.log([
|
|
24
35
|
'',
|
|
25
36
|
` ${cyan('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━')}`,
|
|
26
37
|
'',
|
|
27
|
-
` ${bold(white('⚡ APIForge'))} ${dim(
|
|
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
|
-
// ───
|
|
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');
|
|
49
|
+
process.stdout.write('\x1Bc');
|
|
48
50
|
printHeader();
|
|
49
|
-
intro(bold(` ${cyan('⚡')} APIForge
|
|
50
|
-
// ──
|
|
51
|
+
intro(bold(` ${cyan('⚡')} APIForge Analyzer `));
|
|
52
|
+
// ── Collect setup answers ─────────────────────────────────────────────────
|
|
51
53
|
const answers = await group({
|
|
52
54
|
projectName: () => text({
|
|
53
|
-
message: '
|
|
54
|
-
placeholder: 'My
|
|
55
|
-
validate:
|
|
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:
|
|
61
|
+
validate: v => {
|
|
60
62
|
if (!v.trim())
|
|
61
|
-
return '
|
|
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:
|
|
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('
|
|
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
|
|
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('
|
|
98
|
-
log.info(`${dim('
|
|
127
|
+
log.info(`${dim('Command:')} ${bold(runCommand)}`);
|
|
128
|
+
log.info(`${dim('Backend:')} ${cyan(backendUrl)}`);
|
|
99
129
|
log.info(dim('─────────────────────────────────────────────────'));
|
|
100
|
-
// ──
|
|
101
|
-
const
|
|
102
|
-
let
|
|
103
|
-
|
|
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:
|
|
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
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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
|
|
158
|
+
return `${yellow('◌')} Already configured — no changes made`;
|
|
169
159
|
}
|
|
170
160
|
return [
|
|
171
|
-
`${green('✓')} Patched
|
|
172
|
-
`
|
|
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
|
-
// ──
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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
|
-
|
|
203
|
-
|
|
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
|
-
|
|
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('
|
|
211
|
-
''
|
|
212
|
-
|
|
213
|
-
].join('\n'), 'Patch summary');
|
|
217
|
+
` ${dim('Track progress:')} ${cyan(dashboardUrl)}`,
|
|
218
|
+
` ${dim('Import ID:')} ${dim(pendingImportId)}`,
|
|
219
|
+
].join('\n'), 'Processing');
|
|
214
220
|
}
|
|
215
|
-
|
|
216
|
-
|
|
221
|
+
else if (approval.status === 'denied') {
|
|
222
|
+
poll.stop(red('✗ Import denied by dashboard.'));
|
|
217
223
|
note([
|
|
218
|
-
bold('
|
|
219
|
-
'',
|
|
220
|
-
` ${dim('1.')} Start your server once:`,
|
|
221
|
-
` ${cyan(getStartCommand())}`,
|
|
224
|
+
` ${bold('You denied this import from the dashboard.')}`,
|
|
222
225
|
'',
|
|
223
|
-
` ${dim('
|
|
224
|
-
`
|
|
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
|
-
|
|
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('
|
|
238
|
-
''
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
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
|
|
253
|
-
const cmd = pkg.scripts?.[
|
|
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
|
-
|
|
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
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
if (
|
|
284
|
-
|
|
285
|
-
|
|
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
|
|
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 {
|
|
452
|
+
return { alreadyInjected: true };
|
|
292
453
|
}
|
|
293
454
|
const isESM = detectESM(filePath, content);
|
|
294
455
|
const appVar = detectAppVariable(content) ?? 'app';
|
|
295
|
-
const snippet =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
322
|
-
if (
|
|
323
|
-
|
|
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
|
-
|
|
327
|
-
|
|
328
|
-
|
|
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
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
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
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
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
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
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
|
-
|
|
397
|
-
function
|
|
569
|
+
// ─── Utilities ────────────────────────────────────────────────────────────────
|
|
570
|
+
function tryUnlink(p) {
|
|
398
571
|
try {
|
|
399
|
-
|
|
400
|
-
|
|
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(
|
|
578
|
+
return new Promise(res => setTimeout(res, ms));
|
|
413
579
|
}
|
|
414
|
-
|
|
580
|
+
// ─── Boot ─────────────────────────────────────────────────────────────────────
|
|
581
|
+
main().catch(err => {
|
|
415
582
|
console.error(err);
|
|
416
583
|
process.exit(1);
|
|
417
584
|
});
|