genbox 1.0.15 → 1.0.16

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.
@@ -319,10 +319,46 @@ function parseEnvGenboxSections(content) {
319
319
  }
320
320
  return sections;
321
321
  }
322
+ /**
323
+ * Build a map of service URL variables based on connection type
324
+ * e.g., if connectTo=staging: GATEWAY_URL → STAGING_GATEWAY_URL value
325
+ */
326
+ function buildServiceUrlMap(envVarsFromFile, connectTo) {
327
+ const urlMap = {};
328
+ const prefix = connectTo ? `${connectTo.toUpperCase()}_` : 'LOCAL_';
329
+ // Find all service URL variables (LOCAL_*_URL and STAGING_*_URL patterns)
330
+ const serviceNames = new Set();
331
+ for (const key of Object.keys(envVarsFromFile)) {
332
+ const match = key.match(/^(LOCAL|STAGING|PRODUCTION)_(.+_URL)$/);
333
+ if (match) {
334
+ serviceNames.add(match[2]);
335
+ }
336
+ }
337
+ // Build mapping: VARNAME → value from appropriate prefix
338
+ for (const serviceName of serviceNames) {
339
+ const prefixedKey = `${prefix}${serviceName}`;
340
+ const localKey = `LOCAL_${serviceName}`;
341
+ // Use prefixed value if available, otherwise fall back to local
342
+ const value = envVarsFromFile[prefixedKey] || envVarsFromFile[localKey];
343
+ if (value) {
344
+ urlMap[serviceName] = value;
345
+ }
346
+ }
347
+ // Also handle legacy API_URL for backwards compatibility
348
+ if (!urlMap['API_URL']) {
349
+ const apiUrl = envVarsFromFile[`${prefix}API_URL`] ||
350
+ envVarsFromFile['LOCAL_API_URL'] ||
351
+ envVarsFromFile['STAGING_API_URL'];
352
+ if (apiUrl) {
353
+ urlMap['API_URL'] = apiUrl;
354
+ }
355
+ }
356
+ return urlMap;
357
+ }
322
358
  /**
323
359
  * Build env content for a specific app by combining GLOBAL + app-specific sections
324
360
  */
325
- function buildAppEnvContent(sections, appName, apiUrl) {
361
+ function buildAppEnvContent(sections, appName, serviceUrlMap) {
326
362
  const parts = [];
327
363
  // Always include GLOBAL section
328
364
  const globalSection = sections.get('GLOBAL');
@@ -335,8 +371,11 @@ function buildAppEnvContent(sections, appName, apiUrl) {
335
371
  parts.push(appSection);
336
372
  }
337
373
  let envContent = parts.join('\n\n');
338
- // Expand ${API_URL} references
339
- envContent = envContent.replace(/\$\{API_URL\}/g, apiUrl);
374
+ // Expand all ${VARNAME} references using the service URL map
375
+ for (const [varName, value] of Object.entries(serviceUrlMap)) {
376
+ const pattern = new RegExp(`\\$\\{${varName}\\}`, 'g');
377
+ envContent = envContent.replace(pattern, value);
378
+ }
340
379
  // Keep only actual env vars (filter out pure comment lines but keep var definitions)
341
380
  envContent = envContent
342
381
  .split('\n')
@@ -393,21 +432,19 @@ function buildPayload(resolved, config, publicKey, privateKey, configLoader) {
393
432
  envVarsFromFile[match[1]] = value;
394
433
  }
395
434
  }
396
- // Determine API_URL based on profile's connect_to (v3) or default_connection (v4)
435
+ // Determine connection type from profile's connect_to (v3) or default_connection (v4)
397
436
  let connectTo;
398
437
  if (resolved.profile && config.profiles?.[resolved.profile]) {
399
438
  const profile = config.profiles[resolved.profile];
400
439
  connectTo = (0, config_loader_1.getProfileConnection)(profile);
401
440
  }
402
- let apiUrl;
403
- if (connectTo) {
404
- // Use the environment-specific API URL (e.g., STAGING_API_URL)
405
- const envApiVarName = `${connectTo.toUpperCase()}_API_URL`;
406
- apiUrl = envVarsFromFile[envApiVarName] || resolved.env['API_URL'] || 'http://localhost:3050';
407
- }
408
- else {
409
- // Use local API URL
410
- apiUrl = envVarsFromFile['LOCAL_API_URL'] || 'http://localhost:3050';
441
+ // Build service URL map for variable expansion
442
+ // This maps GATEWAY_URL → STAGING_GATEWAY_URL value (if connectTo=staging)
443
+ // or GATEWAY_URL LOCAL_GATEWAY_URL value (if local)
444
+ const serviceUrlMap = buildServiceUrlMap(envVarsFromFile, connectTo);
445
+ // Log what's being expanded for debugging
446
+ if (connectTo && Object.keys(serviceUrlMap).length > 0) {
447
+ console.log(chalk_1.default.dim(` Using ${connectTo} URLs for variable expansion`));
411
448
  }
412
449
  // Add env file for each app - filtered by selected apps only
413
450
  for (const app of resolved.apps) {
@@ -421,7 +458,7 @@ function buildPayload(resolved, config, publicKey, privateKey, configLoader) {
421
458
  for (const serviceSectionName of servicesSections) {
422
459
  const serviceName = serviceSectionName.split('/')[1];
423
460
  // Build service-specific env content (GLOBAL + service section)
424
- const serviceEnvContent = buildAppEnvContent(sections, serviceSectionName, apiUrl);
461
+ const serviceEnvContent = buildAppEnvContent(sections, serviceSectionName, serviceUrlMap);
425
462
  const stagingName = `${app.name}-${serviceName}.env`;
426
463
  const targetPath = `${repoPath}/apps/${serviceName}/.env`;
427
464
  files.push({
@@ -434,7 +471,7 @@ function buildPayload(resolved, config, publicKey, privateKey, configLoader) {
434
471
  }
435
472
  else {
436
473
  // Regular app - build app-specific env content (GLOBAL + app section)
437
- const appEnvContent = buildAppEnvContent(sections, app.name, apiUrl);
474
+ const appEnvContent = buildAppEnvContent(sections, app.name, serviceUrlMap);
438
475
  files.push({
439
476
  path: `/home/dev/.env-staging/${app.name}.env`,
440
477
  content: appEnvContent,
@@ -51,6 +51,217 @@ const config_generator_1 = require("../scanner/config-generator");
51
51
  const scan_1 = require("../scan");
52
52
  const CONFIG_FILENAME = 'genbox.yaml';
53
53
  const ENV_FILENAME = '.env.genbox';
54
+ /**
55
+ * Extract unique HTTP URLs from frontend app sections in .env.genbox content
56
+ * Catches both localhost URLs and Docker internal service references
57
+ */
58
+ function extractFrontendHttpUrls(envContent, frontendApps) {
59
+ const serviceUrls = new Map();
60
+ // Parse sections
61
+ const sections = new Map();
62
+ let currentSection = 'GLOBAL';
63
+ let currentContent = [];
64
+ for (const line of envContent.split('\n')) {
65
+ const sectionMatch = line.match(/^# === ([^=]+) ===$/);
66
+ if (sectionMatch) {
67
+ if (currentContent.length > 0) {
68
+ sections.set(currentSection, currentContent.join('\n'));
69
+ }
70
+ currentSection = sectionMatch[1].trim();
71
+ currentContent = [];
72
+ }
73
+ else {
74
+ currentContent.push(line);
75
+ }
76
+ }
77
+ if (currentContent.length > 0) {
78
+ sections.set(currentSection, currentContent.join('\n'));
79
+ }
80
+ // Only process frontend app sections
81
+ for (const appName of frontendApps) {
82
+ const appSection = sections.get(appName);
83
+ if (!appSection)
84
+ continue;
85
+ // Find all HTTP URLs
86
+ // Match: VAR_NAME="http://host:port/path" or VAR_NAME=http://host:port/path
87
+ const urlRegex = /^([A-Z_][A-Z0-9_]*)=["']?(https?:\/\/[a-zA-Z0-9_.-]+(?::\d+)?[^"'\s]*)["']?/gm;
88
+ let match;
89
+ while ((match = urlRegex.exec(appSection)) !== null) {
90
+ const varName = match[1];
91
+ const fullUrl = match[2];
92
+ // Extract hostname from URL
93
+ const hostMatch = fullUrl.match(/^https?:\/\/([a-zA-Z0-9_.-]+)/);
94
+ if (!hostMatch)
95
+ continue;
96
+ const hostname = hostMatch[1];
97
+ // Only include URLs that look like local/development URLs:
98
+ // - localhost
99
+ // - Single-word hosts (Docker internal names like "auth", "gateway", "redis")
100
+ // - IP addresses
101
+ const isLocalUrl = hostname === 'localhost' ||
102
+ !hostname.includes('.') || // No dots = likely Docker internal name
103
+ /^\d+\.\d+\.\d+\.\d+$/.test(hostname); // IP address
104
+ if (!isLocalUrl) {
105
+ continue;
106
+ }
107
+ // Extract base URL (protocol + host + port)
108
+ const baseMatch = fullUrl.match(/^(https?:\/\/[a-zA-Z0-9_.-]+(?::\d+)?)/);
109
+ if (!baseMatch)
110
+ continue;
111
+ const baseUrl = baseMatch[1];
112
+ // Group by base URL
113
+ if (!serviceUrls.has(baseUrl)) {
114
+ serviceUrls.set(baseUrl, { urls: new Set(), vars: [] });
115
+ }
116
+ serviceUrls.get(baseUrl).urls.add(fullUrl);
117
+ if (!serviceUrls.get(baseUrl).vars.includes(varName)) {
118
+ serviceUrls.get(baseUrl).vars.push(varName);
119
+ }
120
+ }
121
+ }
122
+ return serviceUrls;
123
+ }
124
+ /**
125
+ * Determine service name from URL (hostname or port)
126
+ * Generates variable prefix from hostname or port number
127
+ */
128
+ function getServiceNameFromUrl(baseUrl) {
129
+ // Parse the URL
130
+ const urlMatch = baseUrl.match(/^https?:\/\/([a-zA-Z0-9_.-]+)(?::(\d+))?/);
131
+ if (!urlMatch) {
132
+ return { name: 'unknown', varPrefix: 'UNKNOWN', description: 'Unknown Service' };
133
+ }
134
+ const hostname = urlMatch[1];
135
+ const port = urlMatch[2] ? parseInt(urlMatch[2]) : undefined;
136
+ // Generate from hostname if not localhost
137
+ if (hostname !== 'localhost') {
138
+ const varPrefix = hostname.toUpperCase().replace(/-/g, '_');
139
+ return {
140
+ name: hostname,
141
+ varPrefix: `${varPrefix}`,
142
+ description: `${hostname} service`
143
+ };
144
+ }
145
+ // Generate from port for localhost URLs
146
+ if (port) {
147
+ return {
148
+ name: `port-${port}`,
149
+ varPrefix: `PORT_${port}`,
150
+ description: `localhost:${port}`
151
+ };
152
+ }
153
+ return { name: 'unknown', varPrefix: 'UNKNOWN', description: 'Unknown Service' };
154
+ }
155
+ /**
156
+ * Prompt user for staging URLs for each unique service URL found in frontend apps
157
+ */
158
+ async function promptForStagingUrls(serviceUrls, existingStagingApiUrl) {
159
+ const mappings = [];
160
+ if (serviceUrls.size === 0) {
161
+ return mappings;
162
+ }
163
+ console.log('');
164
+ console.log(chalk_1.default.blue('=== Frontend Service URL Configuration ==='));
165
+ console.log(chalk_1.default.dim('Frontend apps have service URLs that need staging equivalents.'));
166
+ console.log(chalk_1.default.dim('This enables profiles like admin-quick to connect to staging backend.'));
167
+ console.log('');
168
+ // Sort by port number for consistent ordering
169
+ const sortedServices = Array.from(serviceUrls.entries()).sort((a, b) => {
170
+ const portA = parseInt(a[0].match(/:(\d+)/)?.[1] || '0');
171
+ const portB = parseInt(b[0].match(/:(\d+)/)?.[1] || '0');
172
+ return portA - portB;
173
+ });
174
+ for (const [baseUrl, { urls, vars }] of sortedServices) {
175
+ const serviceInfo = getServiceNameFromUrl(baseUrl);
176
+ // Show what URLs use this service
177
+ console.log(chalk_1.default.cyan(`${serviceInfo.description} (${baseUrl})`));
178
+ console.log(chalk_1.default.dim(` Used by: ${vars.slice(0, 3).join(', ')}${vars.length > 3 ? ` +${vars.length - 3} more` : ''}`));
179
+ // For gateway, suggest existing staging URL if available
180
+ let defaultUrl = '';
181
+ if ((serviceInfo.name === 'gateway' || baseUrl.includes(':3050')) && existingStagingApiUrl) {
182
+ defaultUrl = existingStagingApiUrl;
183
+ }
184
+ const stagingUrl = await prompts.input({
185
+ message: ` Staging URL (leave empty to skip):`,
186
+ default: defaultUrl,
187
+ });
188
+ mappings.push({
189
+ varName: `${serviceInfo.varPrefix}_URL`,
190
+ localUrl: baseUrl,
191
+ stagingUrl: stagingUrl || undefined,
192
+ description: serviceInfo.description,
193
+ });
194
+ }
195
+ return mappings;
196
+ }
197
+ /**
198
+ * Transform .env.genbox content to use expandable variables
199
+ */
200
+ function transformEnvWithVariables(envContent, mappings, frontendApps) {
201
+ if (mappings.length === 0) {
202
+ return envContent;
203
+ }
204
+ let result = envContent;
205
+ // Build GLOBAL section additions
206
+ const globalAdditions = [
207
+ '',
208
+ '# Service URL Configuration',
209
+ '# These expand based on profile: ${GATEWAY_URL} → LOCAL or STAGING value',
210
+ ];
211
+ for (const mapping of mappings) {
212
+ globalAdditions.push(`LOCAL_${mapping.varName}=${mapping.localUrl}`);
213
+ if (mapping.stagingUrl) {
214
+ globalAdditions.push(`STAGING_${mapping.varName}=${mapping.stagingUrl}`);
215
+ }
216
+ else {
217
+ globalAdditions.push(`# STAGING_${mapping.varName}=https://your-staging-url.com`);
218
+ }
219
+ }
220
+ globalAdditions.push('');
221
+ // Insert after GLOBAL section header and existing content
222
+ const globalInsertPoint = result.indexOf('# === GLOBAL ===');
223
+ if (globalInsertPoint !== -1) {
224
+ // Find the next section or end of GLOBAL
225
+ const nextSectionMatch = result.substring(globalInsertPoint + 20).match(/\n# === [^=]+ ===/);
226
+ const insertAt = nextSectionMatch
227
+ ? globalInsertPoint + 20 + nextSectionMatch.index
228
+ : result.length;
229
+ result = result.substring(0, insertAt) + globalAdditions.join('\n') + result.substring(insertAt);
230
+ }
231
+ // Replace localhost URLs with variable syntax in frontend app sections
232
+ // Parse sections again after modification
233
+ const lines = result.split('\n');
234
+ const transformedLines = [];
235
+ let currentSection = 'GLOBAL';
236
+ let inFrontendSection = false;
237
+ for (const line of lines) {
238
+ const sectionMatch = line.match(/^# === ([^=]+) ===$/);
239
+ if (sectionMatch) {
240
+ currentSection = sectionMatch[1].trim();
241
+ inFrontendSection = frontendApps.includes(currentSection);
242
+ transformedLines.push(line);
243
+ continue;
244
+ }
245
+ if (inFrontendSection && line.includes('http://localhost:')) {
246
+ // Check if this line matches any of our mappings
247
+ let transformedLine = line;
248
+ for (const mapping of mappings) {
249
+ if (line.includes(mapping.localUrl)) {
250
+ // Replace the full URL, preserving any path suffix
251
+ const urlPattern = new RegExp(mapping.localUrl.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '(/[^"\'\\s]*)?', 'g');
252
+ transformedLine = transformedLine.replace(urlPattern, (match, pathSuffix) => {
253
+ return `\${${mapping.varName}}${pathSuffix || ''}`;
254
+ });
255
+ }
256
+ }
257
+ transformedLines.push(transformedLine);
258
+ }
259
+ else {
260
+ transformedLines.push(line);
261
+ }
262
+ }
263
+ return transformedLines.join('\n');
264
+ }
54
265
  /**
55
266
  * Detect git repositories in app directories (for multi-repo workspaces)
56
267
  */
@@ -385,10 +596,12 @@ exports.initCommand = new commander_1.Command('init')
385
596
  console.log(chalk_1.default.dim(' 3. Select scope: "repo" (Full control of private repositories)'));
386
597
  console.log(chalk_1.default.dim(' 4. Generate and copy the token'));
387
598
  console.log('');
388
- const gitToken = await prompts.password({
599
+ let gitToken = await prompts.password({
389
600
  message: 'GitHub Personal Access Token (leave empty to skip):',
390
601
  });
391
602
  if (gitToken) {
603
+ // Strip any "GIT_TOKEN=" prefix if user pasted the whole line
604
+ gitToken = gitToken.replace(/^GIT_TOKEN=/i, '');
392
605
  envVarsToAdd['GIT_TOKEN'] = gitToken;
393
606
  console.log(chalk_1.default.green('✓ GIT_TOKEN will be added to .env.genbox'));
394
607
  }
@@ -412,10 +625,12 @@ exports.initCommand = new commander_1.Command('init')
412
625
  if (scan.git.type === 'https' && !nonInteractive) {
413
626
  console.log('');
414
627
  console.log(chalk_1.default.yellow('Private repositories require a GitHub token for cloning.'));
415
- const gitToken = await prompts.password({
628
+ let gitToken = await prompts.password({
416
629
  message: 'GitHub Personal Access Token (leave empty to skip):',
417
630
  });
418
631
  if (gitToken) {
632
+ // Strip any "GIT_TOKEN=" prefix if user pasted the whole line
633
+ gitToken = gitToken.replace(/^GIT_TOKEN=/i, '');
419
634
  envVarsToAdd['GIT_TOKEN'] = gitToken;
420
635
  console.log(chalk_1.default.green('✓ GIT_TOKEN will be added to .env.genbox'));
421
636
  }
@@ -464,10 +679,12 @@ exports.initCommand = new commander_1.Command('init')
464
679
  console.log(chalk_1.default.dim(' 3. Select scope: "repo" (Full control of private repositories)'));
465
680
  console.log(chalk_1.default.dim(' 4. Generate and copy the token'));
466
681
  console.log('');
467
- const gitToken = await prompts.password({
682
+ let gitToken = await prompts.password({
468
683
  message: 'GitHub Personal Access Token (leave empty to skip):',
469
684
  });
470
685
  if (gitToken) {
686
+ // Strip any "GIT_TOKEN=" prefix if user pasted the whole line
687
+ gitToken = gitToken.replace(/^GIT_TOKEN=/i, '');
471
688
  envVarsToAdd['GIT_TOKEN'] = gitToken;
472
689
  console.log(chalk_1.default.green('✓ GIT_TOKEN will be added to .env.genbox'));
473
690
  }
@@ -610,8 +827,19 @@ exports.initCommand = new commander_1.Command('init')
610
827
  }
611
828
  }
612
829
  }
830
+ // Load detected service URLs if using --from-scan
831
+ let detectedServiceUrls;
832
+ if (options.fromScan) {
833
+ const detectedPath = path_1.default.join(process.cwd(), '.genbox', 'detected.yaml');
834
+ try {
835
+ const content = fs_1.default.readFileSync(detectedPath, 'utf8');
836
+ const detectedConfig = yaml.load(content);
837
+ detectedServiceUrls = detectedConfig.service_urls;
838
+ }
839
+ catch { }
840
+ }
613
841
  // Generate .env.genbox
614
- await setupEnvFile(projectName, v4Config, nonInteractive, scan, isMultiRepo, envVarsToAdd, overwriteExisting);
842
+ await setupEnvFile(projectName, v4Config, nonInteractive, scan, isMultiRepo, envVarsToAdd, overwriteExisting, detectedServiceUrls);
615
843
  // Show warnings
616
844
  if (generated.warnings.length > 0) {
617
845
  console.log('');
@@ -935,7 +1163,7 @@ async function setupEnvironments(scan, config, isMultiRepo = false) {
935
1163
  /**
936
1164
  * Setup .env.genbox file with segregated app sections
937
1165
  */
938
- async function setupEnvFile(projectName, config, nonInteractive = false, scan, isMultiRepo = false, extraEnvVars = {}, overwriteExisting = false) {
1166
+ async function setupEnvFile(projectName, config, nonInteractive = false, scan, isMultiRepo = false, extraEnvVars = {}, overwriteExisting = false, detectedServiceUrls) {
939
1167
  const envPath = path_1.default.join(process.cwd(), ENV_FILENAME);
940
1168
  // If overwriting, delete existing file
941
1169
  if (fs_1.default.existsSync(envPath)) {
@@ -1048,6 +1276,52 @@ async function setupEnvFile(projectName, config, nonInteractive = false, scan, i
1048
1276
  }
1049
1277
  }
1050
1278
  }
1279
+ // Identify frontend apps for URL transformation
1280
+ const frontendApps = scan?.apps
1281
+ .filter(a => a.type === 'frontend')
1282
+ .map(a => a.name) || [];
1283
+ // Also get frontend apps from config if available
1284
+ if (config.apps) {
1285
+ for (const [name, app] of Object.entries(config.apps)) {
1286
+ if (app.type === 'frontend' && !frontendApps.includes(name)) {
1287
+ frontendApps.push(name);
1288
+ }
1289
+ }
1290
+ }
1291
+ if (frontendApps.length > 0 && !nonInteractive) {
1292
+ // Use service URLs from detected.yaml if available (preferred)
1293
+ // Otherwise fall back to scanning the collected env content
1294
+ let serviceUrls;
1295
+ if (detectedServiceUrls && detectedServiceUrls.length > 0) {
1296
+ // Convert detected service URLs to the Map format
1297
+ serviceUrls = new Map();
1298
+ for (const svc of detectedServiceUrls) {
1299
+ serviceUrls.set(svc.base_url, {
1300
+ urls: new Set([svc.base_url]),
1301
+ vars: svc.used_by,
1302
+ });
1303
+ }
1304
+ console.log('');
1305
+ console.log(chalk_1.default.dim(`Found ${detectedServiceUrls.length} service URL(s) from detected.yaml`));
1306
+ }
1307
+ else {
1308
+ // Fall back to extracting from collected env content
1309
+ serviceUrls = extractFrontendHttpUrls(segregatedContent, frontendApps);
1310
+ }
1311
+ if (serviceUrls.size > 0) {
1312
+ // Get existing staging API URL if configured
1313
+ const existingStagingApiUrl = extraEnvVars['STAGING_API_URL'] ||
1314
+ (config.environments?.staging?.urls?.api);
1315
+ // Prompt for staging equivalents
1316
+ const urlMappings = await promptForStagingUrls(serviceUrls, existingStagingApiUrl);
1317
+ // Transform content with expandable variables
1318
+ if (urlMappings.length > 0) {
1319
+ segregatedContent = transformEnvWithVariables(segregatedContent, urlMappings, frontendApps);
1320
+ console.log('');
1321
+ console.log(chalk_1.default.green(`✓ Configured ${urlMappings.length} service URL(s) for staging support`));
1322
+ }
1323
+ }
1324
+ }
1051
1325
  // Add END marker
1052
1326
  segregatedContent += `# === END ===\n`;
1053
1327
  // Write the file
@@ -12,6 +12,7 @@
12
12
  * genbox scan # Output to .genbox/detected.yaml
13
13
  * genbox scan --stdout # Output to stdout
14
14
  * genbox scan --json # Output as JSON
15
+ * genbox scan -i # Interactive mode (select apps)
15
16
  */
16
17
  var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
17
18
  if (k2 === undefined) k2 = k;
@@ -52,6 +53,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
52
53
  Object.defineProperty(exports, "__esModule", { value: true });
53
54
  exports.scanCommand = void 0;
54
55
  const commander_1 = require("commander");
56
+ const prompts = __importStar(require("@inquirer/prompts"));
55
57
  const chalk_1 = __importDefault(require("chalk"));
56
58
  const fs = __importStar(require("fs"));
57
59
  const path = __importStar(require("path"));
@@ -109,9 +111,11 @@ exports.scanCommand = new commander_1.Command('scan')
109
111
  .option('--json', 'Output as JSON instead of YAML')
110
112
  .option('--no-infra', 'Skip infrastructure detection (docker-compose)')
111
113
  .option('--no-scripts', 'Skip script detection')
114
+ .option('-i, --interactive', 'Interactive mode - select apps before writing')
112
115
  .option('-e, --exclude <patterns>', 'Comma-separated patterns to exclude', '')
113
116
  .action(async (options) => {
114
117
  const cwd = process.cwd();
118
+ const isInteractive = options.interactive && !options.stdout && process.stdin.isTTY;
115
119
  console.log(chalk_1.default.cyan('\n🔍 Scanning project...\n'));
116
120
  try {
117
121
  // Run the scanner
@@ -122,7 +126,21 @@ exports.scanCommand = new commander_1.Command('scan')
122
126
  skipScripts: !options.scripts,
123
127
  });
124
128
  // Convert scan result to DetectedConfig format
125
- const detected = convertScanToDetected(scan, cwd);
129
+ let detected = convertScanToDetected(scan, cwd);
130
+ // Interactive mode: let user select apps
131
+ if (isInteractive && Object.keys(detected.apps).length > 0) {
132
+ detected = await interactiveAppSelection(detected);
133
+ }
134
+ // Scan env files for service URLs (only for selected frontend apps)
135
+ const frontendApps = Object.entries(detected.apps)
136
+ .filter(([, app]) => app.type === 'frontend')
137
+ .map(([name]) => name);
138
+ if (frontendApps.length > 0) {
139
+ const serviceUrls = scanEnvFilesForUrls(detected.apps, cwd);
140
+ if (serviceUrls.length > 0) {
141
+ detected.service_urls = serviceUrls;
142
+ }
143
+ }
126
144
  // Output
127
145
  if (options.stdout) {
128
146
  outputToStdout(detected, options.json);
@@ -133,10 +151,156 @@ exports.scanCommand = new commander_1.Command('scan')
133
151
  }
134
152
  }
135
153
  catch (error) {
154
+ if (error.name === 'ExitPromptError' || error.message?.includes('force closed')) {
155
+ console.log('\n' + chalk_1.default.dim('Cancelled.'));
156
+ process.exit(0);
157
+ }
136
158
  console.error(chalk_1.default.red('Scan failed:'), error);
137
159
  process.exit(1);
138
160
  }
139
161
  });
162
+ /**
163
+ * Interactive app selection
164
+ */
165
+ async function interactiveAppSelection(detected) {
166
+ const appEntries = Object.entries(detected.apps);
167
+ console.log(chalk_1.default.blue('=== Detected Apps ===\n'));
168
+ // Show detected apps
169
+ for (const [name, app] of appEntries) {
170
+ const parts = [
171
+ chalk_1.default.cyan(name),
172
+ app.type ? `(${app.type})` : '',
173
+ app.framework ? `[${app.framework}]` : '',
174
+ app.port ? `port:${app.port}` : '',
175
+ ].filter(Boolean);
176
+ console.log(` ${parts.join(' ')}`);
177
+ if (app.git) {
178
+ console.log(chalk_1.default.dim(` └─ ${app.git.remote}`));
179
+ }
180
+ }
181
+ console.log();
182
+ // Let user select which apps to include
183
+ const choices = appEntries.map(([name, app]) => ({
184
+ name: `${name} (${app.type || 'unknown'}${app.framework ? `, ${app.framework}` : ''})`,
185
+ value: name,
186
+ checked: app.type !== 'library', // Default: include non-libraries
187
+ }));
188
+ const selectedApps = await prompts.checkbox({
189
+ message: 'Select apps to include in detected.yaml:',
190
+ choices,
191
+ });
192
+ // Filter apps to only selected ones
193
+ const filteredApps = {};
194
+ for (const appName of selectedApps) {
195
+ filteredApps[appName] = detected.apps[appName];
196
+ }
197
+ return {
198
+ ...detected,
199
+ apps: filteredApps,
200
+ };
201
+ }
202
+ /**
203
+ * Scan env files in app directories for service URLs
204
+ */
205
+ function scanEnvFilesForUrls(apps, rootDir) {
206
+ const serviceUrls = new Map();
207
+ const envPatterns = ['.env', '.env.local', '.env.development'];
208
+ for (const [appName, app] of Object.entries(apps)) {
209
+ // Only scan frontend apps
210
+ if (app.type !== 'frontend')
211
+ continue;
212
+ const appDir = path.join(rootDir, app.path);
213
+ // Find env file
214
+ let envContent;
215
+ let envSource;
216
+ for (const pattern of envPatterns) {
217
+ const envPath = path.join(appDir, pattern);
218
+ if (fs.existsSync(envPath)) {
219
+ envContent = fs.readFileSync(envPath, 'utf8');
220
+ envSource = pattern;
221
+ break;
222
+ }
223
+ }
224
+ if (!envContent)
225
+ continue;
226
+ // Find all HTTP URLs
227
+ const urlRegex = /^([A-Z_][A-Z0-9_]*)=["']?(https?:\/\/[a-zA-Z0-9_.-]+(?::\d+)?[^"'\s]*)["']?/gm;
228
+ let match;
229
+ while ((match = urlRegex.exec(envContent)) !== null) {
230
+ const varName = match[1];
231
+ const fullUrl = match[2];
232
+ // Extract hostname
233
+ const hostMatch = fullUrl.match(/^https?:\/\/([a-zA-Z0-9_.-]+)/);
234
+ if (!hostMatch)
235
+ continue;
236
+ const hostname = hostMatch[1];
237
+ // Only include local URLs (localhost, Docker internal names, IPs)
238
+ const isLocalUrl = hostname === 'localhost' ||
239
+ !hostname.includes('.') ||
240
+ /^\d+\.\d+\.\d+\.\d+$/.test(hostname);
241
+ if (!isLocalUrl)
242
+ continue;
243
+ // Extract base URL
244
+ const baseMatch = fullUrl.match(/^(https?:\/\/[a-zA-Z0-9_.-]+(?::\d+)?)/);
245
+ if (!baseMatch)
246
+ continue;
247
+ const baseUrl = baseMatch[1];
248
+ // Add to map
249
+ if (!serviceUrls.has(baseUrl)) {
250
+ serviceUrls.set(baseUrl, { vars: new Set(), apps: new Set() });
251
+ }
252
+ serviceUrls.get(baseUrl).vars.add(varName);
253
+ serviceUrls.get(baseUrl).apps.add(appName);
254
+ }
255
+ }
256
+ // Convert to DetectedServiceUrl array
257
+ const result = [];
258
+ for (const [baseUrl, { vars, apps: appNames }] of serviceUrls) {
259
+ const serviceInfo = getServiceInfoFromUrl(baseUrl);
260
+ result.push({
261
+ base_url: baseUrl,
262
+ var_name: serviceInfo.varName,
263
+ description: serviceInfo.description,
264
+ used_by: Array.from(vars),
265
+ apps: Array.from(appNames),
266
+ source: 'env files',
267
+ });
268
+ }
269
+ // Sort by port for consistent output
270
+ result.sort((a, b) => {
271
+ const portA = parseInt(a.base_url.match(/:(\d+)/)?.[1] || '0');
272
+ const portB = parseInt(b.base_url.match(/:(\d+)/)?.[1] || '0');
273
+ return portA - portB;
274
+ });
275
+ return result;
276
+ }
277
+ /**
278
+ * Get service info from URL
279
+ */
280
+ function getServiceInfoFromUrl(baseUrl) {
281
+ const urlMatch = baseUrl.match(/^https?:\/\/([a-zA-Z0-9_.-]+)(?::(\d+))?/);
282
+ if (!urlMatch) {
283
+ return { varName: 'UNKNOWN_URL', description: 'Unknown service' };
284
+ }
285
+ const hostname = urlMatch[1];
286
+ const port = urlMatch[2] ? parseInt(urlMatch[2]) : undefined;
287
+ // Generate from hostname if not localhost
288
+ if (hostname !== 'localhost') {
289
+ const varName = hostname.toUpperCase().replace(/-/g, '_') + '_URL';
290
+ return {
291
+ varName,
292
+ description: `${hostname} service`,
293
+ };
294
+ }
295
+ // Generate from port for localhost
296
+ if (port) {
297
+ return {
298
+ varName: `PORT_${port}_URL`,
299
+ description: `localhost:${port}`,
300
+ };
301
+ }
302
+ return { varName: 'LOCALHOST_URL', description: 'localhost' };
303
+ }
140
304
  /**
141
305
  * Convert ProjectScan to DetectedConfig
142
306
  */
@@ -408,6 +572,15 @@ function showSummary(detected) {
408
572
  console.log(chalk_1.default.dim(` ${git.remote}`));
409
573
  }
410
574
  }
575
+ // Service URLs (for staging URL configuration)
576
+ if (detected.service_urls && detected.service_urls.length > 0) {
577
+ console.log(`\n Service URLs (${detected.service_urls.length}):`);
578
+ for (const svc of detected.service_urls) {
579
+ console.log(` ${chalk_1.default.cyan(svc.var_name)}: ${svc.base_url}`);
580
+ 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` : ''}`));
581
+ }
582
+ console.log(chalk_1.default.dim('\n These URLs will need staging equivalents in init.'));
583
+ }
411
584
  console.log(chalk_1.default.bold('\n📝 Next steps:\n'));
412
585
  console.log(' 1. Review the detected configuration in .genbox/detected.yaml');
413
586
  console.log(' 2. Run ' + chalk_1.default.cyan('genbox init --from-scan') + ' to create genbox.yaml');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "genbox",
3
- "version": "1.0.15",
3
+ "version": "1.0.16",
4
4
  "description": "Genbox CLI - AI-Powered Development Environments",
5
5
  "main": "dist/index.js",
6
6
  "bin": {