genbox 1.0.78 ā 1.0.79
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/commands/extend.js +45 -4
- package/dist/commands/scan.js +1162 -0
- package/dist/index.js +0 -0
- package/dist/migration.js +335 -0
- package/dist/schema-v3.js +48 -0
- package/package.json +2 -1
|
@@ -0,0 +1,1162 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Scan Command
|
|
4
|
+
*
|
|
5
|
+
* Analyzes the project structure and outputs detected configuration
|
|
6
|
+
* to .genbox/detected.yaml (or stdout with --stdout flag)
|
|
7
|
+
*
|
|
8
|
+
* This separates detection from configuration - users can review
|
|
9
|
+
* what was detected before using it.
|
|
10
|
+
*
|
|
11
|
+
* Usage:
|
|
12
|
+
* genbox scan # Output to .genbox/detected.yaml
|
|
13
|
+
* genbox scan --stdout # Output to stdout
|
|
14
|
+
* genbox scan --json # Output as JSON
|
|
15
|
+
* genbox scan -i # Interactive mode (select apps)
|
|
16
|
+
*/
|
|
17
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
18
|
+
if (k2 === undefined) k2 = k;
|
|
19
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
20
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
21
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
22
|
+
}
|
|
23
|
+
Object.defineProperty(o, k2, desc);
|
|
24
|
+
}) : (function(o, m, k, k2) {
|
|
25
|
+
if (k2 === undefined) k2 = k;
|
|
26
|
+
o[k2] = m[k];
|
|
27
|
+
}));
|
|
28
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
29
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
30
|
+
}) : function(o, v) {
|
|
31
|
+
o["default"] = v;
|
|
32
|
+
});
|
|
33
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
34
|
+
var ownKeys = function(o) {
|
|
35
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
36
|
+
var ar = [];
|
|
37
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
38
|
+
return ar;
|
|
39
|
+
};
|
|
40
|
+
return ownKeys(o);
|
|
41
|
+
};
|
|
42
|
+
return function (mod) {
|
|
43
|
+
if (mod && mod.__esModule) return mod;
|
|
44
|
+
var result = {};
|
|
45
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
46
|
+
__setModuleDefault(result, mod);
|
|
47
|
+
return result;
|
|
48
|
+
};
|
|
49
|
+
})();
|
|
50
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
51
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
52
|
+
};
|
|
53
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
54
|
+
exports.scanCommand = void 0;
|
|
55
|
+
const commander_1 = require("commander");
|
|
56
|
+
const prompts = __importStar(require("@inquirer/prompts"));
|
|
57
|
+
const chalk_1 = __importDefault(require("chalk"));
|
|
58
|
+
const fs = __importStar(require("fs"));
|
|
59
|
+
const path = __importStar(require("path"));
|
|
60
|
+
const yaml = __importStar(require("js-yaml"));
|
|
61
|
+
const child_process_1 = require("child_process");
|
|
62
|
+
const scanner_1 = require("../scanner");
|
|
63
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
64
|
+
const { version } = require('../../package.json');
|
|
65
|
+
/**
|
|
66
|
+
* Detect git repository info for a specific directory
|
|
67
|
+
*/
|
|
68
|
+
function detectGitForDirectory(dir) {
|
|
69
|
+
const gitDir = path.join(dir, '.git');
|
|
70
|
+
if (!fs.existsSync(gitDir))
|
|
71
|
+
return undefined;
|
|
72
|
+
try {
|
|
73
|
+
const remote = (0, child_process_1.execSync)('git remote get-url origin', {
|
|
74
|
+
cwd: dir,
|
|
75
|
+
stdio: 'pipe',
|
|
76
|
+
encoding: 'utf8',
|
|
77
|
+
}).trim();
|
|
78
|
+
if (!remote)
|
|
79
|
+
return undefined;
|
|
80
|
+
const isSSH = remote.startsWith('git@') || remote.startsWith('ssh://');
|
|
81
|
+
let provider = 'other';
|
|
82
|
+
if (remote.includes('github.com'))
|
|
83
|
+
provider = 'github';
|
|
84
|
+
else if (remote.includes('gitlab.com'))
|
|
85
|
+
provider = 'gitlab';
|
|
86
|
+
else if (remote.includes('bitbucket.org'))
|
|
87
|
+
provider = 'bitbucket';
|
|
88
|
+
let branch = 'main';
|
|
89
|
+
try {
|
|
90
|
+
branch = (0, child_process_1.execSync)('git rev-parse --abbrev-ref HEAD', {
|
|
91
|
+
cwd: dir,
|
|
92
|
+
stdio: 'pipe',
|
|
93
|
+
encoding: 'utf8',
|
|
94
|
+
}).trim();
|
|
95
|
+
}
|
|
96
|
+
catch { }
|
|
97
|
+
return {
|
|
98
|
+
remote,
|
|
99
|
+
type: isSSH ? 'ssh' : 'https',
|
|
100
|
+
provider,
|
|
101
|
+
branch,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
return undefined;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
exports.scanCommand = new commander_1.Command('scan')
|
|
109
|
+
.description('Analyze project structure and output detected configuration')
|
|
110
|
+
.option('--stdout', 'Output to stdout instead of .genbox/detected.yaml')
|
|
111
|
+
.option('--json', 'Output as JSON instead of YAML')
|
|
112
|
+
.option('--no-infra', 'Skip infrastructure detection (docker-compose)')
|
|
113
|
+
.option('--no-scripts', 'Skip script detection')
|
|
114
|
+
.option('-i, --interactive', 'Interactive mode - select apps before writing')
|
|
115
|
+
.option('--edit', 'Edit mode - review and modify detected values for each app')
|
|
116
|
+
.option('-e, --exclude <patterns>', 'Comma-separated patterns to exclude', '')
|
|
117
|
+
.action(async (options) => {
|
|
118
|
+
const cwd = process.cwd();
|
|
119
|
+
const isInteractive = (options.interactive || options.edit) && !options.stdout && process.stdin.isTTY;
|
|
120
|
+
const isEditMode = options.edit && !options.stdout && process.stdin.isTTY;
|
|
121
|
+
console.log(chalk_1.default.cyan('\nš Scanning project...\n'));
|
|
122
|
+
try {
|
|
123
|
+
// Run the scanner
|
|
124
|
+
const scanner = new scanner_1.ProjectScanner();
|
|
125
|
+
const exclude = options.exclude ? options.exclude.split(',').map(s => s.trim()) : [];
|
|
126
|
+
const scan = await scanner.scan(cwd, {
|
|
127
|
+
exclude,
|
|
128
|
+
skipScripts: !options.scripts,
|
|
129
|
+
});
|
|
130
|
+
// Convert scan result to DetectedConfig format
|
|
131
|
+
let detected = convertScanToDetected(scan, cwd);
|
|
132
|
+
// Interactive mode: let user select apps, scripts, infrastructure
|
|
133
|
+
if (isInteractive) {
|
|
134
|
+
detected = await interactiveSelection(detected);
|
|
135
|
+
}
|
|
136
|
+
// Edit mode: let user modify detected values for each app
|
|
137
|
+
if (isEditMode) {
|
|
138
|
+
detected = await interactiveEditMode(detected);
|
|
139
|
+
}
|
|
140
|
+
// Scan env files for service URLs (only for selected frontend apps)
|
|
141
|
+
const frontendApps = Object.entries(detected.apps)
|
|
142
|
+
.filter(([, app]) => app.type === 'frontend')
|
|
143
|
+
.map(([name]) => name);
|
|
144
|
+
if (frontendApps.length > 0) {
|
|
145
|
+
let serviceUrls = scanEnvFilesForUrls(detected.apps, cwd);
|
|
146
|
+
// In interactive mode, let user select which URLs to configure
|
|
147
|
+
if (isInteractive && serviceUrls.length > 0) {
|
|
148
|
+
serviceUrls = await interactiveUrlSelection(serviceUrls);
|
|
149
|
+
}
|
|
150
|
+
if (serviceUrls.length > 0) {
|
|
151
|
+
detected.service_urls = serviceUrls;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
// Output
|
|
155
|
+
if (options.stdout) {
|
|
156
|
+
outputToStdout(detected, options.json);
|
|
157
|
+
}
|
|
158
|
+
else {
|
|
159
|
+
await outputToFile(detected, cwd, options.json);
|
|
160
|
+
showSummary(detected);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
catch (error) {
|
|
164
|
+
if (error.name === 'ExitPromptError' || error.message?.includes('force closed')) {
|
|
165
|
+
console.log('\n' + chalk_1.default.dim('Cancelled.'));
|
|
166
|
+
process.exit(0);
|
|
167
|
+
}
|
|
168
|
+
console.error(chalk_1.default.red('Scan failed:'), error);
|
|
169
|
+
process.exit(1);
|
|
170
|
+
}
|
|
171
|
+
});
|
|
172
|
+
/**
|
|
173
|
+
* Interactive app and script selection
|
|
174
|
+
*/
|
|
175
|
+
async function interactiveSelection(detected) {
|
|
176
|
+
let result = { ...detected };
|
|
177
|
+
// === App Selection ===
|
|
178
|
+
const appEntries = Object.entries(detected.apps);
|
|
179
|
+
if (appEntries.length > 0) {
|
|
180
|
+
console.log(chalk_1.default.blue('=== Detected Apps ===\n'));
|
|
181
|
+
// Show detected apps
|
|
182
|
+
for (const [name, app] of appEntries) {
|
|
183
|
+
const parts = [
|
|
184
|
+
chalk_1.default.cyan(name),
|
|
185
|
+
app.type ? `(${app.type})` : '',
|
|
186
|
+
app.framework ? `[${app.framework}]` : '',
|
|
187
|
+
app.port ? `port:${app.port}` : '',
|
|
188
|
+
].filter(Boolean);
|
|
189
|
+
console.log(` ${parts.join(' ')}`);
|
|
190
|
+
if (app.git) {
|
|
191
|
+
console.log(chalk_1.default.dim(` āā ${app.git.remote}`));
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
console.log();
|
|
195
|
+
// Let user select which apps to include
|
|
196
|
+
const appChoices = appEntries.map(([name, app]) => ({
|
|
197
|
+
name: `${name} (${app.type || 'unknown'}${app.framework ? `, ${app.framework}` : ''})`,
|
|
198
|
+
value: name,
|
|
199
|
+
checked: app.type !== 'library', // Default: include non-libraries
|
|
200
|
+
}));
|
|
201
|
+
const selectedApps = await prompts.checkbox({
|
|
202
|
+
message: 'Select apps to include:',
|
|
203
|
+
choices: appChoices,
|
|
204
|
+
});
|
|
205
|
+
// Filter apps to only selected ones
|
|
206
|
+
const filteredApps = {};
|
|
207
|
+
for (const appName of selectedApps) {
|
|
208
|
+
filteredApps[appName] = detected.apps[appName];
|
|
209
|
+
}
|
|
210
|
+
result.apps = filteredApps;
|
|
211
|
+
}
|
|
212
|
+
// === Script Selection ===
|
|
213
|
+
if (detected.scripts && detected.scripts.length > 0) {
|
|
214
|
+
console.log('');
|
|
215
|
+
console.log(chalk_1.default.blue('=== Detected Scripts ===\n'));
|
|
216
|
+
// Group scripts by directory for display
|
|
217
|
+
const scriptsByDir = new Map();
|
|
218
|
+
for (const script of detected.scripts) {
|
|
219
|
+
const dir = script.path.includes('/') ? script.path.split('/')[0] : '(root)';
|
|
220
|
+
const existing = scriptsByDir.get(dir) || [];
|
|
221
|
+
existing.push(script);
|
|
222
|
+
scriptsByDir.set(dir, existing);
|
|
223
|
+
}
|
|
224
|
+
// Show grouped scripts
|
|
225
|
+
for (const [dir, scripts] of scriptsByDir) {
|
|
226
|
+
console.log(chalk_1.default.dim(` ${dir}/ (${scripts.length} scripts)`));
|
|
227
|
+
for (const script of scripts.slice(0, 3)) {
|
|
228
|
+
console.log(` ${chalk_1.default.cyan(script.name)} (${script.stage})`);
|
|
229
|
+
}
|
|
230
|
+
if (scripts.length > 3) {
|
|
231
|
+
console.log(chalk_1.default.dim(` ... and ${scripts.length - 3} more`));
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
console.log();
|
|
235
|
+
// Let user select which scripts to include
|
|
236
|
+
const scriptChoices = detected.scripts.map(s => ({
|
|
237
|
+
name: `${s.path} (${s.stage})`,
|
|
238
|
+
value: s.path,
|
|
239
|
+
checked: s.path.startsWith('scripts/'), // Default: include scripts/ directory
|
|
240
|
+
}));
|
|
241
|
+
const selectedScripts = await prompts.checkbox({
|
|
242
|
+
message: 'Select scripts to include:',
|
|
243
|
+
choices: scriptChoices,
|
|
244
|
+
});
|
|
245
|
+
// Filter scripts to only selected ones
|
|
246
|
+
result.scripts = detected.scripts.filter(s => selectedScripts.includes(s.path));
|
|
247
|
+
}
|
|
248
|
+
// === Docker Services Selection ===
|
|
249
|
+
if (detected.docker_services && detected.docker_services.length > 0) {
|
|
250
|
+
console.log('');
|
|
251
|
+
console.log(chalk_1.default.blue('=== Detected Docker Services ===\n'));
|
|
252
|
+
for (const svc of detected.docker_services) {
|
|
253
|
+
const portInfo = svc.port ? ` port:${svc.port}` : '';
|
|
254
|
+
console.log(` ${chalk_1.default.cyan(svc.name)}${portInfo}`);
|
|
255
|
+
if (svc.build_context) {
|
|
256
|
+
console.log(chalk_1.default.dim(` build: ${svc.dockerfile || 'Dockerfile'}`));
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
console.log();
|
|
260
|
+
const dockerChoices = detected.docker_services.map(svc => ({
|
|
261
|
+
name: `${svc.name}${svc.port ? ` (port ${svc.port})` : ''}`,
|
|
262
|
+
value: svc.name,
|
|
263
|
+
checked: true, // Default: include all
|
|
264
|
+
}));
|
|
265
|
+
const selectedDocker = await prompts.checkbox({
|
|
266
|
+
message: 'Select Docker services to include:',
|
|
267
|
+
choices: dockerChoices,
|
|
268
|
+
});
|
|
269
|
+
// Filter docker services to only selected ones
|
|
270
|
+
result.docker_services = detected.docker_services.filter(svc => selectedDocker.includes(svc.name));
|
|
271
|
+
}
|
|
272
|
+
// === Infrastructure Selection ===
|
|
273
|
+
if (detected.infrastructure && detected.infrastructure.length > 0) {
|
|
274
|
+
console.log('');
|
|
275
|
+
console.log(chalk_1.default.blue('=== Detected Infrastructure ===\n'));
|
|
276
|
+
for (const infra of detected.infrastructure) {
|
|
277
|
+
console.log(` ${chalk_1.default.cyan(infra.name)}: ${infra.type} (${infra.image})`);
|
|
278
|
+
}
|
|
279
|
+
console.log();
|
|
280
|
+
const infraChoices = detected.infrastructure.map(i => ({
|
|
281
|
+
name: `${i.name} (${i.type}, ${i.image})`,
|
|
282
|
+
value: i.name,
|
|
283
|
+
checked: true, // Default: include all
|
|
284
|
+
}));
|
|
285
|
+
const selectedInfra = await prompts.checkbox({
|
|
286
|
+
message: 'Select infrastructure to include:',
|
|
287
|
+
choices: infraChoices,
|
|
288
|
+
});
|
|
289
|
+
// Filter infrastructure to only selected ones
|
|
290
|
+
result.infrastructure = detected.infrastructure.filter(i => selectedInfra.includes(i.name));
|
|
291
|
+
}
|
|
292
|
+
return result;
|
|
293
|
+
}
|
|
294
|
+
/**
|
|
295
|
+
* Interactive edit mode - allows modifying detected values for each app
|
|
296
|
+
*/
|
|
297
|
+
async function interactiveEditMode(detected) {
|
|
298
|
+
const result = { ...detected, apps: { ...detected.apps } };
|
|
299
|
+
const appEntries = Object.entries(result.apps);
|
|
300
|
+
if (appEntries.length === 0) {
|
|
301
|
+
console.log(chalk_1.default.dim('No apps to edit.'));
|
|
302
|
+
return result;
|
|
303
|
+
}
|
|
304
|
+
console.log('');
|
|
305
|
+
console.log(chalk_1.default.blue('=== Edit Detected Values ==='));
|
|
306
|
+
console.log(chalk_1.default.dim('Review and modify detected values for each app.\n'));
|
|
307
|
+
// Show summary of all apps first
|
|
308
|
+
console.log(chalk_1.default.bold('Detected apps:'));
|
|
309
|
+
for (const [name, app] of appEntries) {
|
|
310
|
+
const runner = app.runner || 'pm2';
|
|
311
|
+
const port = app.port ? `:${app.port}` : '';
|
|
312
|
+
console.log(` ${chalk_1.default.cyan(name)} - ${app.type || 'unknown'} (${runner})${port}`);
|
|
313
|
+
}
|
|
314
|
+
console.log('');
|
|
315
|
+
// Ask if user wants to edit any apps
|
|
316
|
+
const editApps = await prompts.confirm({
|
|
317
|
+
message: 'Do you want to edit any app configurations?',
|
|
318
|
+
default: false,
|
|
319
|
+
});
|
|
320
|
+
if (!editApps) {
|
|
321
|
+
return result;
|
|
322
|
+
}
|
|
323
|
+
// Let user select which apps to edit
|
|
324
|
+
const appChoices = appEntries.map(([name, app]) => ({
|
|
325
|
+
name: `${name} (${app.type || 'unknown'}, ${app.runner || 'pm2'})`,
|
|
326
|
+
value: name,
|
|
327
|
+
}));
|
|
328
|
+
const appsToEdit = await prompts.checkbox({
|
|
329
|
+
message: 'Select apps to edit:',
|
|
330
|
+
choices: appChoices,
|
|
331
|
+
});
|
|
332
|
+
// Edit each selected app
|
|
333
|
+
// Pass the scanned root dir for docker-compose lookup
|
|
334
|
+
const rootDir = detected._meta.scanned_root;
|
|
335
|
+
for (const appName of appsToEdit) {
|
|
336
|
+
result.apps[appName] = await editAppConfig(appName, result.apps[appName], rootDir);
|
|
337
|
+
}
|
|
338
|
+
return result;
|
|
339
|
+
}
|
|
340
|
+
/**
|
|
341
|
+
* Look up service info from docker-compose.yml
|
|
342
|
+
*/
|
|
343
|
+
function getDockerComposeServiceInfo(rootDir, serviceName) {
|
|
344
|
+
const composeFiles = ['docker-compose.yml', 'docker-compose.yaml', 'compose.yml', 'compose.yaml'];
|
|
345
|
+
for (const filename of composeFiles) {
|
|
346
|
+
const composePath = path.join(rootDir, filename);
|
|
347
|
+
if (fs.existsSync(composePath)) {
|
|
348
|
+
try {
|
|
349
|
+
const content = fs.readFileSync(composePath, 'utf8');
|
|
350
|
+
const compose = yaml.load(content);
|
|
351
|
+
if (compose?.services?.[serviceName]) {
|
|
352
|
+
const service = compose.services[serviceName];
|
|
353
|
+
const info = {};
|
|
354
|
+
// Parse port
|
|
355
|
+
if (service.ports && service.ports.length > 0) {
|
|
356
|
+
const portDef = service.ports[0];
|
|
357
|
+
if (typeof portDef === 'string') {
|
|
358
|
+
const parts = portDef.split(':');
|
|
359
|
+
info.port = parseInt(parts[0], 10);
|
|
360
|
+
}
|
|
361
|
+
else if (typeof portDef === 'number') {
|
|
362
|
+
info.port = portDef;
|
|
363
|
+
}
|
|
364
|
+
else if (portDef.published) {
|
|
365
|
+
info.port = portDef.published;
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
// Parse build context and dockerfile
|
|
369
|
+
if (service.build) {
|
|
370
|
+
if (typeof service.build === 'string') {
|
|
371
|
+
info.buildContext = service.build;
|
|
372
|
+
}
|
|
373
|
+
else {
|
|
374
|
+
info.buildContext = service.build.context;
|
|
375
|
+
info.dockerfile = service.build.dockerfile;
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
// Parse healthcheck
|
|
379
|
+
if (service.healthcheck?.test) {
|
|
380
|
+
const test = service.healthcheck.test;
|
|
381
|
+
if (Array.isArray(test)) {
|
|
382
|
+
// ["CMD", "curl", "-f", "http://localhost:3000/health"]
|
|
383
|
+
const cmdIndex = test.indexOf('CMD') + 1 || test.indexOf('CMD-SHELL') + 1;
|
|
384
|
+
if (cmdIndex > 0) {
|
|
385
|
+
const healthCmd = test.slice(cmdIndex).join(' ');
|
|
386
|
+
// Extract health endpoint from curl command
|
|
387
|
+
const urlMatch = healthCmd.match(/https?:\/\/[^/]+(\S+)/);
|
|
388
|
+
if (urlMatch) {
|
|
389
|
+
info.healthcheck = urlMatch[1];
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
else if (typeof test === 'string') {
|
|
394
|
+
const urlMatch = test.match(/https?:\/\/[^/]+(\S+)/);
|
|
395
|
+
if (urlMatch) {
|
|
396
|
+
info.healthcheck = urlMatch[1];
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
// Parse depends_on
|
|
401
|
+
if (service.depends_on) {
|
|
402
|
+
if (Array.isArray(service.depends_on)) {
|
|
403
|
+
info.dependsOn = service.depends_on;
|
|
404
|
+
}
|
|
405
|
+
else if (typeof service.depends_on === 'object') {
|
|
406
|
+
info.dependsOn = Object.keys(service.depends_on);
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
// Parse environment variables (names only)
|
|
410
|
+
if (service.environment) {
|
|
411
|
+
if (Array.isArray(service.environment)) {
|
|
412
|
+
info.envVars = service.environment.map((e) => e.split('=')[0]);
|
|
413
|
+
}
|
|
414
|
+
else if (typeof service.environment === 'object') {
|
|
415
|
+
info.envVars = Object.keys(service.environment);
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
return info;
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
catch {
|
|
422
|
+
// Ignore parse errors
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
return undefined;
|
|
427
|
+
}
|
|
428
|
+
/**
|
|
429
|
+
* Edit a single app's configuration
|
|
430
|
+
*/
|
|
431
|
+
async function editAppConfig(name, app, rootDir) {
|
|
432
|
+
console.log('');
|
|
433
|
+
console.log(chalk_1.default.blue(`=== Editing: ${name} ===`));
|
|
434
|
+
// Show current values
|
|
435
|
+
console.log(chalk_1.default.dim('Current values:'));
|
|
436
|
+
console.log(` Type: ${chalk_1.default.cyan(app.type || 'unknown')} ${chalk_1.default.dim(`(${app.type_reason || 'detected'})`)}`);
|
|
437
|
+
console.log(` Runner: ${chalk_1.default.cyan(app.runner || 'pm2')} ${chalk_1.default.dim(`(${app.runner_reason || 'default'})`)}`);
|
|
438
|
+
console.log(` Port: ${app.port ? chalk_1.default.cyan(String(app.port)) : chalk_1.default.dim('not set')} ${app.port_source ? chalk_1.default.dim(`(${app.port_source})`) : ''}`);
|
|
439
|
+
console.log(` Framework: ${app.framework ? chalk_1.default.cyan(app.framework) : chalk_1.default.dim('not detected')}`);
|
|
440
|
+
console.log('');
|
|
441
|
+
const result = { ...app };
|
|
442
|
+
// Edit type
|
|
443
|
+
const typeChoices = [
|
|
444
|
+
{ name: `frontend ${app.type === 'frontend' ? chalk_1.default.green('(current)') : ''}`, value: 'frontend' },
|
|
445
|
+
{ name: `backend ${app.type === 'backend' ? chalk_1.default.green('(current)') : ''}`, value: 'backend' },
|
|
446
|
+
{ name: `worker ${app.type === 'worker' ? chalk_1.default.green('(current)') : ''}`, value: 'worker' },
|
|
447
|
+
{ name: `gateway ${app.type === 'gateway' ? chalk_1.default.green('(current)') : ''}`, value: 'gateway' },
|
|
448
|
+
{ name: `library ${app.type === 'library' ? chalk_1.default.green('(current)') : ''}`, value: 'library' },
|
|
449
|
+
];
|
|
450
|
+
const newType = await prompts.select({
|
|
451
|
+
message: 'App type:',
|
|
452
|
+
choices: typeChoices,
|
|
453
|
+
default: app.type || 'backend',
|
|
454
|
+
});
|
|
455
|
+
if (newType !== app.type) {
|
|
456
|
+
result.type = newType;
|
|
457
|
+
result.type_reason = 'manually set';
|
|
458
|
+
}
|
|
459
|
+
// Edit runner
|
|
460
|
+
const runnerChoices = [
|
|
461
|
+
{ name: `pm2 - Process manager (recommended for Node.js) ${app.runner === 'pm2' ? chalk_1.default.green('(current)') : ''}`, value: 'pm2' },
|
|
462
|
+
{ name: `docker - Docker compose service ${app.runner === 'docker' ? chalk_1.default.green('(current)') : ''}`, value: 'docker' },
|
|
463
|
+
{ name: `bun - Bun runtime ${app.runner === 'bun' ? chalk_1.default.green('(current)') : ''}`, value: 'bun' },
|
|
464
|
+
{ name: `node - Direct Node.js execution ${app.runner === 'node' ? chalk_1.default.green('(current)') : ''}`, value: 'node' },
|
|
465
|
+
{ name: `none - Library/not runnable ${app.runner === 'none' ? chalk_1.default.green('(current)') : ''}`, value: 'none' },
|
|
466
|
+
];
|
|
467
|
+
const newRunner = await prompts.select({
|
|
468
|
+
message: 'Runner:',
|
|
469
|
+
choices: runnerChoices,
|
|
470
|
+
default: app.runner || 'pm2',
|
|
471
|
+
});
|
|
472
|
+
if (newRunner !== app.runner) {
|
|
473
|
+
result.runner = newRunner;
|
|
474
|
+
result.runner_reason = 'manually set';
|
|
475
|
+
}
|
|
476
|
+
// If runner is docker, ask for docker config and auto-detect from docker-compose
|
|
477
|
+
if (newRunner === 'docker') {
|
|
478
|
+
// First ask for service name to look up in docker-compose
|
|
479
|
+
const dockerService = await prompts.input({
|
|
480
|
+
message: 'Docker service name:',
|
|
481
|
+
default: app.docker?.service || name,
|
|
482
|
+
});
|
|
483
|
+
// Look up service info from docker-compose.yml
|
|
484
|
+
const composeInfo = getDockerComposeServiceInfo(rootDir, dockerService);
|
|
485
|
+
if (composeInfo) {
|
|
486
|
+
console.log(chalk_1.default.blue('\n Detected from docker-compose.yml:'));
|
|
487
|
+
if (composeInfo.buildContext)
|
|
488
|
+
console.log(chalk_1.default.dim(` build context: ${composeInfo.buildContext}`));
|
|
489
|
+
if (composeInfo.dockerfile)
|
|
490
|
+
console.log(chalk_1.default.dim(` dockerfile: ${composeInfo.dockerfile}`));
|
|
491
|
+
if (composeInfo.port)
|
|
492
|
+
console.log(chalk_1.default.dim(` port: ${composeInfo.port}`));
|
|
493
|
+
if (composeInfo.healthcheck)
|
|
494
|
+
console.log(chalk_1.default.dim(` healthcheck: ${composeInfo.healthcheck}`));
|
|
495
|
+
if (composeInfo.dependsOn?.length)
|
|
496
|
+
console.log(chalk_1.default.dim(` depends_on: ${composeInfo.dependsOn.join(', ')}`));
|
|
497
|
+
if (composeInfo.envVars?.length)
|
|
498
|
+
console.log(chalk_1.default.dim(` env vars: ${composeInfo.envVars.slice(0, 5).join(', ')}${composeInfo.envVars.length > 5 ? ` +${composeInfo.envVars.length - 5} more` : ''}`));
|
|
499
|
+
console.log('');
|
|
500
|
+
}
|
|
501
|
+
// Use detected values as defaults
|
|
502
|
+
const defaultContext = composeInfo?.buildContext || app.docker?.build_context || app.path || '.';
|
|
503
|
+
const dockerContext = await prompts.input({
|
|
504
|
+
message: 'Docker build context:',
|
|
505
|
+
default: defaultContext,
|
|
506
|
+
});
|
|
507
|
+
const defaultDockerfile = composeInfo?.dockerfile || app.docker?.dockerfile;
|
|
508
|
+
const dockerfile = defaultDockerfile ? await prompts.input({
|
|
509
|
+
message: 'Dockerfile:',
|
|
510
|
+
default: defaultDockerfile,
|
|
511
|
+
}) : undefined;
|
|
512
|
+
result.docker = {
|
|
513
|
+
service: dockerService,
|
|
514
|
+
build_context: dockerContext,
|
|
515
|
+
dockerfile: dockerfile || app.docker?.dockerfile,
|
|
516
|
+
};
|
|
517
|
+
// Auto-set values from docker-compose
|
|
518
|
+
if (composeInfo?.port) {
|
|
519
|
+
result.port = composeInfo.port;
|
|
520
|
+
result.port_source = 'docker-compose.yml';
|
|
521
|
+
}
|
|
522
|
+
if (composeInfo?.healthcheck) {
|
|
523
|
+
result.healthcheck = composeInfo.healthcheck;
|
|
524
|
+
}
|
|
525
|
+
if (composeInfo?.dependsOn?.length) {
|
|
526
|
+
result.depends_on = composeInfo.dependsOn;
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
// Edit port (only if not a library and not already set by docker-compose)
|
|
530
|
+
if (newRunner !== 'none' && result.type !== 'library') {
|
|
531
|
+
// For docker runner, only ask if port wasn't auto-detected
|
|
532
|
+
const currentPort = result.port;
|
|
533
|
+
const portDefault = currentPort ? String(currentPort) : '';
|
|
534
|
+
const portInput = await prompts.input({
|
|
535
|
+
message: currentPort ? `Port (detected: ${currentPort}, press Enter to keep):` : 'Port (leave empty to skip):',
|
|
536
|
+
default: portDefault,
|
|
537
|
+
});
|
|
538
|
+
if (portInput) {
|
|
539
|
+
const portNum = parseInt(portInput, 10);
|
|
540
|
+
if (!isNaN(portNum) && portNum > 0 && portNum < 65536) {
|
|
541
|
+
if (portNum !== currentPort) {
|
|
542
|
+
result.port = portNum;
|
|
543
|
+
result.port_source = 'manually set';
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
else if (!currentPort) {
|
|
548
|
+
result.port = undefined;
|
|
549
|
+
result.port_source = undefined;
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
// Edit framework
|
|
553
|
+
const frameworkChoices = [
|
|
554
|
+
{ name: `Keep current: ${app.framework || 'none'}`, value: app.framework || '' },
|
|
555
|
+
{ name: '---', value: '__separator__', disabled: true },
|
|
556
|
+
{ name: 'nextjs', value: 'nextjs' },
|
|
557
|
+
{ name: 'react', value: 'react' },
|
|
558
|
+
{ name: 'vue', value: 'vue' },
|
|
559
|
+
{ name: 'nuxt', value: 'nuxt' },
|
|
560
|
+
{ name: 'vite', value: 'vite' },
|
|
561
|
+
{ name: 'astro', value: 'astro' },
|
|
562
|
+
{ name: 'nestjs', value: 'nestjs' },
|
|
563
|
+
{ name: 'express', value: 'express' },
|
|
564
|
+
{ name: 'fastify', value: 'fastify' },
|
|
565
|
+
{ name: 'hono', value: 'hono' },
|
|
566
|
+
{ name: 'Other (type manually)', value: '__other__' },
|
|
567
|
+
{ name: 'None / Clear', value: '__none__' },
|
|
568
|
+
];
|
|
569
|
+
const frameworkChoice = await prompts.select({
|
|
570
|
+
message: 'Framework:',
|
|
571
|
+
choices: frameworkChoices,
|
|
572
|
+
default: app.framework || '',
|
|
573
|
+
});
|
|
574
|
+
if (frameworkChoice === '__other__') {
|
|
575
|
+
const customFramework = await prompts.input({
|
|
576
|
+
message: 'Enter framework name:',
|
|
577
|
+
});
|
|
578
|
+
if (customFramework) {
|
|
579
|
+
result.framework = customFramework;
|
|
580
|
+
result.framework_source = 'manually set';
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
else if (frameworkChoice === '__none__') {
|
|
584
|
+
result.framework = undefined;
|
|
585
|
+
result.framework_source = undefined;
|
|
586
|
+
}
|
|
587
|
+
else if (frameworkChoice && frameworkChoice !== app.framework) {
|
|
588
|
+
result.framework = frameworkChoice;
|
|
589
|
+
result.framework_source = 'manually set';
|
|
590
|
+
}
|
|
591
|
+
console.log(chalk_1.default.green(`ā Updated ${name}`));
|
|
592
|
+
return result;
|
|
593
|
+
}
|
|
594
|
+
/**
|
|
595
|
+
* Interactive service URL selection
|
|
596
|
+
*/
|
|
597
|
+
async function interactiveUrlSelection(serviceUrls) {
|
|
598
|
+
if (serviceUrls.length === 0) {
|
|
599
|
+
return [];
|
|
600
|
+
}
|
|
601
|
+
console.log('');
|
|
602
|
+
console.log(chalk_1.default.blue('=== Detected Service URLs ==='));
|
|
603
|
+
console.log(chalk_1.default.dim('These are local/development URLs found in frontend env files.'));
|
|
604
|
+
console.log(chalk_1.default.dim('Select which ones need staging URL equivalents.\n'));
|
|
605
|
+
// Show detected URLs
|
|
606
|
+
for (const svc of serviceUrls) {
|
|
607
|
+
console.log(` ${chalk_1.default.cyan(svc.base_url)}`);
|
|
608
|
+
console.log(chalk_1.default.dim(` Used by: ${svc.used_by.slice(0, 3).join(', ')}${svc.used_by.length > 3 ? ` +${svc.used_by.length - 3} more` : ''}`));
|
|
609
|
+
}
|
|
610
|
+
console.log();
|
|
611
|
+
// Let user select which URLs to configure
|
|
612
|
+
const urlChoices = serviceUrls.map(svc => ({
|
|
613
|
+
name: `${svc.base_url} (${svc.used_by.length} var${svc.used_by.length > 1 ? 's' : ''})`,
|
|
614
|
+
value: svc.base_url,
|
|
615
|
+
checked: true, // Default: include all
|
|
616
|
+
}));
|
|
617
|
+
const selectedUrls = await prompts.checkbox({
|
|
618
|
+
message: 'Select service URLs to configure for staging:',
|
|
619
|
+
choices: urlChoices,
|
|
620
|
+
});
|
|
621
|
+
// Filter to selected URLs
|
|
622
|
+
return serviceUrls.filter(svc => selectedUrls.includes(svc.base_url));
|
|
623
|
+
}
|
|
624
|
+
/**
|
|
625
|
+
* Scan env files in app directories for service URLs
|
|
626
|
+
*/
|
|
627
|
+
function scanEnvFilesForUrls(apps, rootDir) {
|
|
628
|
+
const serviceUrls = new Map();
|
|
629
|
+
const envPatterns = ['.env', '.env.local', '.env.development'];
|
|
630
|
+
for (const [appName, app] of Object.entries(apps)) {
|
|
631
|
+
// Only scan frontend apps
|
|
632
|
+
if (app.type !== 'frontend')
|
|
633
|
+
continue;
|
|
634
|
+
const appDir = path.join(rootDir, app.path);
|
|
635
|
+
// Find env file
|
|
636
|
+
let envContent;
|
|
637
|
+
for (const pattern of envPatterns) {
|
|
638
|
+
const envPath = path.join(appDir, pattern);
|
|
639
|
+
if (fs.existsSync(envPath)) {
|
|
640
|
+
envContent = fs.readFileSync(envPath, 'utf8');
|
|
641
|
+
break;
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
if (!envContent)
|
|
645
|
+
continue;
|
|
646
|
+
// Process each line individually
|
|
647
|
+
for (const line of envContent.split('\n')) {
|
|
648
|
+
// Skip comments and empty lines
|
|
649
|
+
const trimmedLine = line.trim();
|
|
650
|
+
if (!trimmedLine || trimmedLine.startsWith('#'))
|
|
651
|
+
continue;
|
|
652
|
+
// Parse VAR=value format
|
|
653
|
+
const lineMatch = trimmedLine.match(/^([A-Z_][A-Z0-9_]*)=["']?(.+?)["']?$/);
|
|
654
|
+
if (!lineMatch)
|
|
655
|
+
continue;
|
|
656
|
+
const varName = lineMatch[1];
|
|
657
|
+
const value = lineMatch[2];
|
|
658
|
+
// Skip URLs with @ symbol (credentials, connection strings)
|
|
659
|
+
if (value.includes('@'))
|
|
660
|
+
continue;
|
|
661
|
+
// Check if it's a URL
|
|
662
|
+
const urlMatch = value.match(/^(https?:\/\/[a-zA-Z0-9_.-]+(?::\d+)?)/);
|
|
663
|
+
if (!urlMatch)
|
|
664
|
+
continue;
|
|
665
|
+
const baseUrl = urlMatch[1];
|
|
666
|
+
// Extract hostname
|
|
667
|
+
const hostMatch = baseUrl.match(/^https?:\/\/([a-zA-Z0-9_.-]+)/);
|
|
668
|
+
if (!hostMatch)
|
|
669
|
+
continue;
|
|
670
|
+
const hostname = hostMatch[1];
|
|
671
|
+
// Only include local URLs (localhost, Docker internal names, IPs)
|
|
672
|
+
const isLocalUrl = hostname === 'localhost' ||
|
|
673
|
+
!hostname.includes('.') ||
|
|
674
|
+
/^\d+\.\d+\.\d+\.\d+$/.test(hostname);
|
|
675
|
+
if (!isLocalUrl)
|
|
676
|
+
continue;
|
|
677
|
+
// Add to map
|
|
678
|
+
if (!serviceUrls.has(baseUrl)) {
|
|
679
|
+
serviceUrls.set(baseUrl, { vars: new Set(), apps: new Set() });
|
|
680
|
+
}
|
|
681
|
+
serviceUrls.get(baseUrl).vars.add(varName);
|
|
682
|
+
serviceUrls.get(baseUrl).apps.add(appName);
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
// Convert to DetectedServiceUrl array
|
|
686
|
+
const result = [];
|
|
687
|
+
for (const [baseUrl, { vars, apps: appNames }] of serviceUrls) {
|
|
688
|
+
const serviceInfo = getServiceInfoFromUrl(baseUrl);
|
|
689
|
+
result.push({
|
|
690
|
+
base_url: baseUrl,
|
|
691
|
+
var_name: serviceInfo.varName,
|
|
692
|
+
description: serviceInfo.description,
|
|
693
|
+
used_by: Array.from(vars),
|
|
694
|
+
apps: Array.from(appNames),
|
|
695
|
+
source: 'env files',
|
|
696
|
+
});
|
|
697
|
+
}
|
|
698
|
+
// Sort by port for consistent output
|
|
699
|
+
result.sort((a, b) => {
|
|
700
|
+
const portA = parseInt(a.base_url.match(/:(\d+)/)?.[1] || '0');
|
|
701
|
+
const portB = parseInt(b.base_url.match(/:(\d+)/)?.[1] || '0');
|
|
702
|
+
return portA - portB;
|
|
703
|
+
});
|
|
704
|
+
return result;
|
|
705
|
+
}
|
|
706
|
+
/**
|
|
707
|
+
* Get service info from URL
|
|
708
|
+
*/
|
|
709
|
+
function getServiceInfoFromUrl(baseUrl) {
|
|
710
|
+
const urlMatch = baseUrl.match(/^https?:\/\/([a-zA-Z0-9_.-]+)(?::(\d+))?/);
|
|
711
|
+
if (!urlMatch) {
|
|
712
|
+
return { varName: 'UNKNOWN_URL', description: 'Unknown service' };
|
|
713
|
+
}
|
|
714
|
+
const hostname = urlMatch[1];
|
|
715
|
+
const port = urlMatch[2] ? parseInt(urlMatch[2]) : undefined;
|
|
716
|
+
// Generate from hostname if not localhost
|
|
717
|
+
if (hostname !== 'localhost') {
|
|
718
|
+
const varName = hostname.toUpperCase().replace(/-/g, '_') + '_URL';
|
|
719
|
+
return {
|
|
720
|
+
varName,
|
|
721
|
+
description: `${hostname} service`,
|
|
722
|
+
};
|
|
723
|
+
}
|
|
724
|
+
// Generate from port for localhost
|
|
725
|
+
if (port) {
|
|
726
|
+
return {
|
|
727
|
+
varName: `PORT_${port}_URL`,
|
|
728
|
+
description: `localhost:${port}`,
|
|
729
|
+
};
|
|
730
|
+
}
|
|
731
|
+
return { varName: 'LOCALHOST_URL', description: 'localhost' };
|
|
732
|
+
}
|
|
733
|
+
/**
|
|
734
|
+
* Convert ProjectScan to DetectedConfig
|
|
735
|
+
*/
|
|
736
|
+
function convertScanToDetected(scan, root) {
|
|
737
|
+
const detected = {
|
|
738
|
+
_meta: {
|
|
739
|
+
generated_at: new Date().toISOString(),
|
|
740
|
+
genbox_version: version,
|
|
741
|
+
scanned_root: root,
|
|
742
|
+
},
|
|
743
|
+
structure: {
|
|
744
|
+
type: mapStructureType(scan.structure.type),
|
|
745
|
+
confidence: scan.structure.confidence || 'medium',
|
|
746
|
+
indicators: scan.structure.indicators || [],
|
|
747
|
+
},
|
|
748
|
+
runtimes: scan.runtimes.map(r => ({
|
|
749
|
+
language: r.language,
|
|
750
|
+
version: r.version,
|
|
751
|
+
version_source: r.versionSource,
|
|
752
|
+
package_manager: r.packageManager,
|
|
753
|
+
lockfile: r.lockfile,
|
|
754
|
+
})),
|
|
755
|
+
apps: {},
|
|
756
|
+
};
|
|
757
|
+
// Convert apps (detect git info for each app in multi-repo workspaces)
|
|
758
|
+
const isMultiRepo = scan.structure.type === 'hybrid';
|
|
759
|
+
for (const app of scan.apps) {
|
|
760
|
+
// Map scanner AppType to DetectedApp type
|
|
761
|
+
const mappedType = mapAppType(app.type);
|
|
762
|
+
// Detect git info for this app (for multi-repo workspaces)
|
|
763
|
+
let appGit;
|
|
764
|
+
if (isMultiRepo) {
|
|
765
|
+
const appDir = path.join(root, app.path);
|
|
766
|
+
appGit = detectGitForDirectory(appDir);
|
|
767
|
+
}
|
|
768
|
+
// Detect runner based on app type and context
|
|
769
|
+
const { runner, runner_reason } = detectRunner(app, scan);
|
|
770
|
+
detected.apps[app.name] = {
|
|
771
|
+
path: app.path,
|
|
772
|
+
type: mappedType,
|
|
773
|
+
type_reason: inferTypeReason(app),
|
|
774
|
+
runner,
|
|
775
|
+
runner_reason,
|
|
776
|
+
port: app.port,
|
|
777
|
+
port_source: app.port ? inferPortSource(app) : undefined,
|
|
778
|
+
framework: app.framework, // Framework is a string type
|
|
779
|
+
framework_source: app.framework ? inferFrameworkSource(app) : undefined,
|
|
780
|
+
commands: app.scripts ? {
|
|
781
|
+
dev: app.scripts.dev,
|
|
782
|
+
build: app.scripts.build,
|
|
783
|
+
start: app.scripts.start,
|
|
784
|
+
} : undefined,
|
|
785
|
+
dependencies: app.dependencies,
|
|
786
|
+
git: appGit,
|
|
787
|
+
};
|
|
788
|
+
}
|
|
789
|
+
// Convert infrastructure
|
|
790
|
+
if (scan.compose) {
|
|
791
|
+
detected.infrastructure = [];
|
|
792
|
+
for (const db of scan.compose.databases || []) {
|
|
793
|
+
detected.infrastructure.push({
|
|
794
|
+
name: db.name,
|
|
795
|
+
type: 'database',
|
|
796
|
+
image: db.image || 'unknown',
|
|
797
|
+
port: db.ports?.[0]?.host || 0,
|
|
798
|
+
source: 'docker-compose.yml',
|
|
799
|
+
});
|
|
800
|
+
}
|
|
801
|
+
for (const cache of scan.compose.caches || []) {
|
|
802
|
+
detected.infrastructure.push({
|
|
803
|
+
name: cache.name,
|
|
804
|
+
type: 'cache',
|
|
805
|
+
image: cache.image || 'unknown',
|
|
806
|
+
port: cache.ports?.[0]?.host || 0,
|
|
807
|
+
source: 'docker-compose.yml',
|
|
808
|
+
});
|
|
809
|
+
}
|
|
810
|
+
for (const queue of scan.compose.queues || []) {
|
|
811
|
+
detected.infrastructure.push({
|
|
812
|
+
name: queue.name,
|
|
813
|
+
type: 'queue',
|
|
814
|
+
image: queue.image || 'unknown',
|
|
815
|
+
port: queue.ports?.[0]?.host || 0,
|
|
816
|
+
source: 'docker-compose.yml',
|
|
817
|
+
});
|
|
818
|
+
}
|
|
819
|
+
// Add Docker application services as apps with runner: 'docker'
|
|
820
|
+
// (Only add if not already detected as a PM2 app)
|
|
821
|
+
if (scan.compose.applications && scan.compose.applications.length > 0) {
|
|
822
|
+
for (const dockerApp of scan.compose.applications) {
|
|
823
|
+
// Skip if already exists as a PM2 app
|
|
824
|
+
if (detected.apps[dockerApp.name])
|
|
825
|
+
continue;
|
|
826
|
+
// Infer type by checking file structure in build context
|
|
827
|
+
const { type: mappedType, reason: typeReason } = inferDockerAppType(dockerApp.name, dockerApp.build?.context, scan.root);
|
|
828
|
+
detected.apps[dockerApp.name] = {
|
|
829
|
+
path: dockerApp.build?.context || '.',
|
|
830
|
+
type: mappedType,
|
|
831
|
+
type_reason: typeReason,
|
|
832
|
+
runner: 'docker',
|
|
833
|
+
runner_reason: 'defined in docker-compose.yml',
|
|
834
|
+
docker: {
|
|
835
|
+
service: dockerApp.name,
|
|
836
|
+
build_context: dockerApp.build?.context,
|
|
837
|
+
dockerfile: dockerApp.build?.dockerfile,
|
|
838
|
+
image: dockerApp.image,
|
|
839
|
+
},
|
|
840
|
+
port: dockerApp.ports?.[0]?.host,
|
|
841
|
+
port_source: 'docker-compose.yml ports',
|
|
842
|
+
};
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
// Git info
|
|
847
|
+
if (scan.git) {
|
|
848
|
+
detected.git = {
|
|
849
|
+
remote: scan.git.remote,
|
|
850
|
+
type: scan.git.type,
|
|
851
|
+
provider: scan.git.provider,
|
|
852
|
+
branch: scan.git.branch,
|
|
853
|
+
};
|
|
854
|
+
}
|
|
855
|
+
// Scripts
|
|
856
|
+
if (scan.scripts && scan.scripts.length > 0) {
|
|
857
|
+
detected.scripts = scan.scripts.map(s => ({
|
|
858
|
+
name: s.name,
|
|
859
|
+
path: s.path,
|
|
860
|
+
stage: s.stage,
|
|
861
|
+
stage_reason: inferStageReason(s),
|
|
862
|
+
executable: s.isExecutable,
|
|
863
|
+
}));
|
|
864
|
+
}
|
|
865
|
+
return detected;
|
|
866
|
+
}
|
|
867
|
+
function mapStructureType(type) {
|
|
868
|
+
if (type.startsWith('monorepo'))
|
|
869
|
+
return 'monorepo';
|
|
870
|
+
if (type === 'hybrid')
|
|
871
|
+
return 'workspace';
|
|
872
|
+
if (type === 'microservices')
|
|
873
|
+
return 'microservices';
|
|
874
|
+
return 'single-app';
|
|
875
|
+
}
|
|
876
|
+
function mapAppType(type) {
|
|
877
|
+
// Map scanner's AppType to DetectedApp type
|
|
878
|
+
switch (type) {
|
|
879
|
+
case 'frontend':
|
|
880
|
+
return 'frontend';
|
|
881
|
+
case 'backend':
|
|
882
|
+
case 'api': // Scanner has 'api', map to 'backend'
|
|
883
|
+
return 'backend';
|
|
884
|
+
case 'worker':
|
|
885
|
+
return 'worker';
|
|
886
|
+
case 'gateway':
|
|
887
|
+
return 'gateway';
|
|
888
|
+
case 'library':
|
|
889
|
+
return 'library';
|
|
890
|
+
default:
|
|
891
|
+
return undefined; // Unknown type
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
/**
|
|
895
|
+
* Detect the appropriate runner for an app
|
|
896
|
+
* Priority:
|
|
897
|
+
* 1. Library apps ā none
|
|
898
|
+
* 2. Bun lockfile present ā bun
|
|
899
|
+
* 3. Has start script or is a typical app ā pm2
|
|
900
|
+
* 4. Simple CLI/script ā node
|
|
901
|
+
*/
|
|
902
|
+
function detectRunner(app, scan) {
|
|
903
|
+
// Libraries don't run
|
|
904
|
+
if (app.type === 'library') {
|
|
905
|
+
return { runner: 'none', runner_reason: 'library apps are not runnable' };
|
|
906
|
+
}
|
|
907
|
+
// Check for Bun
|
|
908
|
+
const hasBunLockfile = scan.runtimes?.some(r => r.lockfile === 'bun.lockb');
|
|
909
|
+
if (hasBunLockfile) {
|
|
910
|
+
return { runner: 'bun', runner_reason: 'bun.lockb detected' };
|
|
911
|
+
}
|
|
912
|
+
// Check for typical app patterns that benefit from PM2
|
|
913
|
+
const hasStartScript = app.scripts?.start || app.scripts?.dev;
|
|
914
|
+
const isTypicalApp = ['frontend', 'backend', 'api', 'worker', 'gateway'].includes(app.type || '');
|
|
915
|
+
if (hasStartScript || isTypicalApp) {
|
|
916
|
+
return { runner: 'pm2', runner_reason: 'typical Node.js app with start script' };
|
|
917
|
+
}
|
|
918
|
+
// CLI tools or simple scripts can use direct node
|
|
919
|
+
const name = (app.name || '').toLowerCase();
|
|
920
|
+
if (name.includes('cli') || name.includes('tool') || name.includes('script')) {
|
|
921
|
+
return { runner: 'node', runner_reason: 'CLI/tool detected' };
|
|
922
|
+
}
|
|
923
|
+
// Default to pm2 for most apps
|
|
924
|
+
return { runner: 'pm2', runner_reason: 'default for Node.js apps' };
|
|
925
|
+
}
|
|
926
|
+
function inferTypeReason(app) {
|
|
927
|
+
if (!app.type)
|
|
928
|
+
return 'unknown';
|
|
929
|
+
const name = (app.name || app.path || '').toLowerCase();
|
|
930
|
+
if (name.includes('web') || name.includes('frontend') || name.includes('ui') || name.includes('client')) {
|
|
931
|
+
return `naming convention ('${app.name}' contains frontend keyword)`;
|
|
932
|
+
}
|
|
933
|
+
if (name.includes('api') || name.includes('backend') || name.includes('server') || name.includes('gateway')) {
|
|
934
|
+
return `naming convention ('${app.name}' contains backend keyword)`;
|
|
935
|
+
}
|
|
936
|
+
if (name.includes('worker') || name.includes('queue') || name.includes('job')) {
|
|
937
|
+
return `naming convention ('${app.name}' contains worker keyword)`;
|
|
938
|
+
}
|
|
939
|
+
return 'dependency analysis';
|
|
940
|
+
}
|
|
941
|
+
/**
|
|
942
|
+
* Infer app type by examining file structure in the build context directory
|
|
943
|
+
* Falls back to name-based detection only if file analysis doesn't find anything
|
|
944
|
+
*/
|
|
945
|
+
function inferDockerAppType(name, buildContext, rootDir) {
|
|
946
|
+
// If we have a build context, check file structure first
|
|
947
|
+
if (buildContext && rootDir) {
|
|
948
|
+
const contextPath = path.resolve(rootDir, buildContext);
|
|
949
|
+
// Check for frontend indicators
|
|
950
|
+
const frontendConfigs = ['vite.config.ts', 'vite.config.js', 'next.config.js', 'next.config.mjs', 'nuxt.config.ts', 'astro.config.mjs'];
|
|
951
|
+
for (const config of frontendConfigs) {
|
|
952
|
+
if (fs.existsSync(path.join(contextPath, config))) {
|
|
953
|
+
return { type: 'frontend', reason: `config file found: ${config}` };
|
|
954
|
+
}
|
|
955
|
+
}
|
|
956
|
+
// Check for backend indicators
|
|
957
|
+
const backendConfigs = ['nest-cli.json', 'tsconfig.build.json'];
|
|
958
|
+
for (const config of backendConfigs) {
|
|
959
|
+
if (fs.existsSync(path.join(contextPath, config))) {
|
|
960
|
+
return { type: 'backend', reason: `config file found: ${config}` };
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
// Check package.json dependencies
|
|
964
|
+
const packageJsonPath = path.join(contextPath, 'package.json');
|
|
965
|
+
if (fs.existsSync(packageJsonPath)) {
|
|
966
|
+
try {
|
|
967
|
+
const pkgJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
|
|
968
|
+
const allDeps = { ...pkgJson.dependencies, ...pkgJson.devDependencies };
|
|
969
|
+
// Frontend dependencies
|
|
970
|
+
const frontendDeps = ['react', 'react-dom', 'vue', '@angular/core', 'svelte', 'next', 'nuxt', 'vite', '@remix-run/react', 'astro'];
|
|
971
|
+
for (const dep of frontendDeps) {
|
|
972
|
+
if (allDeps[dep]) {
|
|
973
|
+
return { type: 'frontend', reason: `package.json dependency: ${dep}` };
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
// Backend dependencies
|
|
977
|
+
const backendDeps = ['@nestjs/core', 'express', 'fastify', 'koa', 'hapi', '@hono/node-server'];
|
|
978
|
+
for (const dep of backendDeps) {
|
|
979
|
+
if (allDeps[dep]) {
|
|
980
|
+
return { type: 'backend', reason: `package.json dependency: ${dep}` };
|
|
981
|
+
}
|
|
982
|
+
}
|
|
983
|
+
// Worker dependencies
|
|
984
|
+
const workerDeps = ['bull', 'bullmq', 'agenda', 'bee-queue'];
|
|
985
|
+
for (const dep of workerDeps) {
|
|
986
|
+
if (allDeps[dep]) {
|
|
987
|
+
return { type: 'worker', reason: `package.json dependency: ${dep}` };
|
|
988
|
+
}
|
|
989
|
+
}
|
|
990
|
+
}
|
|
991
|
+
catch {
|
|
992
|
+
// Ignore parse errors
|
|
993
|
+
}
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
// Fall back to name-based detection
|
|
997
|
+
const lowerName = name.toLowerCase();
|
|
998
|
+
if (lowerName.includes('web') || lowerName.includes('frontend') || lowerName.includes('ui') || lowerName.includes('client')) {
|
|
999
|
+
return { type: 'frontend', reason: `naming convention ('${name}' contains frontend keyword)` };
|
|
1000
|
+
}
|
|
1001
|
+
if (lowerName.includes('api') || lowerName.includes('backend') || lowerName.includes('server') || lowerName.includes('gateway')) {
|
|
1002
|
+
return { type: 'backend', reason: `naming convention ('${name}' contains backend keyword)` };
|
|
1003
|
+
}
|
|
1004
|
+
if (lowerName.includes('worker') || lowerName.includes('queue') || lowerName.includes('job')) {
|
|
1005
|
+
return { type: 'worker', reason: `naming convention ('${name}' contains worker keyword)` };
|
|
1006
|
+
}
|
|
1007
|
+
// Default to backend for Docker services
|
|
1008
|
+
return { type: 'backend', reason: 'default for Docker services' };
|
|
1009
|
+
}
|
|
1010
|
+
function inferPortSource(app) {
|
|
1011
|
+
if (app.scripts?.dev?.includes('--port')) {
|
|
1012
|
+
return 'package.json scripts.dev (--port flag)';
|
|
1013
|
+
}
|
|
1014
|
+
if (app.scripts?.dev?.includes('PORT=')) {
|
|
1015
|
+
return 'package.json scripts.dev (PORT= env)';
|
|
1016
|
+
}
|
|
1017
|
+
if (app.scripts?.start?.includes('--port')) {
|
|
1018
|
+
return 'package.json scripts.start (--port flag)';
|
|
1019
|
+
}
|
|
1020
|
+
return 'framework default';
|
|
1021
|
+
}
|
|
1022
|
+
function inferFrameworkSource(app) {
|
|
1023
|
+
const framework = app.framework;
|
|
1024
|
+
if (!framework)
|
|
1025
|
+
return 'unknown';
|
|
1026
|
+
// Config file based detection
|
|
1027
|
+
const configFrameworks = ['nextjs', 'nuxt', 'nestjs', 'astro', 'gatsby'];
|
|
1028
|
+
if (configFrameworks.includes(framework)) {
|
|
1029
|
+
return `config file (${framework}.config.* or similar)`;
|
|
1030
|
+
}
|
|
1031
|
+
return 'package.json dependencies';
|
|
1032
|
+
}
|
|
1033
|
+
function inferStageReason(script) {
|
|
1034
|
+
const name = script.name.toLowerCase();
|
|
1035
|
+
if (name.includes('setup') || name.includes('init') || name.includes('install')) {
|
|
1036
|
+
return `filename contains '${name.match(/setup|init|install/)?.[0]}'`;
|
|
1037
|
+
}
|
|
1038
|
+
if (name.includes('build') || name.includes('compile')) {
|
|
1039
|
+
return `filename contains '${name.match(/build|compile/)?.[0]}'`;
|
|
1040
|
+
}
|
|
1041
|
+
if (name.includes('start') || name.includes('run') || name.includes('serve')) {
|
|
1042
|
+
return `filename contains '${name.match(/start|run|serve/)?.[0]}'`;
|
|
1043
|
+
}
|
|
1044
|
+
if (name.includes('deploy') || name.includes('release')) {
|
|
1045
|
+
return `filename contains '${name.match(/deploy|release/)?.[0]}'`;
|
|
1046
|
+
}
|
|
1047
|
+
return 'default assignment';
|
|
1048
|
+
}
|
|
1049
|
+
function outputToStdout(detected, asJson) {
|
|
1050
|
+
if (asJson) {
|
|
1051
|
+
console.log(JSON.stringify(detected, null, 2));
|
|
1052
|
+
}
|
|
1053
|
+
else {
|
|
1054
|
+
console.log(yaml.dump(detected, { lineWidth: 120, noRefs: true }));
|
|
1055
|
+
}
|
|
1056
|
+
}
|
|
1057
|
+
async function outputToFile(detected, root, asJson) {
|
|
1058
|
+
const genboxDir = path.join(root, '.genbox');
|
|
1059
|
+
// Create .genbox directory if it doesn't exist
|
|
1060
|
+
if (!fs.existsSync(genboxDir)) {
|
|
1061
|
+
fs.mkdirSync(genboxDir, { recursive: true });
|
|
1062
|
+
}
|
|
1063
|
+
// Add .genbox to .gitignore if not already there
|
|
1064
|
+
const gitignorePath = path.join(root, '.gitignore');
|
|
1065
|
+
if (fs.existsSync(gitignorePath)) {
|
|
1066
|
+
const gitignore = fs.readFileSync(gitignorePath, 'utf8');
|
|
1067
|
+
if (!gitignore.includes('.genbox')) {
|
|
1068
|
+
fs.appendFileSync(gitignorePath, '\n# Genbox generated files\n.genbox/\n');
|
|
1069
|
+
console.log(chalk_1.default.dim('Added .genbox/ to .gitignore'));
|
|
1070
|
+
}
|
|
1071
|
+
}
|
|
1072
|
+
// Write detected config
|
|
1073
|
+
const filename = asJson ? 'detected.json' : 'detected.yaml';
|
|
1074
|
+
const filePath = path.join(genboxDir, filename);
|
|
1075
|
+
const content = asJson
|
|
1076
|
+
? JSON.stringify(detected, null, 2)
|
|
1077
|
+
: yaml.dump(detected, { lineWidth: 120, noRefs: true });
|
|
1078
|
+
fs.writeFileSync(filePath, content);
|
|
1079
|
+
console.log(chalk_1.default.green(`\nā Detected configuration written to: ${filePath}`));
|
|
1080
|
+
}
|
|
1081
|
+
function showSummary(detected) {
|
|
1082
|
+
console.log(chalk_1.default.bold('\nš Detection Summary:\n'));
|
|
1083
|
+
// Structure
|
|
1084
|
+
console.log(` Structure: ${chalk_1.default.cyan(detected.structure.type)} (${detected.structure.confidence} confidence)`);
|
|
1085
|
+
if (detected.structure.indicators.length > 0) {
|
|
1086
|
+
console.log(chalk_1.default.dim(` Indicators: ${detected.structure.indicators.join(', ')}`));
|
|
1087
|
+
}
|
|
1088
|
+
// Runtimes
|
|
1089
|
+
if (detected.runtimes.length > 0) {
|
|
1090
|
+
console.log(`\n Runtimes:`);
|
|
1091
|
+
for (const runtime of detected.runtimes) {
|
|
1092
|
+
console.log(` ${chalk_1.default.cyan(runtime.language)}${runtime.version ? ` ${runtime.version}` : ''}`);
|
|
1093
|
+
if (runtime.package_manager) {
|
|
1094
|
+
console.log(chalk_1.default.dim(` Package manager: ${runtime.package_manager}`));
|
|
1095
|
+
}
|
|
1096
|
+
}
|
|
1097
|
+
}
|
|
1098
|
+
// Apps
|
|
1099
|
+
const appNames = Object.keys(detected.apps);
|
|
1100
|
+
if (appNames.length > 0) {
|
|
1101
|
+
console.log(`\n Apps (${appNames.length}):`);
|
|
1102
|
+
for (const name of appNames) {
|
|
1103
|
+
const app = detected.apps[name];
|
|
1104
|
+
const parts = [
|
|
1105
|
+
chalk_1.default.cyan(name),
|
|
1106
|
+
app.type ? `(${app.type})` : '',
|
|
1107
|
+
app.framework ? `[${app.framework}]` : '',
|
|
1108
|
+
app.port ? `port:${app.port}` : '',
|
|
1109
|
+
].filter(Boolean);
|
|
1110
|
+
console.log(` ${parts.join(' ')}`);
|
|
1111
|
+
}
|
|
1112
|
+
}
|
|
1113
|
+
// Docker Services (applications with build context)
|
|
1114
|
+
if (detected.docker_services && detected.docker_services.length > 0) {
|
|
1115
|
+
console.log(`\n Docker Services (${detected.docker_services.length}):`);
|
|
1116
|
+
for (const svc of detected.docker_services) {
|
|
1117
|
+
const portInfo = svc.port ? ` port:${svc.port}` : '';
|
|
1118
|
+
console.log(` ${chalk_1.default.cyan(svc.name)}${portInfo}`);
|
|
1119
|
+
if (svc.build_context) {
|
|
1120
|
+
console.log(chalk_1.default.dim(` build: ${svc.build_context}`));
|
|
1121
|
+
}
|
|
1122
|
+
}
|
|
1123
|
+
}
|
|
1124
|
+
// Infrastructure
|
|
1125
|
+
if (detected.infrastructure && detected.infrastructure.length > 0) {
|
|
1126
|
+
console.log(`\n Infrastructure (${detected.infrastructure.length}):`);
|
|
1127
|
+
for (const infra of detected.infrastructure) {
|
|
1128
|
+
console.log(` ${chalk_1.default.cyan(infra.name)}: ${infra.type} (${infra.image})`);
|
|
1129
|
+
}
|
|
1130
|
+
}
|
|
1131
|
+
// Git (root level) - show branch prominently
|
|
1132
|
+
if (detected.git?.remote) {
|
|
1133
|
+
console.log(`\n Git: ${detected.git.provider || 'git'} (${detected.git.type})`);
|
|
1134
|
+
console.log(` Remote: ${chalk_1.default.dim(detected.git.remote)}`);
|
|
1135
|
+
console.log(` Branch: ${chalk_1.default.cyan(detected.git.branch || 'main')} ${chalk_1.default.dim('ā base branch for new environment branches')}`);
|
|
1136
|
+
}
|
|
1137
|
+
// Per-app git repos (for multi-repo workspaces)
|
|
1138
|
+
const appsWithGit = Object.entries(detected.apps).filter(([, app]) => app.git);
|
|
1139
|
+
if (appsWithGit.length > 0) {
|
|
1140
|
+
console.log(`\n App Repositories (${appsWithGit.length}):`);
|
|
1141
|
+
for (const [name, app] of appsWithGit) {
|
|
1142
|
+
const git = app.git;
|
|
1143
|
+
console.log(` ${chalk_1.default.cyan(name)}: ${git.provider} (${git.type})`);
|
|
1144
|
+
console.log(chalk_1.default.dim(` ${git.remote}`));
|
|
1145
|
+
console.log(` Branch: ${chalk_1.default.cyan(git.branch || 'main')}`);
|
|
1146
|
+
}
|
|
1147
|
+
}
|
|
1148
|
+
// Service URLs (for staging URL configuration)
|
|
1149
|
+
if (detected.service_urls && detected.service_urls.length > 0) {
|
|
1150
|
+
console.log(`\n Service URLs (${detected.service_urls.length}):`);
|
|
1151
|
+
for (const svc of detected.service_urls) {
|
|
1152
|
+
console.log(` ${chalk_1.default.cyan(svc.var_name)}: ${svc.base_url}`);
|
|
1153
|
+
console.log(chalk_1.default.dim(` Used by: ${svc.used_by.slice(0, 3).join(', ')}${svc.used_by.length > 3 ? ` +${svc.used_by.length - 3} more` : ''}`));
|
|
1154
|
+
}
|
|
1155
|
+
console.log(chalk_1.default.dim('\n These URLs will need staging equivalents in init.'));
|
|
1156
|
+
}
|
|
1157
|
+
console.log(chalk_1.default.bold('\nš Next steps:\n'));
|
|
1158
|
+
console.log(' 1. Review the detected configuration in .genbox/detected.yaml');
|
|
1159
|
+
console.log(' 2. Run ' + chalk_1.default.cyan('genbox init --from-scan') + ' to create genbox.yaml');
|
|
1160
|
+
console.log(' 3. Or manually create genbox.yaml using detected values');
|
|
1161
|
+
console.log();
|
|
1162
|
+
}
|