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