offbyt 1.0.0 → 1.0.2
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/cli.js +117 -2
- package/industry-mode.json +22 -0
- package/lib/ir-integration.js +4 -4
- package/lib/modes/configBasedGenerator.js +27 -27
- package/lib/modes/connect.js +26 -25
- package/lib/modes/generateApi.js +217 -76
- package/lib/modes/interactiveSetup.js +28 -29
- package/lib/utils/cliFormatter.js +131 -0
- package/lib/utils/codeInjector.js +144 -26
- package/lib/utils/industryMode.js +419 -0
- package/lib/utils/postGenerationVerifier.js +256 -0
- package/lib/utils/resourceDetector.js +234 -169
- package/package.json +1 -1
|
@@ -0,0 +1,419 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { spawn, exec } from 'child_process';
|
|
4
|
+
import { promisify } from 'util';
|
|
5
|
+
import { fileURLToPath } from 'url';
|
|
6
|
+
import chalk from 'chalk';
|
|
7
|
+
import ora from 'ora';
|
|
8
|
+
import { detectResourcesFromFrontend } from './resourceDetector.js';
|
|
9
|
+
|
|
10
|
+
const execAsync = promisify(exec);
|
|
11
|
+
const DEFAULT_RULES = {
|
|
12
|
+
requiredContract: ['database', 'framework', 'auth', 'resources'],
|
|
13
|
+
minResources: 1,
|
|
14
|
+
requireAuth: true,
|
|
15
|
+
requiredSecurity: ['validation', 'rateLimit', 'helmet', 'cors'],
|
|
16
|
+
smoke: {
|
|
17
|
+
checkHealth: true,
|
|
18
|
+
checkResourceList: true,
|
|
19
|
+
maxResources: 10,
|
|
20
|
+
timeoutMs: 45000
|
|
21
|
+
}
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export async function runIndustryContractCheck(projectPath, options = {}) {
|
|
25
|
+
const spinner = options.silent ? null : ora('Industry Mode: validating offline project contract...').start();
|
|
26
|
+
|
|
27
|
+
try {
|
|
28
|
+
const rules = loadIndustryRules();
|
|
29
|
+
const contract = buildContractSnapshot(projectPath, options.config || null);
|
|
30
|
+
const issues = [];
|
|
31
|
+
|
|
32
|
+
for (const key of rules.requiredContract || []) {
|
|
33
|
+
if (key === 'resources' && contract.resources.length < (rules.minResources || 1)) {
|
|
34
|
+
issues.push(`At least ${rules.minResources || 1} frontend resources are required`);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (key === 'database' && !contract.database) {
|
|
38
|
+
issues.push('Database selection is required');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (key === 'framework' && !contract.framework) {
|
|
42
|
+
issues.push('Framework selection is required');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (key === 'auth' && rules.requireAuth && !contract.auth.enabled) {
|
|
46
|
+
issues.push('Authentication must be enabled for Industry Mode');
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (!hasSecurity(contract.security, rules.requiredSecurity || [])) {
|
|
51
|
+
issues.push('Security baseline is incomplete (validation/rateLimit/helmet/cors)');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const report = {
|
|
55
|
+
ok: issues.length === 0,
|
|
56
|
+
mode: 'offline-industry-contract',
|
|
57
|
+
projectPath,
|
|
58
|
+
timestamp: new Date().toISOString(),
|
|
59
|
+
contract,
|
|
60
|
+
issues
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
if (spinner) {
|
|
64
|
+
if (report.ok) {
|
|
65
|
+
spinner.succeed('Industry Mode: contract validation passed');
|
|
66
|
+
} else {
|
|
67
|
+
spinner.fail(`Industry Mode: contract validation failed (${issues.length} issue(s))`);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return report;
|
|
72
|
+
} catch (error) {
|
|
73
|
+
if (spinner) spinner.fail(`Industry Mode: contract validation error - ${error.message}`);
|
|
74
|
+
return {
|
|
75
|
+
ok: false,
|
|
76
|
+
mode: 'offline-industry-contract',
|
|
77
|
+
projectPath,
|
|
78
|
+
timestamp: new Date().toISOString(),
|
|
79
|
+
contract: null,
|
|
80
|
+
issues: [`Unexpected error: ${error.message}`]
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export async function runIndustrySmoke(projectPath, options = {}) {
|
|
86
|
+
const rules = loadIndustryRules();
|
|
87
|
+
const smokeRules = { ...DEFAULT_RULES.smoke, ...(rules.smoke || {}) };
|
|
88
|
+
const timeoutMs = Number.isInteger(options.timeoutMs) && options.timeoutMs > 0
|
|
89
|
+
? options.timeoutMs
|
|
90
|
+
: smokeRules.timeoutMs;
|
|
91
|
+
|
|
92
|
+
const spinner = options.silent ? null : ora('Industry Mode: running offline smoke checks...').start();
|
|
93
|
+
|
|
94
|
+
const backendPath = path.join(projectPath, 'backend');
|
|
95
|
+
const report = {
|
|
96
|
+
ok: false,
|
|
97
|
+
mode: 'offline-industry-smoke',
|
|
98
|
+
projectPath,
|
|
99
|
+
timestamp: new Date().toISOString(),
|
|
100
|
+
health: null,
|
|
101
|
+
endpointChecks: [],
|
|
102
|
+
issue: null
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
if (!fs.existsSync(backendPath)) {
|
|
106
|
+
report.issue = 'Backend folder not found.';
|
|
107
|
+
if (spinner) spinner.fail(`Industry Mode: ${report.issue}`);
|
|
108
|
+
return report;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const port = detectPort(backendPath);
|
|
112
|
+
const resources = detectBackendResources(backendPath).slice(0, smokeRules.maxResources || 10);
|
|
113
|
+
let proc = null;
|
|
114
|
+
|
|
115
|
+
try {
|
|
116
|
+
await execAsync('npm install --no-audit --no-fund', {
|
|
117
|
+
cwd: backendPath,
|
|
118
|
+
timeout: 180000,
|
|
119
|
+
maxBuffer: 1024 * 1024 * 10
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
proc = spawn('npm', ['run', 'start'], {
|
|
123
|
+
cwd: backendPath,
|
|
124
|
+
shell: true,
|
|
125
|
+
env: {
|
|
126
|
+
...process.env,
|
|
127
|
+
PORT: String(port)
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
const health = await waitForHealth(port, timeoutMs);
|
|
132
|
+
report.health = health;
|
|
133
|
+
|
|
134
|
+
if (!health.ok) {
|
|
135
|
+
report.issue = 'Health endpoint did not become ready in time';
|
|
136
|
+
if (spinner) spinner.fail(`Industry Mode: ${report.issue}`);
|
|
137
|
+
return report;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
for (const resource of resources) {
|
|
141
|
+
const url = `http://127.0.0.1:${port}/api/${resource}`;
|
|
142
|
+
try {
|
|
143
|
+
const response = await fetch(url);
|
|
144
|
+
report.endpointChecks.push({
|
|
145
|
+
resource,
|
|
146
|
+
method: 'GET',
|
|
147
|
+
url,
|
|
148
|
+
status: response.status,
|
|
149
|
+
ok: response.ok
|
|
150
|
+
});
|
|
151
|
+
} catch (error) {
|
|
152
|
+
report.endpointChecks.push({
|
|
153
|
+
resource,
|
|
154
|
+
method: 'GET',
|
|
155
|
+
url,
|
|
156
|
+
status: 0,
|
|
157
|
+
ok: false,
|
|
158
|
+
error: error.message
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const endpointsOk = report.endpointChecks.every((check) => check.ok);
|
|
164
|
+
report.ok = health.ok && endpointsOk;
|
|
165
|
+
|
|
166
|
+
if (!report.ok && !report.issue) {
|
|
167
|
+
report.issue = 'One or more resource list endpoints failed';
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (spinner) {
|
|
171
|
+
if (report.ok) {
|
|
172
|
+
spinner.succeed('Industry Mode: smoke checks passed');
|
|
173
|
+
} else {
|
|
174
|
+
spinner.fail(`Industry Mode: smoke checks failed - ${report.issue}`);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return report;
|
|
179
|
+
} catch (error) {
|
|
180
|
+
report.issue = `Smoke runner error: ${error.message}`;
|
|
181
|
+
if (spinner) spinner.fail(`Industry Mode: ${report.issue}`);
|
|
182
|
+
return report;
|
|
183
|
+
} finally {
|
|
184
|
+
await stopProcess(proc);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export function writeIndustryReport(projectPath, report) {
|
|
189
|
+
const backendPath = path.join(projectPath, 'backend');
|
|
190
|
+
const reportsPath = fs.existsSync(backendPath)
|
|
191
|
+
? path.join(backendPath, 'reports')
|
|
192
|
+
: path.join(projectPath, 'reports');
|
|
193
|
+
|
|
194
|
+
if (!fs.existsSync(reportsPath)) {
|
|
195
|
+
fs.mkdirSync(reportsPath, { recursive: true });
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const filePath = path.join(reportsPath, 'offbyt-industry-report.json');
|
|
199
|
+
fs.writeFileSync(filePath, JSON.stringify(report, null, 2));
|
|
200
|
+
return filePath;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function buildContractSnapshot(projectPath, config) {
|
|
204
|
+
const backendPath = path.join(projectPath, 'backend');
|
|
205
|
+
const resources = detectResourcesFromFrontend(projectPath);
|
|
206
|
+
|
|
207
|
+
const mergedConfig = config || readBackendRuntimeConfig(backendPath);
|
|
208
|
+
const security = inferSecurityFromBackend(backendPath);
|
|
209
|
+
|
|
210
|
+
return {
|
|
211
|
+
database: mergedConfig.database || null,
|
|
212
|
+
framework: mergedConfig.framework || null,
|
|
213
|
+
auth: {
|
|
214
|
+
enabled: Boolean(mergedConfig.enableAuth),
|
|
215
|
+
type: mergedConfig.authType || null
|
|
216
|
+
},
|
|
217
|
+
resources: resources.map((resource) => resource.name),
|
|
218
|
+
security,
|
|
219
|
+
backendExists: fs.existsSync(backendPath)
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function readBackendRuntimeConfig(backendPath) {
|
|
224
|
+
const defaults = {
|
|
225
|
+
database: null,
|
|
226
|
+
framework: null,
|
|
227
|
+
enableAuth: false,
|
|
228
|
+
authType: null
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
const packageJsonPath = path.join(backendPath, 'package.json');
|
|
232
|
+
if (fs.existsSync(packageJsonPath)) {
|
|
233
|
+
try {
|
|
234
|
+
const pkg = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
|
|
235
|
+
const deps = { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) };
|
|
236
|
+
if (deps.express) defaults.framework = 'express';
|
|
237
|
+
if (deps.fastify) defaults.framework = 'fastify';
|
|
238
|
+
if (deps['@nestjs/core']) defaults.framework = 'nestjs';
|
|
239
|
+
if (deps.sequelize) defaults.database = 'sql';
|
|
240
|
+
if (deps.mongoose) defaults.database = 'mongodb';
|
|
241
|
+
if (deps.jsonwebtoken) {
|
|
242
|
+
defaults.enableAuth = true;
|
|
243
|
+
defaults.authType = 'jwt';
|
|
244
|
+
}
|
|
245
|
+
} catch {
|
|
246
|
+
// Ignore malformed package.json.
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
return defaults;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function inferSecurityFromBackend(backendPath) {
|
|
254
|
+
const serverFile = path.join(backendPath, 'server.js');
|
|
255
|
+
let serverContent = '';
|
|
256
|
+
if (fs.existsSync(serverFile)) {
|
|
257
|
+
serverContent = fs.readFileSync(serverFile, 'utf8');
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const middlewarePath = path.join(backendPath, 'middleware');
|
|
261
|
+
const hasValidation = fs.existsSync(path.join(middlewarePath, 'validation.js'));
|
|
262
|
+
const hasRateLimit = fs.existsSync(path.join(middlewarePath, 'rateLimiter.js'));
|
|
263
|
+
const hasHelmet = /helmet\(/.test(serverContent) || /from ['"]helmet['"]/.test(serverContent);
|
|
264
|
+
const hasCors = /cors\(/.test(serverContent) || /from ['"]cors['"]/.test(serverContent);
|
|
265
|
+
|
|
266
|
+
return {
|
|
267
|
+
validation: hasValidation,
|
|
268
|
+
rateLimit: hasRateLimit,
|
|
269
|
+
helmet: hasHelmet,
|
|
270
|
+
cors: hasCors
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function hasSecurity(security, requiredSecurity) {
|
|
275
|
+
return requiredSecurity.every((key) => Boolean(security[key]));
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function detectBackendResources(backendPath) {
|
|
279
|
+
const routesPath = path.join(backendPath, 'routes');
|
|
280
|
+
if (!fs.existsSync(routesPath)) {
|
|
281
|
+
return [];
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const files = fs.readdirSync(routesPath);
|
|
285
|
+
return files
|
|
286
|
+
.filter((name) => name.endsWith('.routes.js'))
|
|
287
|
+
.map((name) => name.replace('.routes.js', ''))
|
|
288
|
+
.filter((name) => name !== 'auth' && name !== 'index');
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function detectPort(backendPath) {
|
|
292
|
+
const envPath = path.join(backendPath, '.env');
|
|
293
|
+
if (fs.existsSync(envPath)) {
|
|
294
|
+
const envContent = fs.readFileSync(envPath, 'utf8');
|
|
295
|
+
const match = envContent.match(/^PORT\s*=\s*(\d+)\s*$/m);
|
|
296
|
+
if (match) {
|
|
297
|
+
const parsed = Number.parseInt(match[1], 10);
|
|
298
|
+
if (!Number.isNaN(parsed)) return parsed;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
return 5000;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
async function waitForHealth(port, timeoutMs) {
|
|
306
|
+
const deadline = Date.now() + timeoutMs;
|
|
307
|
+
const healthPaths = ['/api/health', '/health'];
|
|
308
|
+
|
|
309
|
+
while (Date.now() < deadline) {
|
|
310
|
+
for (const healthPath of healthPaths) {
|
|
311
|
+
try {
|
|
312
|
+
const response = await fetch(`http://127.0.0.1:${port}${healthPath}`);
|
|
313
|
+
if (response.ok) {
|
|
314
|
+
return { ok: true, path: healthPath, status: response.status };
|
|
315
|
+
}
|
|
316
|
+
} catch {
|
|
317
|
+
// Continue polling.
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
await delay(1000);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
return { ok: false, path: null, status: 0 };
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
async function stopProcess(proc) {
|
|
328
|
+
if (!proc || proc.killed) return;
|
|
329
|
+
|
|
330
|
+
try {
|
|
331
|
+
proc.kill('SIGTERM');
|
|
332
|
+
} catch {
|
|
333
|
+
// Ignore kill errors.
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
await delay(350);
|
|
337
|
+
|
|
338
|
+
if (!proc.killed && proc.exitCode === null) {
|
|
339
|
+
if (process.platform === 'win32') {
|
|
340
|
+
try {
|
|
341
|
+
await execAsync(`taskkill /PID ${proc.pid} /T /F`);
|
|
342
|
+
} catch {
|
|
343
|
+
// Ignore taskkill failures.
|
|
344
|
+
}
|
|
345
|
+
} else {
|
|
346
|
+
try {
|
|
347
|
+
proc.kill('SIGKILL');
|
|
348
|
+
} catch {
|
|
349
|
+
// Ignore kill failures.
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function loadIndustryRules() {
|
|
356
|
+
const cwdPath = path.join(process.cwd(), 'industry-mode.json');
|
|
357
|
+
if (fs.existsSync(cwdPath)) {
|
|
358
|
+
return mergeRules(cwdPath);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
const currentFile = fileURLToPath(import.meta.url);
|
|
362
|
+
const projectRootPath = path.resolve(path.dirname(currentFile), '..', '..', 'industry-mode.json');
|
|
363
|
+
if (fs.existsSync(projectRootPath)) {
|
|
364
|
+
return mergeRules(projectRootPath);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
return DEFAULT_RULES;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function mergeRules(filePath) {
|
|
371
|
+
try {
|
|
372
|
+
const parsed = JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
373
|
+
return {
|
|
374
|
+
...DEFAULT_RULES,
|
|
375
|
+
...parsed,
|
|
376
|
+
smoke: {
|
|
377
|
+
...DEFAULT_RULES.smoke,
|
|
378
|
+
...(parsed.smoke || {})
|
|
379
|
+
}
|
|
380
|
+
};
|
|
381
|
+
} catch {
|
|
382
|
+
return DEFAULT_RULES;
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function delay(ms) {
|
|
387
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
export function printIndustryReport(report) {
|
|
391
|
+
const statusLabel = report.ok ? chalk.green('PASS') : chalk.red('FAIL');
|
|
392
|
+
console.log(chalk.cyan('\nIndustry Mode Report'));
|
|
393
|
+
console.log(`Status: ${statusLabel}`);
|
|
394
|
+
|
|
395
|
+
if (report.mode === 'offline-industry-contract') {
|
|
396
|
+
console.log(`Resources detected: ${report.contract?.resources?.length || 0}`);
|
|
397
|
+
if (!report.ok) {
|
|
398
|
+
for (const issue of report.issues || []) {
|
|
399
|
+
console.log(chalk.yellow(`- ${issue}`));
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
return;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
if (report.mode === 'offline-industry-smoke') {
|
|
406
|
+
console.log(`Health: ${report.health?.ok ? 'ok' : 'failed'}`);
|
|
407
|
+
console.log(`Endpoint checks: ${report.endpointChecks.length}`);
|
|
408
|
+
if (!report.ok) {
|
|
409
|
+
console.log(chalk.yellow(`Issue: ${report.issue || 'unknown issue'}`));
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
export default {
|
|
415
|
+
runIndustryContractCheck,
|
|
416
|
+
runIndustrySmoke,
|
|
417
|
+
writeIndustryReport,
|
|
418
|
+
printIndustryReport
|
|
419
|
+
};
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { spawn, exec } from 'child_process';
|
|
4
|
+
import { promisify } from 'util';
|
|
5
|
+
import chalk from 'chalk';
|
|
6
|
+
import ora from 'ora';
|
|
7
|
+
|
|
8
|
+
const execAsync = promisify(exec);
|
|
9
|
+
|
|
10
|
+
export async function verifyGeneratedBackend(projectPath, options = {}) {
|
|
11
|
+
const backendPath = path.join(projectPath, 'backend');
|
|
12
|
+
const timeoutMs = Number.isInteger(options.timeoutMs) && options.timeoutMs > 0 ? options.timeoutMs : 45000;
|
|
13
|
+
const maxAttempts = Number.isInteger(options.maxAttempts) && options.maxAttempts > 0 ? options.maxAttempts : 2;
|
|
14
|
+
|
|
15
|
+
const report = {
|
|
16
|
+
ok: false,
|
|
17
|
+
backendPath,
|
|
18
|
+
attempts: 0,
|
|
19
|
+
fixesApplied: [],
|
|
20
|
+
healthEndpoint: null,
|
|
21
|
+
port: null,
|
|
22
|
+
issue: null
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
if (!fs.existsSync(backendPath)) {
|
|
26
|
+
report.issue = 'Backend folder not found.';
|
|
27
|
+
return report;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const packageJsonPath = path.join(backendPath, 'package.json');
|
|
31
|
+
if (!fs.existsSync(packageJsonPath)) {
|
|
32
|
+
report.issue = 'backend/package.json not found.';
|
|
33
|
+
return report;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const installSpinner = ora('Phase-2: Installing backend dependencies...').start();
|
|
37
|
+
try {
|
|
38
|
+
await execAsync('npm install --no-audit --no-fund', {
|
|
39
|
+
cwd: backendPath,
|
|
40
|
+
timeout: 180000,
|
|
41
|
+
maxBuffer: 1024 * 1024 * 10
|
|
42
|
+
});
|
|
43
|
+
installSpinner.succeed('Phase-2: Dependencies are ready');
|
|
44
|
+
} catch (error) {
|
|
45
|
+
installSpinner.warn(`Phase-2: npm install had warnings (${error.message})`);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
let port = detectPort(backendPath);
|
|
49
|
+
const healthPaths = ['/api/health', '/health'];
|
|
50
|
+
|
|
51
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
52
|
+
report.attempts = attempt;
|
|
53
|
+
const spin = ora(`Phase-2: Verifying backend startup (attempt ${attempt}/${maxAttempts})...`).start();
|
|
54
|
+
|
|
55
|
+
const proc = spawn('npm', ['run', 'start'], {
|
|
56
|
+
cwd: backendPath,
|
|
57
|
+
shell: true,
|
|
58
|
+
env: {
|
|
59
|
+
...process.env,
|
|
60
|
+
PORT: String(port)
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
const logs = [];
|
|
65
|
+
proc.stdout.on('data', (chunk) => logs.push(chunk.toString()));
|
|
66
|
+
proc.stderr.on('data', (chunk) => logs.push(chunk.toString()));
|
|
67
|
+
|
|
68
|
+
let exitedEarly = false;
|
|
69
|
+
proc.on('exit', () => {
|
|
70
|
+
exitedEarly = true;
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
const ready = await waitForHealth(port, healthPaths, timeoutMs);
|
|
74
|
+
|
|
75
|
+
if (ready.ok) {
|
|
76
|
+
report.ok = true;
|
|
77
|
+
report.port = port;
|
|
78
|
+
report.healthEndpoint = ready.path;
|
|
79
|
+
spin.succeed(`Phase-2: Backend healthy at http://localhost:${port}${ready.path}`);
|
|
80
|
+
await stopProcess(proc);
|
|
81
|
+
break;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
await stopProcess(proc);
|
|
85
|
+
|
|
86
|
+
const combined = logs.join('\n');
|
|
87
|
+
const knownFix = await tryKnownFixes(backendPath, combined, { port });
|
|
88
|
+
|
|
89
|
+
if (knownFix.applied) {
|
|
90
|
+
report.fixesApplied.push(knownFix.message);
|
|
91
|
+
if (knownFix.nextPort) {
|
|
92
|
+
port = knownFix.nextPort;
|
|
93
|
+
}
|
|
94
|
+
spin.warn(`Phase-2: ${knownFix.message}. Retrying...`);
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const reason = inferIssue(combined, exitedEarly);
|
|
99
|
+
report.issue = reason;
|
|
100
|
+
spin.fail(`Phase-2: Verification failed - ${reason}`);
|
|
101
|
+
break;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
report.port = report.port || port;
|
|
105
|
+
if (!report.ok && !report.issue) {
|
|
106
|
+
report.issue = 'Backend health endpoint did not become ready in time.';
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return report;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function detectPort(backendPath) {
|
|
113
|
+
const envPath = path.join(backendPath, '.env');
|
|
114
|
+
if (fs.existsSync(envPath)) {
|
|
115
|
+
const envContent = fs.readFileSync(envPath, 'utf8');
|
|
116
|
+
const match = envContent.match(/^PORT\s*=\s*(\d+)\s*$/m);
|
|
117
|
+
if (match) {
|
|
118
|
+
const parsed = Number.parseInt(match[1], 10);
|
|
119
|
+
if (!Number.isNaN(parsed)) return parsed;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return 5000;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async function waitForHealth(port, healthPaths, timeoutMs) {
|
|
127
|
+
const deadline = Date.now() + timeoutMs;
|
|
128
|
+
|
|
129
|
+
while (Date.now() < deadline) {
|
|
130
|
+
for (const healthPath of healthPaths) {
|
|
131
|
+
try {
|
|
132
|
+
const response = await fetch(`http://127.0.0.1:${port}${healthPath}`);
|
|
133
|
+
if (response.ok) {
|
|
134
|
+
return { ok: true, path: healthPath };
|
|
135
|
+
}
|
|
136
|
+
} catch {
|
|
137
|
+
// Keep polling until timeout.
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
await delay(1000);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return { ok: false, path: null };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async function tryKnownFixes(backendPath, logs, context = {}) {
|
|
148
|
+
const missingPkgs = extractMissingPackages(logs);
|
|
149
|
+
if (missingPkgs.length > 0) {
|
|
150
|
+
const pkg = missingPkgs[0];
|
|
151
|
+
try {
|
|
152
|
+
await execAsync(`npm install ${pkg} --no-audit --no-fund`, {
|
|
153
|
+
cwd: backendPath,
|
|
154
|
+
timeout: 120000,
|
|
155
|
+
maxBuffer: 1024 * 1024 * 5
|
|
156
|
+
});
|
|
157
|
+
return { applied: true, message: `Installed missing package ${pkg}` };
|
|
158
|
+
} catch {
|
|
159
|
+
return { applied: false, message: `Could not install missing package ${pkg}` };
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (/EADDRINUSE/i.test(logs)) {
|
|
164
|
+
const nextPort = (context.port || 5000) + 1;
|
|
165
|
+
return {
|
|
166
|
+
applied: true,
|
|
167
|
+
message: `Port ${context.port || 5000} was busy, switched verifier to port ${nextPort}`,
|
|
168
|
+
nextPort
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return { applied: false, message: '' };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function extractMissingPackages(logs) {
|
|
176
|
+
const packages = new Set();
|
|
177
|
+
const patterns = [
|
|
178
|
+
/Cannot find package '([^']+)'/g,
|
|
179
|
+
/Cannot find module '([^']+)'/g,
|
|
180
|
+
/ERR_MODULE_NOT_FOUND[\s\S]*?'([^']+)'/g
|
|
181
|
+
];
|
|
182
|
+
|
|
183
|
+
for (const pattern of patterns) {
|
|
184
|
+
let match;
|
|
185
|
+
while ((match = pattern.exec(logs)) !== null) {
|
|
186
|
+
const name = (match[1] || '').trim();
|
|
187
|
+
if (!name || name.startsWith('.') || path.isAbsolute(name)) {
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (name.includes('/') && !name.startsWith('@')) {
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
packages.add(name);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
return Array.from(packages);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function inferIssue(logs, exitedEarly) {
|
|
203
|
+
if (/ECONNREFUSED|MongoNetworkError|SequelizeConnectionError|ConnectionRefusedError|connection error/i.test(logs)) {
|
|
204
|
+
return 'Database is not reachable. Start your DB service or update .env credentials.';
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if (/ERR_MODULE_NOT_FOUND|Cannot find module|Cannot find package/i.test(logs)) {
|
|
208
|
+
return 'Module resolution error remains after retry.';
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if (/SyntaxError/i.test(logs)) {
|
|
212
|
+
return 'Syntax error found in generated backend code.';
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
if (exitedEarly) {
|
|
216
|
+
return 'Backend process exited before health-check passed.';
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
return 'Health endpoint timeout.';
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
async function stopProcess(proc) {
|
|
223
|
+
if (!proc || proc.killed) {
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
try {
|
|
228
|
+
proc.kill('SIGTERM');
|
|
229
|
+
} catch {
|
|
230
|
+
// Ignore kill errors.
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
await delay(350);
|
|
234
|
+
|
|
235
|
+
if (!proc.killed && proc.exitCode === null) {
|
|
236
|
+
if (process.platform === 'win32') {
|
|
237
|
+
try {
|
|
238
|
+
await execAsync(`taskkill /PID ${proc.pid} /T /F`);
|
|
239
|
+
} catch {
|
|
240
|
+
// Ignore taskkill errors.
|
|
241
|
+
}
|
|
242
|
+
} else {
|
|
243
|
+
try {
|
|
244
|
+
proc.kill('SIGKILL');
|
|
245
|
+
} catch {
|
|
246
|
+
// Ignore kill errors.
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function delay(ms) {
|
|
253
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
export default { verifyGeneratedBackend };
|