genbox 1.0.15 → 1.0.17

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
  */
@@ -154,6 +365,81 @@ function findAppEnvFiles(apps, rootDir) {
154
365
  }
155
366
  return envFiles;
156
367
  }
368
+ /**
369
+ * Read existing values from .env.genbox file
370
+ */
371
+ function readExistingEnvGenbox() {
372
+ const envPath = path_1.default.join(process.cwd(), ENV_FILENAME);
373
+ const values = {};
374
+ if (!fs_1.default.existsSync(envPath)) {
375
+ return values;
376
+ }
377
+ try {
378
+ const content = fs_1.default.readFileSync(envPath, 'utf8');
379
+ for (const line of content.split('\n')) {
380
+ // Skip comments and empty lines
381
+ const trimmed = line.trim();
382
+ if (!trimmed || trimmed.startsWith('#'))
383
+ continue;
384
+ // Parse KEY=value
385
+ const match = trimmed.match(/^([A-Z_][A-Z0-9_]*)=(.*)$/);
386
+ if (match) {
387
+ let value = match[2].trim();
388
+ // Remove quotes if present
389
+ if ((value.startsWith('"') && value.endsWith('"')) ||
390
+ (value.startsWith("'") && value.endsWith("'"))) {
391
+ value = value.slice(1, -1);
392
+ }
393
+ values[match[1]] = value;
394
+ }
395
+ }
396
+ }
397
+ catch {
398
+ // Ignore read errors
399
+ }
400
+ return values;
401
+ }
402
+ /**
403
+ * Mask a secret value for display (show first 4 and last 4 chars)
404
+ */
405
+ function maskSecret(value) {
406
+ if (value.length <= 8) {
407
+ return '*'.repeat(value.length);
408
+ }
409
+ return value.slice(0, 4) + '*'.repeat(value.length - 8) + value.slice(-4);
410
+ }
411
+ /**
412
+ * Prompt for a secret with existing value support
413
+ */
414
+ async function promptForSecret(message, existingValue, options = {}) {
415
+ if (existingValue) {
416
+ console.log(chalk_1.default.dim(` Found existing value: ${maskSecret(existingValue)}`));
417
+ const useExisting = await prompts.confirm({
418
+ message: 'Use existing value?',
419
+ default: true,
420
+ });
421
+ if (useExisting) {
422
+ return existingValue;
423
+ }
424
+ }
425
+ if (options.showInstructions) {
426
+ console.log('');
427
+ console.log(chalk_1.default.dim(' To create a token:'));
428
+ console.log(chalk_1.default.dim(' 1. Go to https://github.com/settings/tokens'));
429
+ console.log(chalk_1.default.dim(' 2. Click "Generate new token" → "Classic"'));
430
+ console.log(chalk_1.default.dim(' 3. Select scope: "repo" (Full control of private repositories)'));
431
+ console.log(chalk_1.default.dim(' 4. Generate and copy the token'));
432
+ console.log('');
433
+ }
434
+ let value = await prompts.password({
435
+ message,
436
+ });
437
+ if (value) {
438
+ // Strip any "KEY=" prefix if user pasted the whole line
439
+ value = value.replace(/^[A-Z_]+=/, '');
440
+ }
441
+ return value || undefined;
442
+ }
157
443
  exports.initCommand = new commander_1.Command('init')
158
444
  .description('Initialize a new Genbox configuration')
159
445
  .option('--v2', 'Use legacy v2 format (single-app only)')
@@ -186,6 +472,8 @@ exports.initCommand = new commander_1.Command('init')
186
472
  }
187
473
  console.log(chalk_1.default.blue('Initializing Genbox...'));
188
474
  console.log('');
475
+ // Read existing .env.genbox values (for defaults when overwriting)
476
+ const existingEnvValues = readExistingEnvGenbox();
189
477
  // Track env vars to add to .env.genbox
190
478
  const envVarsToAdd = {};
191
479
  // Get initial exclusions from CLI options only
@@ -378,16 +666,7 @@ exports.initCommand = new commander_1.Command('init')
378
666
  if (hasHttpsRepos && !nonInteractive) {
379
667
  console.log('');
380
668
  console.log(chalk_1.default.yellow('Private repositories require a GitHub token for cloning.'));
381
- console.log('');
382
- console.log(chalk_1.default.dim(' To create a token:'));
383
- console.log(chalk_1.default.dim(' 1. Go to https://github.com/settings/tokens'));
384
- console.log(chalk_1.default.dim(' 2. Click "Generate new token" → "Classic"'));
385
- console.log(chalk_1.default.dim(' 3. Select scope: "repo" (Full control of private repositories)'));
386
- console.log(chalk_1.default.dim(' 4. Generate and copy the token'));
387
- console.log('');
388
- const gitToken = await prompts.password({
389
- message: 'GitHub Personal Access Token (leave empty to skip):',
390
- });
669
+ const gitToken = await promptForSecret('GitHub Personal Access Token (leave empty to skip):', existingEnvValues['GIT_TOKEN'], { showInstructions: !existingEnvValues['GIT_TOKEN'] });
391
670
  if (gitToken) {
392
671
  envVarsToAdd['GIT_TOKEN'] = gitToken;
393
672
  console.log(chalk_1.default.green('✓ GIT_TOKEN will be added to .env.genbox'));
@@ -412,9 +691,7 @@ exports.initCommand = new commander_1.Command('init')
412
691
  if (scan.git.type === 'https' && !nonInteractive) {
413
692
  console.log('');
414
693
  console.log(chalk_1.default.yellow('Private repositories require a GitHub token for cloning.'));
415
- const gitToken = await prompts.password({
416
- message: 'GitHub Personal Access Token (leave empty to skip):',
417
- });
694
+ const gitToken = await promptForSecret('GitHub Personal Access Token (leave empty to skip):', existingEnvValues['GIT_TOKEN'], { showInstructions: !existingEnvValues['GIT_TOKEN'] });
418
695
  if (gitToken) {
419
696
  envVarsToAdd['GIT_TOKEN'] = gitToken;
420
697
  console.log(chalk_1.default.green('✓ GIT_TOKEN will be added to .env.genbox'));
@@ -457,16 +734,7 @@ exports.initCommand = new commander_1.Command('init')
457
734
  if (hasHttpsRepos) {
458
735
  console.log('');
459
736
  console.log(chalk_1.default.yellow('Private repositories require a GitHub token for cloning.'));
460
- console.log('');
461
- console.log(chalk_1.default.dim(' To create a token:'));
462
- console.log(chalk_1.default.dim(' 1. Go to https://github.com/settings/tokens'));
463
- console.log(chalk_1.default.dim(' 2. Click "Generate new token" → "Classic"'));
464
- console.log(chalk_1.default.dim(' 3. Select scope: "repo" (Full control of private repositories)'));
465
- console.log(chalk_1.default.dim(' 4. Generate and copy the token'));
466
- console.log('');
467
- const gitToken = await prompts.password({
468
- message: 'GitHub Personal Access Token (leave empty to skip):',
469
- });
737
+ const gitToken = await promptForSecret('GitHub Personal Access Token (leave empty to skip):', existingEnvValues['GIT_TOKEN'], { showInstructions: !existingEnvValues['GIT_TOKEN'] });
470
738
  if (gitToken) {
471
739
  envVarsToAdd['GIT_TOKEN'] = gitToken;
472
740
  console.log(chalk_1.default.green('✓ GIT_TOKEN will be added to .env.genbox'));
@@ -539,7 +807,7 @@ exports.initCommand = new commander_1.Command('init')
539
807
  // Environment configuration (skip only in non-interactive mode)
540
808
  // For --from-scan, we still want to prompt for environments since they're required for genbox to work
541
809
  if (!nonInteractive) {
542
- const envConfig = await setupEnvironments(scan, v4Config, isMultiRepo);
810
+ const envConfig = await setupEnvironments(scan, v4Config, isMultiRepo, existingEnvValues);
543
811
  if (envConfig) {
544
812
  v4Config.environments = envConfig;
545
813
  }
@@ -610,8 +878,19 @@ exports.initCommand = new commander_1.Command('init')
610
878
  }
611
879
  }
612
880
  }
881
+ // Load detected service URLs if using --from-scan
882
+ let detectedServiceUrls;
883
+ if (options.fromScan) {
884
+ const detectedPath = path_1.default.join(process.cwd(), '.genbox', 'detected.yaml');
885
+ try {
886
+ const content = fs_1.default.readFileSync(detectedPath, 'utf8');
887
+ const detectedConfig = yaml.load(content);
888
+ detectedServiceUrls = detectedConfig.service_urls;
889
+ }
890
+ catch { }
891
+ }
613
892
  // Generate .env.genbox
614
- await setupEnvFile(projectName, v4Config, nonInteractive, scan, isMultiRepo, envVarsToAdd, overwriteExisting);
893
+ await setupEnvFile(projectName, v4Config, nonInteractive, scan, isMultiRepo, envVarsToAdd, overwriteExisting, detectedServiceUrls);
615
894
  // Show warnings
616
895
  if (generated.warnings.length > 0) {
617
896
  console.log('');
@@ -804,7 +1083,7 @@ async function setupGitAuth(gitInfo, projectName) {
804
1083
  /**
805
1084
  * Setup staging/production environments (v4 format)
806
1085
  */
807
- async function setupEnvironments(scan, config, isMultiRepo = false) {
1086
+ async function setupEnvironments(scan, config, isMultiRepo = false, existingEnvValues = {}) {
808
1087
  const setupEnvs = await prompts.confirm({
809
1088
  message: 'Configure staging/production environments?',
810
1089
  default: true,
@@ -818,6 +1097,9 @@ async function setupEnvironments(scan, config, isMultiRepo = false) {
818
1097
  console.log(chalk_1.default.dim('Actual secrets go in .env.genbox'));
819
1098
  console.log('');
820
1099
  const environments = {};
1100
+ // Get existing staging API URL if available
1101
+ const existingStagingApiUrl = existingEnvValues['STAGING_API_URL'];
1102
+ const existingProductionApiUrl = existingEnvValues['PRODUCTION_API_URL'] || existingEnvValues['PROD_API_URL'];
821
1103
  if (isMultiRepo) {
822
1104
  // For multi-repo: configure API URLs per backend app
823
1105
  const backendApps = scan.apps.filter(a => a.type === 'backend' || a.type === 'api');
@@ -825,6 +1107,20 @@ async function setupEnvironments(scan, config, isMultiRepo = false) {
825
1107
  console.log(chalk_1.default.dim('Configure staging API URLs for each backend service:'));
826
1108
  const urls = {};
827
1109
  for (const app of backendApps) {
1110
+ // Check for existing app-specific URL or use general staging URL for 'api' app
1111
+ const existingUrl = existingEnvValues[`STAGING_${app.name.toUpperCase()}_URL`] ||
1112
+ (app.name === 'api' ? existingStagingApiUrl : '');
1113
+ if (existingUrl) {
1114
+ console.log(chalk_1.default.dim(` Found existing value for ${app.name}: ${existingUrl}`));
1115
+ const useExisting = await prompts.confirm({
1116
+ message: ` Use existing ${app.name} staging URL?`,
1117
+ default: true,
1118
+ });
1119
+ if (useExisting) {
1120
+ urls[app.name] = existingUrl;
1121
+ continue;
1122
+ }
1123
+ }
828
1124
  const url = await prompts.input({
829
1125
  message: ` ${app.name} staging URL (leave empty to skip):`,
830
1126
  default: '',
@@ -845,10 +1141,23 @@ async function setupEnvironments(scan, config, isMultiRepo = false) {
845
1141
  }
846
1142
  else {
847
1143
  // No backend apps, just ask for a single URL
848
- const stagingApiUrl = await prompts.input({
849
- message: 'Staging API URL (leave empty to skip):',
850
- default: '',
851
- });
1144
+ let stagingApiUrl = '';
1145
+ if (existingStagingApiUrl) {
1146
+ console.log(chalk_1.default.dim(` Found existing value: ${existingStagingApiUrl}`));
1147
+ const useExisting = await prompts.confirm({
1148
+ message: 'Use existing staging API URL?',
1149
+ default: true,
1150
+ });
1151
+ if (useExisting) {
1152
+ stagingApiUrl = existingStagingApiUrl;
1153
+ }
1154
+ }
1155
+ if (!stagingApiUrl) {
1156
+ stagingApiUrl = await prompts.input({
1157
+ message: 'Staging API URL (leave empty to skip):',
1158
+ default: '',
1159
+ });
1160
+ }
852
1161
  if (stagingApiUrl) {
853
1162
  environments.staging = {
854
1163
  description: 'Staging environment',
@@ -863,10 +1172,23 @@ async function setupEnvironments(scan, config, isMultiRepo = false) {
863
1172
  }
864
1173
  else {
865
1174
  // Single repo: simple single URL
866
- const stagingApiUrl = await prompts.input({
867
- message: 'Staging API URL (leave empty to skip):',
868
- default: '',
869
- });
1175
+ let stagingApiUrl = '';
1176
+ if (existingStagingApiUrl) {
1177
+ console.log(chalk_1.default.dim(` Found existing value: ${existingStagingApiUrl}`));
1178
+ const useExisting = await prompts.confirm({
1179
+ message: 'Use existing staging API URL?',
1180
+ default: true,
1181
+ });
1182
+ if (useExisting) {
1183
+ stagingApiUrl = existingStagingApiUrl;
1184
+ }
1185
+ }
1186
+ if (!stagingApiUrl) {
1187
+ stagingApiUrl = await prompts.input({
1188
+ message: 'Staging API URL (leave empty to skip):',
1189
+ default: '',
1190
+ });
1191
+ }
870
1192
  if (stagingApiUrl) {
871
1193
  environments.staging = {
872
1194
  description: 'Staging environment',
@@ -889,6 +1211,21 @@ async function setupEnvironments(scan, config, isMultiRepo = false) {
889
1211
  console.log(chalk_1.default.dim('Configure production API URLs for each backend service:'));
890
1212
  const prodUrls = {};
891
1213
  for (const app of backendApps) {
1214
+ // Check for existing app-specific URL or use general production URL for 'api' app
1215
+ const existingUrl = existingEnvValues[`PRODUCTION_${app.name.toUpperCase()}_URL`] ||
1216
+ existingEnvValues[`PROD_${app.name.toUpperCase()}_URL`] ||
1217
+ (app.name === 'api' ? existingProductionApiUrl : '');
1218
+ if (existingUrl) {
1219
+ console.log(chalk_1.default.dim(` Found existing value for ${app.name}: ${existingUrl}`));
1220
+ const useExisting = await prompts.confirm({
1221
+ message: ` Use existing ${app.name} production URL?`,
1222
+ default: true,
1223
+ });
1224
+ if (useExisting) {
1225
+ prodUrls[app.name] = existingUrl;
1226
+ continue;
1227
+ }
1228
+ }
892
1229
  const url = await prompts.input({
893
1230
  message: ` ${app.name} production URL:`,
894
1231
  default: '',
@@ -911,10 +1248,23 @@ async function setupEnvironments(scan, config, isMultiRepo = false) {
911
1248
  }
912
1249
  }
913
1250
  else {
914
- const prodApiUrl = await prompts.input({
915
- message: 'Production API URL:',
916
- default: '',
917
- });
1251
+ let prodApiUrl = '';
1252
+ if (existingProductionApiUrl) {
1253
+ console.log(chalk_1.default.dim(` Found existing value: ${existingProductionApiUrl}`));
1254
+ const useExisting = await prompts.confirm({
1255
+ message: 'Use existing production API URL?',
1256
+ default: true,
1257
+ });
1258
+ if (useExisting) {
1259
+ prodApiUrl = existingProductionApiUrl;
1260
+ }
1261
+ }
1262
+ if (!prodApiUrl) {
1263
+ prodApiUrl = await prompts.input({
1264
+ message: 'Production API URL:',
1265
+ default: '',
1266
+ });
1267
+ }
918
1268
  if (prodApiUrl) {
919
1269
  environments.production = {
920
1270
  description: 'Production (use with caution)',
@@ -935,7 +1285,7 @@ async function setupEnvironments(scan, config, isMultiRepo = false) {
935
1285
  /**
936
1286
  * Setup .env.genbox file with segregated app sections
937
1287
  */
938
- async function setupEnvFile(projectName, config, nonInteractive = false, scan, isMultiRepo = false, extraEnvVars = {}, overwriteExisting = false) {
1288
+ async function setupEnvFile(projectName, config, nonInteractive = false, scan, isMultiRepo = false, extraEnvVars = {}, overwriteExisting = false, detectedServiceUrls) {
939
1289
  const envPath = path_1.default.join(process.cwd(), ENV_FILENAME);
940
1290
  // If overwriting, delete existing file
941
1291
  if (fs_1.default.existsSync(envPath)) {
@@ -1048,6 +1398,52 @@ async function setupEnvFile(projectName, config, nonInteractive = false, scan, i
1048
1398
  }
1049
1399
  }
1050
1400
  }
1401
+ // Identify frontend apps for URL transformation
1402
+ const frontendApps = scan?.apps
1403
+ .filter(a => a.type === 'frontend')
1404
+ .map(a => a.name) || [];
1405
+ // Also get frontend apps from config if available
1406
+ if (config.apps) {
1407
+ for (const [name, app] of Object.entries(config.apps)) {
1408
+ if (app.type === 'frontend' && !frontendApps.includes(name)) {
1409
+ frontendApps.push(name);
1410
+ }
1411
+ }
1412
+ }
1413
+ if (frontendApps.length > 0 && !nonInteractive) {
1414
+ // Use service URLs from detected.yaml if available (preferred)
1415
+ // Otherwise fall back to scanning the collected env content
1416
+ let serviceUrls;
1417
+ if (detectedServiceUrls && detectedServiceUrls.length > 0) {
1418
+ // Convert detected service URLs to the Map format
1419
+ serviceUrls = new Map();
1420
+ for (const svc of detectedServiceUrls) {
1421
+ serviceUrls.set(svc.base_url, {
1422
+ urls: new Set([svc.base_url]),
1423
+ vars: svc.used_by,
1424
+ });
1425
+ }
1426
+ console.log('');
1427
+ console.log(chalk_1.default.dim(`Found ${detectedServiceUrls.length} service URL(s) from detected.yaml`));
1428
+ }
1429
+ else {
1430
+ // Fall back to extracting from collected env content
1431
+ serviceUrls = extractFrontendHttpUrls(segregatedContent, frontendApps);
1432
+ }
1433
+ if (serviceUrls.size > 0) {
1434
+ // Get existing staging API URL if configured
1435
+ const existingStagingApiUrl = extraEnvVars['STAGING_API_URL'] ||
1436
+ (config.environments?.staging?.urls?.api);
1437
+ // Prompt for staging equivalents
1438
+ const urlMappings = await promptForStagingUrls(serviceUrls, existingStagingApiUrl);
1439
+ // Transform content with expandable variables
1440
+ if (urlMappings.length > 0) {
1441
+ segregatedContent = transformEnvWithVariables(segregatedContent, urlMappings, frontendApps);
1442
+ console.log('');
1443
+ console.log(chalk_1.default.green(`✓ Configured ${urlMappings.length} service URL(s) for staging support`));
1444
+ }
1445
+ }
1446
+ }
1051
1447
  // Add END marker
1052
1448
  segregatedContent += `# === END ===\n`;
1053
1449
  // 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, scripts, infrastructure
131
+ if (isInteractive) {
132
+ detected = await interactiveSelection(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,214 @@ 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 and script selection
164
+ */
165
+ async function interactiveSelection(detected) {
166
+ let result = { ...detected };
167
+ // === App Selection ===
168
+ const appEntries = Object.entries(detected.apps);
169
+ if (appEntries.length > 0) {
170
+ console.log(chalk_1.default.blue('=== Detected Apps ===\n'));
171
+ // Show detected apps
172
+ for (const [name, app] of appEntries) {
173
+ const parts = [
174
+ chalk_1.default.cyan(name),
175
+ app.type ? `(${app.type})` : '',
176
+ app.framework ? `[${app.framework}]` : '',
177
+ app.port ? `port:${app.port}` : '',
178
+ ].filter(Boolean);
179
+ console.log(` ${parts.join(' ')}`);
180
+ if (app.git) {
181
+ console.log(chalk_1.default.dim(` └─ ${app.git.remote}`));
182
+ }
183
+ }
184
+ console.log();
185
+ // Let user select which apps to include
186
+ const appChoices = appEntries.map(([name, app]) => ({
187
+ name: `${name} (${app.type || 'unknown'}${app.framework ? `, ${app.framework}` : ''})`,
188
+ value: name,
189
+ checked: app.type !== 'library', // Default: include non-libraries
190
+ }));
191
+ const selectedApps = await prompts.checkbox({
192
+ message: 'Select apps to include:',
193
+ choices: appChoices,
194
+ });
195
+ // Filter apps to only selected ones
196
+ const filteredApps = {};
197
+ for (const appName of selectedApps) {
198
+ filteredApps[appName] = detected.apps[appName];
199
+ }
200
+ result.apps = filteredApps;
201
+ }
202
+ // === Script Selection ===
203
+ if (detected.scripts && detected.scripts.length > 0) {
204
+ console.log('');
205
+ console.log(chalk_1.default.blue('=== Detected Scripts ===\n'));
206
+ // Group scripts by directory for display
207
+ const scriptsByDir = new Map();
208
+ for (const script of detected.scripts) {
209
+ const dir = script.path.includes('/') ? script.path.split('/')[0] : '(root)';
210
+ const existing = scriptsByDir.get(dir) || [];
211
+ existing.push(script);
212
+ scriptsByDir.set(dir, existing);
213
+ }
214
+ // Show grouped scripts
215
+ for (const [dir, scripts] of scriptsByDir) {
216
+ console.log(chalk_1.default.dim(` ${dir}/ (${scripts.length} scripts)`));
217
+ for (const script of scripts.slice(0, 3)) {
218
+ console.log(` ${chalk_1.default.cyan(script.name)} (${script.stage})`);
219
+ }
220
+ if (scripts.length > 3) {
221
+ console.log(chalk_1.default.dim(` ... and ${scripts.length - 3} more`));
222
+ }
223
+ }
224
+ console.log();
225
+ // Let user select which scripts to include
226
+ const scriptChoices = detected.scripts.map(s => ({
227
+ name: `${s.path} (${s.stage})`,
228
+ value: s.path,
229
+ checked: s.path.startsWith('scripts/'), // Default: include scripts/ directory
230
+ }));
231
+ const selectedScripts = await prompts.checkbox({
232
+ message: 'Select scripts to include:',
233
+ choices: scriptChoices,
234
+ });
235
+ // Filter scripts to only selected ones
236
+ result.scripts = detected.scripts.filter(s => selectedScripts.includes(s.path));
237
+ }
238
+ // === Infrastructure Selection ===
239
+ if (detected.infrastructure && detected.infrastructure.length > 0) {
240
+ console.log('');
241
+ console.log(chalk_1.default.blue('=== Detected Infrastructure ===\n'));
242
+ for (const infra of detected.infrastructure) {
243
+ console.log(` ${chalk_1.default.cyan(infra.name)}: ${infra.type} (${infra.image})`);
244
+ }
245
+ console.log();
246
+ const infraChoices = detected.infrastructure.map(i => ({
247
+ name: `${i.name} (${i.type}, ${i.image})`,
248
+ value: i.name,
249
+ checked: true, // Default: include all
250
+ }));
251
+ const selectedInfra = await prompts.checkbox({
252
+ message: 'Select infrastructure to include:',
253
+ choices: infraChoices,
254
+ });
255
+ // Filter infrastructure to only selected ones
256
+ result.infrastructure = detected.infrastructure.filter(i => selectedInfra.includes(i.name));
257
+ }
258
+ return result;
259
+ }
260
+ /**
261
+ * Scan env files in app directories for service URLs
262
+ */
263
+ function scanEnvFilesForUrls(apps, rootDir) {
264
+ const serviceUrls = new Map();
265
+ const envPatterns = ['.env', '.env.local', '.env.development'];
266
+ for (const [appName, app] of Object.entries(apps)) {
267
+ // Only scan frontend apps
268
+ if (app.type !== 'frontend')
269
+ continue;
270
+ const appDir = path.join(rootDir, app.path);
271
+ // Find env file
272
+ let envContent;
273
+ let envSource;
274
+ for (const pattern of envPatterns) {
275
+ const envPath = path.join(appDir, pattern);
276
+ if (fs.existsSync(envPath)) {
277
+ envContent = fs.readFileSync(envPath, 'utf8');
278
+ envSource = pattern;
279
+ break;
280
+ }
281
+ }
282
+ if (!envContent)
283
+ continue;
284
+ // Find all HTTP URLs
285
+ const urlRegex = /^([A-Z_][A-Z0-9_]*)=["']?(https?:\/\/[a-zA-Z0-9_.-]+(?::\d+)?[^"'\s]*)["']?/gm;
286
+ let match;
287
+ while ((match = urlRegex.exec(envContent)) !== null) {
288
+ const varName = match[1];
289
+ const fullUrl = match[2];
290
+ // Extract hostname
291
+ const hostMatch = fullUrl.match(/^https?:\/\/([a-zA-Z0-9_.-]+)/);
292
+ if (!hostMatch)
293
+ continue;
294
+ const hostname = hostMatch[1];
295
+ // Only include local URLs (localhost, Docker internal names, IPs)
296
+ const isLocalUrl = hostname === 'localhost' ||
297
+ !hostname.includes('.') ||
298
+ /^\d+\.\d+\.\d+\.\d+$/.test(hostname);
299
+ if (!isLocalUrl)
300
+ continue;
301
+ // Extract base URL
302
+ const baseMatch = fullUrl.match(/^(https?:\/\/[a-zA-Z0-9_.-]+(?::\d+)?)/);
303
+ if (!baseMatch)
304
+ continue;
305
+ const baseUrl = baseMatch[1];
306
+ // Add to map
307
+ if (!serviceUrls.has(baseUrl)) {
308
+ serviceUrls.set(baseUrl, { vars: new Set(), apps: new Set() });
309
+ }
310
+ serviceUrls.get(baseUrl).vars.add(varName);
311
+ serviceUrls.get(baseUrl).apps.add(appName);
312
+ }
313
+ }
314
+ // Convert to DetectedServiceUrl array
315
+ const result = [];
316
+ for (const [baseUrl, { vars, apps: appNames }] of serviceUrls) {
317
+ const serviceInfo = getServiceInfoFromUrl(baseUrl);
318
+ result.push({
319
+ base_url: baseUrl,
320
+ var_name: serviceInfo.varName,
321
+ description: serviceInfo.description,
322
+ used_by: Array.from(vars),
323
+ apps: Array.from(appNames),
324
+ source: 'env files',
325
+ });
326
+ }
327
+ // Sort by port for consistent output
328
+ result.sort((a, b) => {
329
+ const portA = parseInt(a.base_url.match(/:(\d+)/)?.[1] || '0');
330
+ const portB = parseInt(b.base_url.match(/:(\d+)/)?.[1] || '0');
331
+ return portA - portB;
332
+ });
333
+ return result;
334
+ }
335
+ /**
336
+ * Get service info from URL
337
+ */
338
+ function getServiceInfoFromUrl(baseUrl) {
339
+ const urlMatch = baseUrl.match(/^https?:\/\/([a-zA-Z0-9_.-]+)(?::(\d+))?/);
340
+ if (!urlMatch) {
341
+ return { varName: 'UNKNOWN_URL', description: 'Unknown service' };
342
+ }
343
+ const hostname = urlMatch[1];
344
+ const port = urlMatch[2] ? parseInt(urlMatch[2]) : undefined;
345
+ // Generate from hostname if not localhost
346
+ if (hostname !== 'localhost') {
347
+ const varName = hostname.toUpperCase().replace(/-/g, '_') + '_URL';
348
+ return {
349
+ varName,
350
+ description: `${hostname} service`,
351
+ };
352
+ }
353
+ // Generate from port for localhost
354
+ if (port) {
355
+ return {
356
+ varName: `PORT_${port}_URL`,
357
+ description: `localhost:${port}`,
358
+ };
359
+ }
360
+ return { varName: 'LOCALHOST_URL', description: 'localhost' };
361
+ }
140
362
  /**
141
363
  * Convert ProjectScan to DetectedConfig
142
364
  */
@@ -408,6 +630,15 @@ function showSummary(detected) {
408
630
  console.log(chalk_1.default.dim(` ${git.remote}`));
409
631
  }
410
632
  }
633
+ // Service URLs (for staging URL configuration)
634
+ if (detected.service_urls && detected.service_urls.length > 0) {
635
+ console.log(`\n Service URLs (${detected.service_urls.length}):`);
636
+ for (const svc of detected.service_urls) {
637
+ console.log(` ${chalk_1.default.cyan(svc.var_name)}: ${svc.base_url}`);
638
+ 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` : ''}`));
639
+ }
640
+ console.log(chalk_1.default.dim('\n These URLs will need staging equivalents in init.'));
641
+ }
411
642
  console.log(chalk_1.default.bold('\n📝 Next steps:\n'));
412
643
  console.log(' 1. Review the detected configuration in .genbox/detected.yaml');
413
644
  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.17",
4
4
  "description": "Genbox CLI - AI-Powered Development Environments",
5
5
  "main": "dist/index.js",
6
6
  "bin": {