genbox 1.0.10 → 1.0.12

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.
@@ -48,6 +48,65 @@ const config_loader_1 = require("../config-loader");
48
48
  const profile_resolver_1 = require("../profile-resolver");
49
49
  const api_1 = require("../api");
50
50
  const ssh_config_1 = require("../ssh-config");
51
+ const child_process_1 = require("child_process");
52
+ /**
53
+ * Poll for genbox IP address (servers take a few seconds to get an IP assigned)
54
+ */
55
+ async function waitForIpAddress(genboxId, maxAttempts = 30, delayMs = 2000) {
56
+ for (let i = 0; i < maxAttempts; i++) {
57
+ try {
58
+ const genbox = await (0, api_1.fetchApi)(`/genboxes/${genboxId}`);
59
+ if (genbox.ipAddress) {
60
+ return genbox.ipAddress;
61
+ }
62
+ }
63
+ catch {
64
+ // Ignore errors during polling
65
+ }
66
+ await new Promise(resolve => setTimeout(resolve, delayMs));
67
+ }
68
+ return null;
69
+ }
70
+ /**
71
+ * Find SSH private key
72
+ */
73
+ function findSshKeyPath() {
74
+ const home = os.homedir();
75
+ const keyPaths = [
76
+ path.join(home, '.ssh', 'id_ed25519'),
77
+ path.join(home, '.ssh', 'id_rsa'),
78
+ ];
79
+ for (const keyPath of keyPaths) {
80
+ if (fs.existsSync(keyPath)) {
81
+ return keyPath;
82
+ }
83
+ }
84
+ return null;
85
+ }
86
+ /**
87
+ * Wait for SSH to be available on the server
88
+ */
89
+ async function waitForSsh(ipAddress, maxAttempts = 30, delayMs = 5000) {
90
+ const keyPath = findSshKeyPath();
91
+ if (!keyPath)
92
+ return false;
93
+ const sshOpts = `-i ${keyPath} -o IdentitiesOnly=yes -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -o ConnectTimeout=5`;
94
+ for (let i = 0; i < maxAttempts; i++) {
95
+ try {
96
+ (0, child_process_1.execSync)(`ssh ${sshOpts} dev@${ipAddress} "echo 'SSH ready'"`, {
97
+ encoding: 'utf8',
98
+ timeout: 10000,
99
+ stdio: ['pipe', 'pipe', 'pipe'],
100
+ });
101
+ return true;
102
+ }
103
+ catch {
104
+ // SSH not ready yet
105
+ }
106
+ await new Promise(resolve => setTimeout(resolve, delayMs));
107
+ }
108
+ return false;
109
+ }
51
110
  async function provisionGenbox(payload) {
52
111
  return (0, api_1.fetchApi)('/genboxes', {
53
112
  method: 'POST',
@@ -171,16 +230,38 @@ exports.createCommand = new commander_1.Command('create')
171
230
  const spinner = (0, ora_1.default)(`Creating Genbox '${name}'...`).start();
172
231
  try {
173
232
  const genbox = await provisionGenbox(payload);
174
- spinner.succeed(chalk_1.default.green(`Genbox '${name}' created successfully!`));
175
- // Add SSH config
176
- if (genbox.ipAddress) {
177
- const sshAdded = (0, ssh_config_1.addSshConfigEntry)({
178
- name,
179
- ipAddress: genbox.ipAddress,
180
- });
181
- if (sshAdded) {
182
- console.log(chalk_1.default.dim(` SSH config added: ssh ${(0, ssh_config_1.getSshAlias)(name)}`));
233
+ spinner.succeed(chalk_1.default.green(`Genbox '${name}' created!`));
234
+ // Wait for IP if not immediately available
235
+ let ipAddress = genbox.ipAddress;
236
+ if (!ipAddress && genbox._id) {
237
+ spinner.start('Waiting for IP address...');
238
+ ipAddress = await waitForIpAddress(genbox._id);
239
+ if (ipAddress) {
240
+ spinner.succeed(`IP address assigned: ${ipAddress}`);
241
+ genbox.ipAddress = ipAddress;
183
242
  }
243
+ else {
244
+ spinner.fail('Timed out waiting for IP. Run `genbox status` to check later.');
245
+ displayGenboxInfo(genbox, resolved);
246
+ return;
247
+ }
248
+ }
249
+ // Wait for SSH to be available
250
+ spinner.start('Waiting for SSH to be ready...');
251
+ const sshReady = await waitForSsh(ipAddress);
252
+ if (sshReady) {
253
+ spinner.succeed(chalk_1.default.green('SSH is ready!'));
254
+ }
255
+ else {
256
+ spinner.warn('SSH not ready yet. Server may still be booting.');
257
+ }
258
+ // Add SSH config
259
+ const sshAdded = (0, ssh_config_1.addSshConfigEntry)({
260
+ name,
261
+ ipAddress,
262
+ });
263
+ if (sshAdded) {
264
+ console.log(chalk_1.default.dim(` SSH config added: ssh ${(0, ssh_config_1.getSshAlias)(name)}`));
184
265
  }
185
266
  // Display results
186
267
  displayGenboxInfo(genbox, resolved);
@@ -250,6 +331,13 @@ function displayResolvedConfig(resolved) {
250
331
  }
251
332
  console.log('');
252
333
  console.log(` ${chalk_1.default.bold('Database:')} ${resolved.database.mode}${resolved.database.source ? ` (from ${resolved.database.source})` : ''}`);
334
+ if (Object.keys(resolved.env).length > 0) {
335
+ console.log('');
336
+ console.log(` ${chalk_1.default.bold('Environment:')}`);
337
+ for (const [key, value] of Object.entries(resolved.env)) {
338
+ console.log(chalk_1.default.dim(` ${key}=${value}`));
339
+ }
340
+ }
253
341
  if (resolved.warnings.length > 0) {
254
342
  console.log('');
255
343
  console.log(chalk_1.default.yellow(' Warnings:'));
@@ -260,11 +348,69 @@ function displayResolvedConfig(resolved) {
260
348
  console.log(chalk_1.default.dim('───────────────────────────────────────────────'));
261
349
  console.log('');
262
350
  }
351
+ /**
352
+ * Parse .env.genbox file into segregated sections
353
+ */
354
+ function parseEnvGenboxSections(content) {
355
+ const sections = new Map();
356
+ let currentSection = 'GLOBAL';
357
+ let currentContent = [];
358
+ for (const line of content.split('\n')) {
359
+ const sectionMatch = line.match(/^# === ([^=]+) ===$/);
360
+ if (sectionMatch) {
361
+ // Save previous section
362
+ if (currentContent.length > 0) {
363
+ sections.set(currentSection, currentContent.join('\n').trim());
364
+ }
365
+ currentSection = sectionMatch[1].trim();
366
+ currentContent = [];
367
+ }
368
+ else if (currentSection !== 'END') {
369
+ currentContent.push(line);
370
+ }
371
+ }
372
+ // Save last section
373
+ if (currentContent.length > 0 && currentSection !== 'END') {
374
+ sections.set(currentSection, currentContent.join('\n').trim());
375
+ }
376
+ return sections;
377
+ }
378
+ /**
379
+ * Build env content for a specific app by combining GLOBAL + app-specific sections
380
+ */
381
+ function buildAppEnvContent(sections, appName, apiUrl) {
382
+ const parts = [];
383
+ // Always include GLOBAL section
384
+ const globalSection = sections.get('GLOBAL');
385
+ if (globalSection) {
386
+ parts.push(globalSection);
387
+ }
388
+ // Include app-specific section if exists
389
+ const appSection = sections.get(appName);
390
+ if (appSection) {
391
+ parts.push(appSection);
392
+ }
393
+ let envContent = parts.join('\n\n');
394
+ // Expand ${API_URL} references
395
+ envContent = envContent.replace(/\$\{API_URL\}/g, apiUrl);
396
+ // Keep only actual env vars (filter out pure comment lines but keep var definitions)
397
+ envContent = envContent
398
+ .split('\n')
399
+ .filter(line => {
400
+ const trimmed = line.trim();
401
+ // Keep empty lines, lines with = (even if commented), and non-comment lines
402
+ return trimmed === '' || trimmed.includes('=') || !trimmed.startsWith('#');
403
+ })
404
+ .join('\n')
405
+ .replace(/\n{3,}/g, '\n\n')
406
+ .trim();
407
+ return envContent;
408
+ }
263
409
  /**
264
410
  * Build API payload from resolved config
265
411
  */
266
412
  function buildPayload(resolved, config, publicKey, privateKey, configLoader) {
267
- // Load env vars
413
+ // Load env vars from .env.genbox
268
414
  const envVars = configLoader.loadEnvVars(process.cwd());
269
415
  // Build services map
270
416
  const services = {};
@@ -280,8 +426,82 @@ function buildPayload(resolved, config, publicKey, privateKey, configLoader) {
280
426
  }
281
427
  // Build files bundle
282
428
  const files = [];
429
+ // Track env files to move in setup script (staging approach to avoid blocking git clone)
430
+ const envFilesToMove = [];
431
+ // Send .env.genbox content to server for each app
432
+ const envGenboxPath = path.join(process.cwd(), '.env.genbox');
433
+ if (fs.existsSync(envGenboxPath)) {
434
+ const rawEnvContent = fs.readFileSync(envGenboxPath, 'utf-8');
435
+ // Parse into sections
436
+ const sections = parseEnvGenboxSections(rawEnvContent);
437
+ // Parse GLOBAL section to get API URL values
438
+ const globalSection = sections.get('GLOBAL') || '';
439
+ const envVarsFromFile = {};
440
+ for (const line of globalSection.split('\n')) {
441
+ const match = line.match(/^([A-Z_][A-Z0-9_]*)=(.*)$/);
442
+ if (match) {
443
+ let value = match[2].trim();
444
+ // Remove quotes if present
445
+ if ((value.startsWith('"') && value.endsWith('"')) ||
446
+ (value.startsWith("'") && value.endsWith("'"))) {
447
+ value = value.slice(1, -1);
448
+ }
449
+ envVarsFromFile[match[1]] = value;
450
+ }
451
+ }
452
+ // Determine API_URL based on profile's connect_to setting
453
+ const connectTo = resolved.profile ?
454
+ (config.profiles?.[resolved.profile]?.connect_to) : undefined;
455
+ let apiUrl;
456
+ if (connectTo) {
457
+ // Use the environment-specific API URL (e.g., STAGING_API_URL)
458
+ const envApiVarName = `${connectTo.toUpperCase()}_API_URL`;
459
+ apiUrl = envVarsFromFile[envApiVarName] || resolved.env['API_URL'] || 'http://localhost:3050';
460
+ }
461
+ else {
462
+ // Use local API URL
463
+ apiUrl = envVarsFromFile['LOCAL_API_URL'] || 'http://localhost:3050';
464
+ }
465
+ // Add env file for each app - filtered by selected apps only
466
+ for (const app of resolved.apps) {
467
+ const appPath = config.apps[app.name]?.path || app.name;
468
+ const repoPath = resolved.repos.find(r => r.name === app.name)?.path ||
469
+ (resolved.repos[0]?.path ? `${resolved.repos[0].path}/${appPath}` : `/home/dev/${config.project.name}/${appPath}`);
470
+ // Check if this app has microservices (sections like api/gateway, api/auth)
471
+ const servicesSections = Array.from(sections.keys()).filter(s => s.startsWith(`${app.name}/`));
472
+ if (servicesSections.length > 0) {
473
+ // App has microservices - create env file for each service
474
+ for (const serviceSectionName of servicesSections) {
475
+ const serviceName = serviceSectionName.split('/')[1];
476
+ // Build service-specific env content (GLOBAL + service section)
477
+ const serviceEnvContent = buildAppEnvContent(sections, serviceSectionName, apiUrl);
478
+ const stagingName = `${app.name}-${serviceName}.env`;
479
+ const targetPath = `${repoPath}/apps/${serviceName}/.env`;
480
+ files.push({
481
+ path: `/home/dev/.env-staging/${stagingName}`,
482
+ content: serviceEnvContent,
483
+ permissions: '0644',
484
+ });
485
+ envFilesToMove.push({ stagingName, targetPath });
486
+ }
487
+ }
488
+ else {
489
+ // Regular app - build app-specific env content (GLOBAL + app section)
490
+ const appEnvContent = buildAppEnvContent(sections, app.name, apiUrl);
491
+ files.push({
492
+ path: `/home/dev/.env-staging/${app.name}.env`,
493
+ content: appEnvContent,
494
+ permissions: '0644',
495
+ });
496
+ envFilesToMove.push({
497
+ stagingName: `${app.name}.env`,
498
+ targetPath: `${repoPath}/.env`,
499
+ });
500
+ }
501
+ }
502
+ }
283
503
  // Add setup script if generated
284
- const setupScript = generateSetupScript(resolved, config);
504
+ const setupScript = generateSetupScript(resolved, config, envFilesToMove);
285
505
  if (setupScript) {
286
506
  files.push({
287
507
  path: '/home/dev/setup-genbox.sh',
@@ -327,13 +547,27 @@ function buildPayload(resolved, config, publicKey, privateKey, configLoader) {
327
547
  /**
328
548
  * Generate setup script
329
549
  */
330
- function generateSetupScript(resolved, config) {
550
+ function generateSetupScript(resolved, config, envFilesToMove = []) {
331
551
  const lines = [
332
552
  '#!/bin/bash',
333
553
  '# Generated by genbox create',
334
554
  'set -e',
335
555
  '',
336
556
  ];
557
+ // Move .env files from staging to their correct locations
558
+ // This runs after git clone has completed
559
+ if (envFilesToMove.length > 0) {
560
+ lines.push('# Move .env files from staging to app directories');
561
+ for (const { stagingName, targetPath } of envFilesToMove) {
562
+ lines.push(`if [ -f "/home/dev/.env-staging/${stagingName}" ]; then`);
563
+ lines.push(` mkdir -p "$(dirname "${targetPath}")"`);
564
+ lines.push(` mv "/home/dev/.env-staging/${stagingName}" "${targetPath}"`);
565
+ lines.push(` echo "Moved .env to ${targetPath}"`);
566
+ lines.push('fi');
567
+ }
568
+ lines.push('rm -rf /home/dev/.env-staging 2>/dev/null || true');
569
+ lines.push('');
570
+ }
337
571
  // Change to project directory
338
572
  if (resolved.repos.length > 0) {
339
573
  lines.push(`cd ${resolved.repos[0].path} || exit 1`);
@@ -465,18 +699,38 @@ async function createLegacy(name, options) {
465
699
  gitToken: envVars.GIT_TOKEN,
466
700
  });
467
701
  spinner.succeed(chalk_1.default.green(`Genbox '${name}' created!`));
468
- if (genbox.ipAddress) {
469
- const sshAdded = (0, ssh_config_1.addSshConfigEntry)({ name, ipAddress: genbox.ipAddress });
470
- if (sshAdded) {
471
- console.log(chalk_1.default.dim(` SSH config added: ssh ${(0, ssh_config_1.getSshAlias)(name)}`));
702
+ // Wait for IP if not immediately available
703
+ let ipAddress = genbox.ipAddress;
704
+ if (!ipAddress && genbox._id) {
705
+ spinner.start('Waiting for IP address...');
706
+ ipAddress = await waitForIpAddress(genbox._id);
707
+ if (ipAddress) {
708
+ spinner.succeed(`IP address assigned: ${ipAddress}`);
709
+ genbox.ipAddress = ipAddress;
710
+ }
711
+ else {
712
+ spinner.fail('Timed out waiting for IP. Run `genbox status` to check later.');
713
+ return;
472
714
  }
473
715
  }
716
+ // Wait for SSH to be available
717
+ spinner.start('Waiting for SSH to be ready...');
718
+ const sshReady = await waitForSsh(ipAddress);
719
+ if (sshReady) {
720
+ spinner.succeed(chalk_1.default.green('SSH is ready!'));
721
+ }
722
+ else {
723
+ spinner.warn('SSH not ready yet. Server may still be booting.');
724
+ }
725
+ // Add SSH config
726
+ const sshAdded = (0, ssh_config_1.addSshConfigEntry)({ name, ipAddress });
727
+ if (sshAdded) {
728
+ console.log(chalk_1.default.dim(` SSH config added: ssh ${(0, ssh_config_1.getSshAlias)(name)}`));
729
+ }
474
730
  console.log(chalk_1.default.dim('───────────────────────────────────────────────'));
475
731
  console.log(` ${chalk_1.default.bold('Environment:')} ${name}`);
476
732
  console.log(` ${chalk_1.default.bold('Status:')} ${chalk_1.default.yellow(genbox.status)}`);
477
- if (genbox.ipAddress) {
478
- console.log(` ${chalk_1.default.bold('IP:')} ${genbox.ipAddress}`);
479
- }
733
+ console.log(` ${chalk_1.default.bold('IP:')} ${ipAddress}`);
480
734
  console.log(chalk_1.default.dim('───────────────────────────────────────────────'));
481
735
  }
482
736
  catch (error) {
@@ -103,13 +103,15 @@ function detectAppGitRepos(apps, rootDir) {
103
103
  return repos;
104
104
  }
105
105
  /**
106
- * Find .env files in app directories
106
+ * Find .env files in app directories (including nested microservices)
107
107
  */
108
108
  function findAppEnvFiles(apps, rootDir) {
109
109
  const envFiles = [];
110
110
  const envPatterns = ['.env', '.env.local', '.env.development'];
111
111
  for (const app of apps) {
112
112
  const appDir = path_1.default.join(rootDir, app.path);
113
+ // Check for direct .env file in app directory
114
+ let foundDirectEnv = false;
113
115
  for (const pattern of envPatterns) {
114
116
  const envPath = path_1.default.join(appDir, pattern);
115
117
  if (fs_1.default.existsSync(envPath)) {
@@ -118,7 +120,35 @@ function findAppEnvFiles(apps, rootDir) {
118
120
  envFile: pattern,
119
121
  fullPath: envPath,
120
122
  });
121
- break; // Only take the first match per app
123
+ foundDirectEnv = true;
124
+ break;
125
+ }
126
+ }
127
+ // Check for nested microservices (e.g., api/apps/*)
128
+ const appsSubdir = path_1.default.join(appDir, 'apps');
129
+ if (fs_1.default.existsSync(appsSubdir) && fs_1.default.statSync(appsSubdir).isDirectory()) {
130
+ try {
131
+ const services = fs_1.default.readdirSync(appsSubdir);
132
+ for (const service of services) {
133
+ const serviceDir = path_1.default.join(appsSubdir, service);
134
+ if (!fs_1.default.statSync(serviceDir).isDirectory())
135
+ continue;
136
+ for (const pattern of envPatterns) {
137
+ const envPath = path_1.default.join(serviceDir, pattern);
138
+ if (fs_1.default.existsSync(envPath)) {
139
+ envFiles.push({
140
+ appName: `${app.name}/${service}`,
141
+ envFile: pattern,
142
+ fullPath: envPath,
143
+ isService: true,
144
+ });
145
+ break;
146
+ }
147
+ }
148
+ }
149
+ }
150
+ catch {
151
+ // Ignore errors reading subdirectories
122
152
  }
123
153
  }
124
154
  }
@@ -454,6 +484,20 @@ exports.initCommand = new commander_1.Command('init')
454
484
  });
455
485
  fs_1.default.writeFileSync(configPath, yamlContent);
456
486
  console.log(chalk_1.default.green(`\n✔ Configuration saved to ${CONFIG_FILENAME}`));
487
+ // Add API URLs from environments to envVarsToAdd
488
+ // Always add LOCAL_API_URL for local development
489
+ envVarsToAdd['LOCAL_API_URL'] = 'http://localhost:3050';
490
+ if (v3Config.environments) {
491
+ for (const [envName, envConfig] of Object.entries(v3Config.environments)) {
492
+ const apiUrl = envConfig.api?.api ||
493
+ envConfig.api?.url ||
494
+ envConfig.api?.gateway;
495
+ if (apiUrl) {
496
+ const varName = `${envName.toUpperCase()}_API_URL`;
497
+ envVarsToAdd[varName] = apiUrl;
498
+ }
499
+ }
500
+ }
457
501
  // Generate .env.genbox
458
502
  await setupEnvFile(projectName, v3Config, nonInteractive, scan, isMultiRepo, envVarsToAdd, overwriteExisting);
459
503
  // Show warnings
@@ -464,11 +508,38 @@ exports.initCommand = new commander_1.Command('init')
464
508
  console.log(chalk_1.default.dim(` - ${warning}`));
465
509
  }
466
510
  }
511
+ // Show API URL guidance if environments are configured
512
+ if (v3Config.environments && Object.keys(v3Config.environments).length > 0) {
513
+ console.log('');
514
+ console.log(chalk_1.default.blue('=== API URL Configuration ==='));
515
+ console.log(chalk_1.default.dim('The following API URLs were added to .env.genbox:'));
516
+ console.log('');
517
+ console.log(chalk_1.default.dim(' LOCAL_API_URL=http://localhost:3050'));
518
+ for (const [envName, envConfig] of Object.entries(v3Config.environments)) {
519
+ const apiUrl = envConfig.api?.api ||
520
+ envConfig.api?.url ||
521
+ envConfig.api?.gateway;
522
+ if (apiUrl) {
523
+ const varName = `${envName.toUpperCase()}_API_URL`;
524
+ console.log(chalk_1.default.dim(` ${varName}=${apiUrl}`));
525
+ }
526
+ }
527
+ console.log('');
528
+ console.log(chalk_1.default.yellow('To use dynamic API URLs:'));
529
+ console.log(chalk_1.default.dim(' Use ${API_URL} in your app env vars, e.g.:'));
530
+ console.log(chalk_1.default.cyan(' VITE_API_BASE_URL=${API_URL}'));
531
+ console.log(chalk_1.default.cyan(' NEXT_PUBLIC_API_URL=${API_URL}'));
532
+ console.log('');
533
+ console.log(chalk_1.default.dim(' At create time, ${API_URL} expands based on profile:'));
534
+ console.log(chalk_1.default.dim(' • connect_to: staging → uses STAGING_API_URL'));
535
+ console.log(chalk_1.default.dim(' • connect_to: production → uses PRODUCTION_API_URL'));
536
+ console.log(chalk_1.default.dim(' • local/no connect_to → uses LOCAL_API_URL'));
537
+ }
467
538
  // Next steps
468
539
  console.log('');
469
540
  console.log(chalk_1.default.bold('Next steps:'));
470
541
  console.log(chalk_1.default.dim(` 1. Review and edit ${CONFIG_FILENAME}`));
471
- console.log(chalk_1.default.dim(` 2. Add secrets to ${ENV_FILENAME}`));
542
+ console.log(chalk_1.default.dim(` 2. Update ${ENV_FILENAME} to use API URL variables where needed`));
472
543
  console.log(chalk_1.default.dim(` 3. Run 'genbox profiles' to see available profiles`));
473
544
  console.log(chalk_1.default.dim(` 4. Run 'genbox create <name> --profile <profile>' to create an environment`));
474
545
  }
@@ -746,7 +817,7 @@ async function setupEnvironments(scan, config, isMultiRepo = false) {
746
817
  return Object.keys(environments).length > 0 ? environments : undefined;
747
818
  }
748
819
  /**
749
- * Setup .env.genbox file
820
+ * Setup .env.genbox file with segregated app sections
750
821
  */
751
822
  async function setupEnvFile(projectName, config, nonInteractive = false, scan, isMultiRepo = false, extraEnvVars = {}, overwriteExisting = false) {
752
823
  const envPath = path_1.default.join(process.cwd(), ENV_FILENAME);
@@ -760,66 +831,84 @@ async function setupEnvFile(projectName, config, nonInteractive = false, scan, i
760
831
  return;
761
832
  }
762
833
  }
834
+ // Build segregated content with GLOBAL section first
835
+ let segregatedContent = `# Genbox Environment Variables
836
+ # Project: ${projectName}
837
+ # DO NOT COMMIT THIS FILE
838
+ #
839
+ # This file uses segregated sections for each app/service.
840
+ # At 'genbox create' time, only GLOBAL + selected app sections are used.
841
+ # Use \${API_URL} for dynamic API URLs based on profile's connect_to setting.
842
+
843
+ # === GLOBAL ===
844
+ # These variables are always included regardless of which apps are selected
845
+
846
+ `;
847
+ // Add global env vars
848
+ for (const [key, value] of Object.entries(extraEnvVars)) {
849
+ segregatedContent += `${key}=${value}\n`;
850
+ }
851
+ // Add GIT authentication placeholder if not already added
852
+ if (!extraEnvVars['GIT_TOKEN']) {
853
+ segregatedContent += `# GIT_TOKEN=ghp_xxxxxxxxxxxx\n`;
854
+ }
855
+ segregatedContent += `
856
+ # Database URLs (used by profiles with database mode)
857
+ # STAGING_MONGODB_URL=mongodb+srv://user:password@staging.mongodb.net
858
+ # PROD_MONGODB_URL=mongodb+srv://readonly:password@prod.mongodb.net
859
+
860
+ `;
763
861
  // For multi-repo: find env files in app directories
764
862
  if (isMultiRepo && scan) {
765
863
  const appEnvFiles = findAppEnvFiles(scan.apps, process.cwd());
766
864
  if (appEnvFiles.length > 0 && !nonInteractive) {
767
865
  console.log('');
768
866
  console.log(chalk_1.default.blue('=== Environment Files ==='));
769
- console.log(chalk_1.default.dim(`Found .env files in ${appEnvFiles.length} app directories`));
867
+ // Group by app type
868
+ const directApps = appEnvFiles.filter(e => !e.isService);
869
+ const serviceApps = appEnvFiles.filter(e => e.isService);
870
+ if (directApps.length > 0) {
871
+ console.log(chalk_1.default.dim(`Found ${directApps.length} app env files`));
872
+ }
873
+ if (serviceApps.length > 0) {
874
+ console.log(chalk_1.default.dim(`Found ${serviceApps.length} microservice env files`));
875
+ }
770
876
  const envChoices = appEnvFiles.map(env => ({
771
- name: `${env.appName}/${env.envFile}`,
877
+ name: env.isService ? `${env.appName} (service)` : env.appName,
772
878
  value: env.fullPath,
773
879
  checked: true,
774
880
  }));
775
881
  const selectedEnvFiles = await prompts.checkbox({
776
- message: 'Select .env files to merge into .env.genbox:',
882
+ message: 'Select .env files to include in .env.genbox:',
777
883
  choices: envChoices,
778
884
  });
779
885
  if (selectedEnvFiles.length > 0) {
780
- let mergedContent = `# Genbox Environment Variables
781
- # Merged from: ${selectedEnvFiles.map(f => path_1.default.relative(process.cwd(), f)).join(', ')}
782
- # DO NOT COMMIT THIS FILE
783
- #
784
- # Add staging/production URLs:
785
- # STAGING_MONGODB_URL=mongodb+srv://...
786
- # STAGING_REDIS_URL=redis://...
787
- # PROD_MONGODB_URL=mongodb+srv://...
788
- #
789
- # Git authentication:
790
- # GIT_TOKEN=ghp_xxxxxxxxxxxx
791
-
792
- `;
793
886
  for (const envFilePath of selectedEnvFiles) {
794
887
  const appInfo = appEnvFiles.find(e => e.fullPath === envFilePath);
795
- const content = fs_1.default.readFileSync(envFilePath, 'utf8');
796
- mergedContent += `\n# === ${appInfo?.appName || path_1.default.dirname(envFilePath)} ===\n`;
797
- mergedContent += content;
798
- mergedContent += '\n';
888
+ if (!appInfo)
889
+ continue;
890
+ const content = fs_1.default.readFileSync(envFilePath, 'utf8').trim();
891
+ // Add section header and content
892
+ segregatedContent += `# === ${appInfo.appName} ===\n`;
893
+ segregatedContent += content;
894
+ segregatedContent += '\n\n';
799
895
  }
800
- fs_1.default.writeFileSync(envPath, mergedContent);
801
- console.log(chalk_1.default.green(`✔ Created ${ENV_FILENAME} from ${selectedEnvFiles.length} app env files`));
802
896
  }
803
897
  }
804
898
  else if (appEnvFiles.length > 0 && nonInteractive) {
805
899
  // Non-interactive: merge all env files
806
- let mergedContent = `# Genbox Environment Variables
807
- # Merged from app directories
808
- # DO NOT COMMIT THIS FILE
809
-
810
- `;
811
900
  for (const envFile of appEnvFiles) {
812
- const content = fs_1.default.readFileSync(envFile.fullPath, 'utf8');
813
- mergedContent += `\n# === ${envFile.appName} ===\n`;
814
- mergedContent += content;
815
- mergedContent += '\n';
901
+ const content = fs_1.default.readFileSync(envFile.fullPath, 'utf8').trim();
902
+ segregatedContent += `# === ${envFile.appName} ===\n`;
903
+ segregatedContent += content;
904
+ segregatedContent += '\n\n';
816
905
  }
817
- fs_1.default.writeFileSync(envPath, mergedContent);
818
- console.log(chalk_1.default.green(`✔ Created ${ENV_FILENAME} from ${appEnvFiles.length} app env files`));
819
906
  }
820
907
  }
821
- // If no env file created yet, check for root .env
822
- if (!fs_1.default.existsSync(envPath)) {
908
+ // If no app env files found, check for root .env
909
+ const hasAppSections = segregatedContent.includes('# === ') &&
910
+ !segregatedContent.endsWith('# === GLOBAL ===\n');
911
+ if (!hasAppSections) {
823
912
  const existingEnvFiles = ['.env.local', '.env', '.env.development'];
824
913
  let existingEnvPath;
825
914
  for (const envFile of existingEnvFiles) {
@@ -831,51 +920,28 @@ async function setupEnvFile(projectName, config, nonInteractive = false, scan, i
831
920
  }
832
921
  if (existingEnvPath) {
833
922
  const copyExisting = nonInteractive ? true : await prompts.confirm({
834
- message: `Found ${path_1.default.basename(existingEnvPath)}. Copy to ${ENV_FILENAME}?`,
923
+ message: `Found ${path_1.default.basename(existingEnvPath)}. Include in ${ENV_FILENAME}?`,
835
924
  default: true,
836
925
  });
837
926
  if (copyExisting) {
838
- const content = fs_1.default.readFileSync(existingEnvPath, 'utf8');
839
- const header = `# Genbox Environment Variables
840
- # Generated from ${path_1.default.basename(existingEnvPath)}
841
- # DO NOT COMMIT THIS FILE
842
- #
843
- # Add staging/production URLs:
844
- # STAGING_MONGODB_URL=mongodb+srv://...
845
- # STAGING_REDIS_URL=redis://...
846
- # PROD_MONGODB_URL=mongodb+srv://...
847
- #
848
- # Git authentication:
849
- # GIT_TOKEN=ghp_xxxxxxxxxxxx
850
-
851
- `;
852
- fs_1.default.writeFileSync(envPath, header + content);
853
- console.log(chalk_1.default.green(`✔ Created ${ENV_FILENAME} from ${path_1.default.basename(existingEnvPath)}`));
854
- }
855
- }
856
- else {
857
- const createEnv = nonInteractive ? true : await prompts.confirm({
858
- message: `Create ${ENV_FILENAME} template?`,
859
- default: true,
860
- });
861
- if (createEnv) {
862
- const template = generateEnvTemplate(projectName, config);
863
- fs_1.default.writeFileSync(envPath, template);
864
- console.log(chalk_1.default.green(`✔ Created ${ENV_FILENAME} template`));
927
+ const content = fs_1.default.readFileSync(existingEnvPath, 'utf8').trim();
928
+ segregatedContent += `# === root ===\n`;
929
+ segregatedContent += `# From ${path_1.default.basename(existingEnvPath)}\n`;
930
+ segregatedContent += content;
931
+ segregatedContent += '\n\n';
865
932
  }
866
933
  }
867
934
  }
868
- // Append extra env vars (like GIT_TOKEN) to the file
869
- if (Object.keys(extraEnvVars).length > 0 && fs_1.default.existsSync(envPath)) {
870
- let content = fs_1.default.readFileSync(envPath, 'utf8');
871
- // Add extra env vars section
872
- let extraSection = '\n# === Added by genbox init ===\n';
873
- for (const [key, value] of Object.entries(extraEnvVars)) {
874
- // Remove any existing commented placeholder
875
- content = content.replace(new RegExp(`^#\\s*${key}=.*$`, 'gm'), '');
876
- extraSection += `${key}=${value}\n`;
877
- }
878
- fs_1.default.writeFileSync(envPath, content.trim() + '\n' + extraSection);
935
+ // Add END marker
936
+ segregatedContent += `# === END ===\n`;
937
+ // Write the file
938
+ fs_1.default.writeFileSync(envPath, segregatedContent);
939
+ const sectionCount = (segregatedContent.match(/# === [^=]+ ===/g) || []).length - 2; // Exclude GLOBAL and END
940
+ if (sectionCount > 0) {
941
+ console.log(chalk_1.default.green(`✔ Created ${ENV_FILENAME} with ${sectionCount} app section(s)`));
942
+ }
943
+ else {
944
+ console.log(chalk_1.default.green(`✔ Created ${ENV_FILENAME}`));
879
945
  }
880
946
  // Add to .gitignore
881
947
  const gitignorePath = path_1.default.join(process.cwd(), '.gitignore');
@@ -897,6 +963,19 @@ function generateEnvTemplate(projectName, config) {
897
963
  '# DO NOT COMMIT THIS FILE',
898
964
  '',
899
965
  '# ============================================',
966
+ '# API URL CONFIGURATION',
967
+ '# ============================================',
968
+ '# Use ${API_URL} in your app env vars (e.g., VITE_API_BASE_URL=${API_URL})',
969
+ '# At create time, ${API_URL} expands based on profile:',
970
+ '# - connect_to: staging → uses STAGING_API_URL',
971
+ '# - connect_to: production → uses PRODUCTION_API_URL',
972
+ '# - local/no connect_to → uses LOCAL_API_URL',
973
+ '',
974
+ 'LOCAL_API_URL=http://localhost:3050',
975
+ 'STAGING_API_URL=https://api.staging.example.com',
976
+ '# PRODUCTION_API_URL=https://api.example.com',
977
+ '',
978
+ '# ============================================',
900
979
  '# STAGING ENVIRONMENT',
901
980
  '# ============================================',
902
981
  '',
@@ -939,6 +1018,15 @@ function generateEnvTemplate(projectName, config) {
939
1018
  'STRIPE_SECRET_KEY=sk_test_xxx',
940
1019
  'STRIPE_WEBHOOK_SECRET=whsec_xxx',
941
1020
  '',
1021
+ '# ============================================',
1022
+ '# APPLICATION ENV VARS',
1023
+ '# ============================================',
1024
+ '# Use ${API_URL} for dynamic API URLs',
1025
+ '',
1026
+ '# Example:',
1027
+ '# VITE_API_BASE_URL=${API_URL}',
1028
+ '# NEXT_PUBLIC_API_URL=${API_URL}',
1029
+ '',
942
1030
  ];
943
1031
  return lines.join('\n');
944
1032
  }
@@ -42,6 +42,7 @@ const chalk_1 = __importDefault(require("chalk"));
42
42
  const api_1 = require("../api");
43
43
  const config_1 = require("../config");
44
44
  const genbox_selector_1 = require("../genbox-selector");
45
+ const ssh_config_1 = require("../ssh-config");
45
46
  const os = __importStar(require("os"));
46
47
  const path = __importStar(require("path"));
47
48
  const fs = __importStar(require("fs"));
@@ -101,6 +102,16 @@ exports.statusCommand = new commander_1.Command('status')
101
102
  return;
102
103
  }
103
104
  const selectedName = target.name;
105
+ // Auto-add SSH config if missing
106
+ if (!(0, ssh_config_1.hasSshConfigEntry)(selectedName)) {
107
+ const sshAdded = (0, ssh_config_1.addSshConfigEntry)({
108
+ name: selectedName,
109
+ ipAddress: target.ipAddress,
110
+ });
111
+ if (sshAdded) {
112
+ console.log(chalk_1.default.dim(` SSH config added: ssh ${(0, ssh_config_1.getSshAlias)(selectedName)}`));
113
+ }
114
+ }
104
115
  // 2. Get SSH key
105
116
  let keyPath;
106
117
  try {
@@ -113,7 +113,7 @@ class ProfileResolver {
113
113
  infrastructure,
114
114
  database,
115
115
  repos: this.resolveRepos(config, apps),
116
- env: this.resolveEnvVars(config, apps, infrastructure, database),
116
+ env: this.resolveEnvVars(config, apps, infrastructure, database, profile.connect_to),
117
117
  hooks: config.hooks || {},
118
118
  profile: options.profile,
119
119
  warnings,
@@ -293,7 +293,10 @@ class ProfileResolver {
293
293
  getUrlForDependency(depName, envConfig) {
294
294
  // Check if it's an API dependency
295
295
  if (depName === 'api' && envConfig.api) {
296
- return envConfig.api.url || envConfig.api.gateway;
296
+ // Check common fields: url, gateway, api, or first string value
297
+ const apiConfig = envConfig.api;
298
+ return apiConfig.url || apiConfig.gateway || apiConfig.api ||
299
+ Object.values(apiConfig).find(v => typeof v === 'string' && v.startsWith('http'));
297
300
  }
298
301
  // Check infrastructure
299
302
  const infraConfig = envConfig[depName];
@@ -454,9 +457,21 @@ class ProfileResolver {
454
457
  /**
455
458
  * Resolve environment variables
456
459
  */
457
- resolveEnvVars(config, apps, infrastructure, database) {
460
+ resolveEnvVars(config, apps, infrastructure, database, connectTo) {
458
461
  const env = {};
459
- // Add API URL based on resolution
462
+ // If connect_to is set, get API URL from environment config
463
+ if (connectTo && config.environments?.[connectTo]) {
464
+ const envConfig = config.environments[connectTo];
465
+ const apiUrl = this.getUrlForDependency('api', envConfig);
466
+ if (apiUrl) {
467
+ env['API_URL'] = apiUrl;
468
+ env['VITE_API_URL'] = apiUrl;
469
+ env['VITE_API_BASE_URL'] = apiUrl;
470
+ env['NEXT_PUBLIC_API_URL'] = apiUrl;
471
+ env['NEXT_PUBLIC_API_BASE_URL'] = apiUrl;
472
+ }
473
+ }
474
+ // Add API URL based on app dependency resolution (may override connect_to)
460
475
  for (const app of apps) {
461
476
  const apiDep = app.dependencies['api'];
462
477
  if (apiDep) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "genbox",
3
- "version": "1.0.10",
3
+ "version": "1.0.12",
4
4
  "description": "Genbox CLI - AI-Powered Development Environments",
5
5
  "main": "dist/index.js",
6
6
  "bin": {