genbox 1.0.59 → 1.0.61

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.
@@ -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
+ }