genbox 1.0.18 → 1.0.20

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.
@@ -110,6 +110,8 @@ exports.createCommand = new commander_1.Command('create')
110
110
  .option('--db-source <source>', 'Database source: staging, production')
111
111
  .option('-s, --size <size>', 'Server size: small, medium, large, xl')
112
112
  .option('-b, --branch <branch>', 'Git branch to checkout')
113
+ .option('-nb, --new-branch <name>', 'Create a new branch with this name')
114
+ .option('-sb, --source-branch <branch>', 'Source branch to create new branch from (defaults to current/default branch)')
113
115
  .option('-y, --yes', 'Skip interactive prompts')
114
116
  .option('--dry-run', 'Show what would be created without actually creating')
115
117
  .action(async (name, options) => {
@@ -137,13 +139,19 @@ exports.createCommand = new commander_1.Command('create')
137
139
  dbSource: options.dbSource,
138
140
  size: options.size,
139
141
  branch: options.branch,
142
+ newBranch: options.newBranch,
143
+ sourceBranch: options.sourceBranch,
140
144
  yes: options.yes,
141
145
  dryRun: options.dryRun,
142
146
  };
143
147
  // Resolve configuration
144
148
  console.log(chalk_1.default.blue('Resolving configuration...'));
145
149
  console.log('');
146
- const resolved = await profileResolver.resolve(config, createOptions);
150
+ let resolved = await profileResolver.resolve(config, createOptions);
151
+ // Interactive branch selection if no branch was specified
152
+ if (!options.branch && !options.newBranch && !options.yes && resolved.repos.length > 0) {
153
+ resolved = await promptForBranchOptions(resolved, config);
154
+ }
147
155
  // Display resolved configuration
148
156
  displayResolvedConfig(resolved);
149
157
  // Ask to save as profile (if not using one already)
@@ -264,6 +272,12 @@ function displayResolvedConfig(resolved) {
264
272
  for (const repo of resolved.repos) {
265
273
  console.log(` • ${repo.name}: ${repo.url}`);
266
274
  console.log(chalk_1.default.dim(` → ${repo.path}`));
275
+ if (repo.newBranch) {
276
+ console.log(` Branch: ${chalk_1.default.cyan(repo.newBranch)} ${chalk_1.default.dim(`(new, from ${repo.sourceBranch || 'main'})`)}`);
277
+ }
278
+ else if (repo.branch) {
279
+ console.log(` Branch: ${chalk_1.default.cyan(repo.branch)}`);
280
+ }
267
281
  }
268
282
  }
269
283
  if (resolved.infrastructure.length > 0) {
@@ -292,6 +306,91 @@ function displayResolvedConfig(resolved) {
292
306
  console.log(chalk_1.default.dim('───────────────────────────────────────────────'));
293
307
  console.log('');
294
308
  }
309
+ /**
310
+ * Prompt for branch options interactively
311
+ */
312
+ async function promptForBranchOptions(resolved, config) {
313
+ // Get the default branch from config or first repo
314
+ const defaultBranch = config.defaults?.branch || resolved.repos[0]?.branch || 'main';
315
+ console.log(chalk_1.default.blue('=== Branch Configuration ==='));
316
+ console.log(chalk_1.default.dim(`Default branch: ${defaultBranch}`));
317
+ console.log('');
318
+ const branchChoice = await prompts.select({
319
+ message: 'Branch option:',
320
+ choices: [
321
+ {
322
+ name: `Use default branch (${defaultBranch})`,
323
+ value: 'default',
324
+ },
325
+ {
326
+ name: 'Use a different existing branch',
327
+ value: 'existing',
328
+ },
329
+ {
330
+ name: 'Create a new branch',
331
+ value: 'new',
332
+ },
333
+ ],
334
+ default: 'default',
335
+ });
336
+ if (branchChoice === 'default') {
337
+ // Keep resolved repos as-is
338
+ return resolved;
339
+ }
340
+ if (branchChoice === 'existing') {
341
+ const branchName = await prompts.input({
342
+ message: 'Enter branch name:',
343
+ default: defaultBranch,
344
+ validate: (value) => {
345
+ if (!value.trim())
346
+ return 'Branch name is required';
347
+ return true;
348
+ },
349
+ });
350
+ // Update all repos with the selected branch
351
+ return {
352
+ ...resolved,
353
+ repos: resolved.repos.map(repo => ({
354
+ ...repo,
355
+ branch: branchName.trim(),
356
+ newBranch: undefined,
357
+ sourceBranch: undefined,
358
+ })),
359
+ };
360
+ }
361
+ if (branchChoice === 'new') {
362
+ const newBranchName = await prompts.input({
363
+ message: 'New branch name:',
364
+ validate: (value) => {
365
+ if (!value.trim())
366
+ return 'Branch name is required';
367
+ if (!/^[\w\-./]+$/.test(value))
368
+ return 'Invalid branch name (use letters, numbers, -, _, /, .)';
369
+ return true;
370
+ },
371
+ });
372
+ const sourceBranch = await prompts.input({
373
+ message: 'Create from branch:',
374
+ default: defaultBranch,
375
+ validate: (value) => {
376
+ if (!value.trim())
377
+ return 'Source branch is required';
378
+ return true;
379
+ },
380
+ });
381
+ // Update all repos with new branch info
382
+ return {
383
+ ...resolved,
384
+ repos: resolved.repos.map(repo => ({
385
+ ...repo,
386
+ branch: newBranchName.trim(),
387
+ newBranch: newBranchName.trim(),
388
+ sourceBranch: sourceBranch.trim(),
389
+ })),
390
+ };
391
+ }
392
+ return resolved;
393
+ }
295
394
  /**
296
395
  * Parse .env.genbox file into segregated sections
297
396
  */
@@ -505,6 +604,8 @@ function buildPayload(resolved, config, publicKey, privateKey, configLoader) {
505
604
  url: repo.url,
506
605
  path: repo.path,
507
606
  branch: repo.branch,
607
+ newBranch: repo.newBranch,
608
+ sourceBranch: repo.sourceBranch,
508
609
  };
509
610
  }
510
611
  return {
@@ -170,12 +170,132 @@ async function handleBulkDelete(options) {
170
170
  console.log(chalk_1.default.yellow(`Completed: ${successCount} destroyed, ${failCount} failed.`));
171
171
  }
172
172
  }
173
+ /**
174
+ * Check for uncommitted changes and handle them before destroy
175
+ */
176
+ async function handleUncommittedChanges(genbox, options) {
177
+ // Skip if --yes flag is used (user explicitly wants to skip this)
178
+ if (options.yes && !options.saveChanges) {
179
+ return { proceed: true };
180
+ }
181
+ // If save-changes is explicitly set, use that value
182
+ if (options.saveChanges === 'trash' || options.saveChanges === 'skip') {
183
+ return { proceed: true, action: options.saveChanges };
184
+ }
185
+ // Check for uncommitted changes by running git status on the genbox
186
+ console.log('');
187
+ console.log(chalk_1.default.blue('Checking for uncommitted changes...'));
188
+ try {
189
+ // Try to get git status from the genbox
190
+ const statusResponse = await (0, api_1.fetchApi)(`/genboxes/${genbox._id}/exec`, {
191
+ method: 'POST',
192
+ body: JSON.stringify({
193
+ command: 'cd /home/dev/* 2>/dev/null && git status --porcelain 2>/dev/null || echo ""',
194
+ timeout: 10000,
195
+ }),
196
+ });
197
+ const gitStatus = statusResponse?.stdout?.trim() || '';
198
+ if (!gitStatus) {
199
+ console.log(chalk_1.default.dim(' No uncommitted changes detected.'));
200
+ return { proceed: true, action: 'none' };
201
+ }
202
+ // There are uncommitted changes
203
+ const changeCount = gitStatus.split('\n').filter(line => line.trim()).length;
204
+ console.log(chalk_1.default.yellow(` Found ${changeCount} uncommitted change${changeCount === 1 ? '' : 's'}.`));
205
+ console.log('');
206
+ // If save-changes is set to 'pr', auto-select that option
207
+ if (options.saveChanges === 'pr') {
208
+ return { proceed: true, action: 'pr' };
209
+ }
210
+ // Prompt user for action
211
+ const action = await (0, select_1.default)({
212
+ message: 'What would you like to do with uncommitted changes?',
213
+ choices: [
214
+ {
215
+ name: 'Commit, push, and create PR (recommended)',
216
+ value: 'pr',
217
+ description: 'Creates a PR with your changes before destroying',
218
+ },
219
+ {
220
+ name: 'Trash changes',
221
+ value: 'trash',
222
+ description: 'Discard all uncommitted changes',
223
+ },
224
+ {
225
+ name: 'Cancel destroy',
226
+ value: 'cancel',
227
+ description: 'Keep the genbox running',
228
+ },
229
+ ],
230
+ default: 'pr',
231
+ });
232
+ if (action === 'cancel') {
233
+ return { proceed: false };
234
+ }
235
+ return { proceed: true, action };
236
+ }
237
+ catch (error) {
238
+ // If we can't check (e.g., genbox is not running), just proceed
239
+ console.log(chalk_1.default.dim(' Could not check for changes (genbox may not be accessible).'));
240
+ return { proceed: true, action: 'skip' };
241
+ }
242
+ }
243
+ /**
244
+ * Commit, push and create PR for changes
245
+ */
246
+ async function createPRForChanges(genbox) {
247
+ const spinner = (0, ora_1.default)('Creating PR for uncommitted changes...').start();
248
+ try {
249
+ // Execute git commands on the genbox to commit, push, and create PR
250
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
251
+ const branchName = `genbox-save/${genbox.name}-${timestamp}`;
252
+ const commands = [
253
+ // Ensure we're in the project directory
254
+ 'cd /home/dev/*',
255
+ // Create a new branch for the changes
256
+ `git checkout -b "${branchName}"`,
257
+ // Stage all changes
258
+ 'git add -A',
259
+ // Commit with a descriptive message
260
+ `git commit -m "Save changes from genbox '${genbox.name}' before destroy"`,
261
+ // Push to origin
262
+ `git push -u origin "${branchName}"`,
263
+ // Try to create PR using gh CLI if available
264
+ `gh pr create --title "Changes from genbox '${genbox.name}'" --body "Auto-saved changes before genbox destroy" 2>/dev/null || echo "PR_SKIP"`,
265
+ ].join(' && ');
266
+ const result = await (0, api_1.fetchApi)(`/genboxes/${genbox._id}/exec`, {
267
+ method: 'POST',
268
+ body: JSON.stringify({
269
+ command: commands,
270
+ timeout: 60000,
271
+ }),
272
+ });
273
+ if (result?.exitCode !== 0 && !result?.stdout?.includes('PR_SKIP')) {
274
+ spinner.fail(chalk_1.default.red('Failed to save changes'));
275
+ console.log(chalk_1.default.dim(` Error: ${result?.stderr || 'Unknown error'}`));
276
+ return false;
277
+ }
278
+ if (result?.stdout?.includes('PR_SKIP')) {
279
+ spinner.succeed(chalk_1.default.green(`Changes pushed to branch '${branchName}'`));
280
+ console.log(chalk_1.default.dim(' Note: Could not create PR automatically. Create it manually on GitHub.'));
281
+ }
282
+ else {
283
+ spinner.succeed(chalk_1.default.green('PR created successfully'));
284
+ }
285
+ return true;
286
+ }
287
+ catch (error) {
288
+ spinner.fail(chalk_1.default.red(`Failed to save changes: ${error.message}`));
289
+ return false;
290
+ }
291
+ }
173
292
  exports.destroyCommand = new commander_1.Command('destroy')
174
293
  .alias('delete')
175
294
  .description('Destroy one or more Genboxes')
176
295
  .argument('[name]', 'Name of the Genbox to destroy (optional - will prompt if not provided)')
177
296
  .option('-y, --yes', 'Skip confirmation')
178
297
  .option('-a, --all', 'Bulk delete mode - select multiple genboxes to destroy')
298
+ .option('--save-changes <action>', 'Handle uncommitted changes: pr (commit/push/PR), trash (discard), skip (ignore)')
179
299
  .action(async (name, options) => {
180
300
  try {
181
301
  // Bulk delete mode when --all is used without a specific name
@@ -195,6 +315,28 @@ exports.destroyCommand = new commander_1.Command('destroy')
195
315
  if (!target) {
196
316
  return;
197
317
  }
318
+ // Check for uncommitted changes if genbox is running
319
+ if (target.status === 'running') {
320
+ const { proceed, action } = await handleUncommittedChanges(target, options);
321
+ if (!proceed) {
322
+ console.log(chalk_1.default.dim('Destroy cancelled.'));
323
+ return;
324
+ }
325
+ // Handle the action
326
+ if (action === 'pr') {
327
+ const success = await createPRForChanges(target);
328
+ if (!success) {
329
+ const continueAnyway = await (0, confirm_1.default)({
330
+ message: 'Failed to save changes. Continue with destroy anyway?',
331
+ default: false,
332
+ });
333
+ if (!continueAnyway) {
334
+ console.log(chalk_1.default.dim('Destroy cancelled.'));
335
+ return;
336
+ }
337
+ }
338
+ }
339
+ }
198
340
  // Confirm
199
341
  let confirmed = options.yes;
200
342
  if (!confirmed) {
@@ -567,6 +567,7 @@ exports.initCommand = new commander_1.Command('init')
567
567
  }
568
568
  if (scan.git) {
569
569
  console.log(` ${chalk_1.default.dim('Git:')} ${scan.git.remote} (${scan.git.type})`);
570
+ console.log(` ${chalk_1.default.dim('Branch:')} ${chalk_1.default.cyan(scan.git.branch || 'main')}`);
570
571
  }
571
572
  console.log('');
572
573
  // Get project name (use scan value when --from-scan)
@@ -626,6 +627,20 @@ exports.initCommand = new commander_1.Command('init')
626
627
  v4Config.defaults = {};
627
628
  }
628
629
  v4Config.defaults.size = serverSize;
630
+ // Get default branch (use detected branch or allow override)
631
+ const detectedBranch = scan.git?.branch || 'main';
632
+ let defaultBranch = detectedBranch;
633
+ if (!nonInteractive && !options.fromScan) {
634
+ const branchInput = await prompts.input({
635
+ message: 'Default branch for new environments:',
636
+ default: detectedBranch,
637
+ });
638
+ defaultBranch = branchInput || detectedBranch;
639
+ }
640
+ // Store default branch in config defaults
641
+ if (defaultBranch && defaultBranch !== 'main') {
642
+ v4Config.defaults.branch = defaultBranch;
643
+ }
629
644
  // Git repository setup - different handling for multi-repo vs single-repo
630
645
  // When using --from-scan, skip git selection and use what's in detected.yaml
631
646
  const isMultiRepo = isMultiRepoStructure;
@@ -925,13 +940,42 @@ exports.initCommand = new commander_1.Command('init')
925
940
  console.log(chalk_1.default.dim(' • default_connection: production → uses PRODUCTION_API_URL'));
926
941
  console.log(chalk_1.default.dim(' • local/no default_connection → uses LOCAL_API_URL'));
927
942
  }
943
+ // CORS Configuration Warning
944
+ const hasBackendApps = scan.apps.some(a => a.type === 'backend' || a.type === 'api');
945
+ const hasFrontendApps = scan.apps.some(a => a.type === 'frontend');
946
+ if (hasBackendApps && hasFrontendApps) {
947
+ console.log('');
948
+ console.log(chalk_1.default.yellow('=== CORS Configuration Required ==='));
949
+ console.log(chalk_1.default.white('To use genbox environments, add .genbox.dev to your backend CORS config:'));
950
+ console.log('');
951
+ console.log(chalk_1.default.dim(' NestJS (main.ts):'));
952
+ console.log(chalk_1.default.cyan(` app.enableCors({`));
953
+ console.log(chalk_1.default.cyan(` origin: [/\\.genbox\\.dev$/, ...otherOrigins],`));
954
+ console.log(chalk_1.default.cyan(` credentials: true,`));
955
+ console.log(chalk_1.default.cyan(` });`));
956
+ console.log('');
957
+ console.log(chalk_1.default.dim(' Express:'));
958
+ console.log(chalk_1.default.cyan(` app.use(cors({ origin: /\\.genbox\\.dev$/ }));`));
959
+ console.log('');
960
+ console.log(chalk_1.default.dim(' Or use env var: CORS_ORIGINS=*.genbox.dev'));
961
+ console.log('');
962
+ console.log(chalk_1.default.red(' Without this, you will see CORS errors when accessing genbox environments.'));
963
+ }
928
964
  // Next steps
929
965
  console.log('');
930
966
  console.log(chalk_1.default.bold('Next steps:'));
931
967
  console.log(chalk_1.default.dim(` 1. Review and edit ${CONFIG_FILENAME}`));
932
- console.log(chalk_1.default.dim(` 2. Update ${ENV_FILENAME} to use API URL variables where needed`));
933
- console.log(chalk_1.default.dim(` 3. Run 'genbox profiles' to see available profiles`));
934
- console.log(chalk_1.default.dim(` 4. Run 'genbox create <name> --profile <profile>' to create an environment`));
968
+ if (hasBackendApps && hasFrontendApps) {
969
+ console.log(chalk_1.default.yellow(` 2. Add .genbox.dev to your backend CORS configuration`));
970
+ console.log(chalk_1.default.dim(` 3. Update ${ENV_FILENAME} to use API URL variables where needed`));
971
+ console.log(chalk_1.default.dim(` 4. Run 'genbox profiles' to see available profiles`));
972
+ console.log(chalk_1.default.dim(` 5. Run 'genbox create <name> --profile <profile>' to create an environment`));
973
+ }
974
+ else {
975
+ console.log(chalk_1.default.dim(` 2. Update ${ENV_FILENAME} to use API URL variables where needed`));
976
+ console.log(chalk_1.default.dim(` 3. Run 'genbox profiles' to see available profiles`));
977
+ console.log(chalk_1.default.dim(` 4. Run 'genbox create <name> --profile <profile>' to create an environment`));
978
+ }
935
979
  }
936
980
  catch (error) {
937
981
  if (error.name === 'ExitPromptError' || error.message?.includes('force closed')) {
@@ -0,0 +1,576 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ var __importDefault = (this && this.__importDefault) || function (mod) {
36
+ return (mod && mod.__esModule) ? mod : { "default": mod };
37
+ };
38
+ Object.defineProperty(exports, "__esModule", { value: true });
39
+ exports.rebuildCommand = void 0;
40
+ const commander_1 = require("commander");
41
+ const prompts = __importStar(require("@inquirer/prompts"));
42
+ const chalk_1 = __importDefault(require("chalk"));
43
+ const ora_1 = __importDefault(require("ora"));
44
+ const fs = __importStar(require("fs"));
45
+ const path = __importStar(require("path"));
46
+ const os = __importStar(require("os"));
47
+ const config_loader_1 = require("../config-loader");
48
+ const profile_resolver_1 = require("../profile-resolver");
49
+ const api_1 = require("../api");
50
+ const schema_v4_1 = require("../schema-v4");
51
+ function getPublicSshKey() {
52
+ const home = os.homedir();
53
+ const potentialKeys = [
54
+ path.join(home, '.ssh', 'id_ed25519.pub'),
55
+ path.join(home, '.ssh', 'id_rsa.pub'),
56
+ ];
57
+ for (const keyPath of potentialKeys) {
58
+ if (fs.existsSync(keyPath)) {
59
+ const content = fs.readFileSync(keyPath, 'utf-8').trim();
60
+ if (content)
61
+ return content;
62
+ }
63
+ }
64
+ throw new Error('No public SSH key found in ~/.ssh/');
65
+ }
66
+ function getPrivateSshKey() {
67
+ const home = os.homedir();
68
+ const potentialKeys = [
69
+ path.join(home, '.ssh', 'id_ed25519'),
70
+ path.join(home, '.ssh', 'id_rsa'),
71
+ ];
72
+ for (const keyPath of potentialKeys) {
73
+ if (fs.existsSync(keyPath)) {
74
+ return fs.readFileSync(keyPath, 'utf-8');
75
+ }
76
+ }
77
+ return undefined;
78
+ }
79
+ async function findGenboxByName(name) {
80
+ const genboxes = await (0, api_1.fetchApi)('/genboxes');
81
+ return genboxes.find((g) => g.name === name);
82
+ }
83
+ async function rebuildGenbox(id, payload) {
84
+ return (0, api_1.fetchApi)(`/genboxes/${id}/rebuild`, {
85
+ method: 'POST',
86
+ body: JSON.stringify(payload),
87
+ });
88
+ }
89
+ /**
90
+ * Parse .env.genbox file into segregated sections
91
+ */
92
+ function parseEnvGenboxSections(content) {
93
+ const sections = new Map();
94
+ let currentSection = 'GLOBAL';
95
+ let currentContent = [];
96
+ for (const line of content.split('\n')) {
97
+ const sectionMatch = line.match(/^# === ([^=]+) ===$/);
98
+ if (sectionMatch) {
99
+ if (currentContent.length > 0) {
100
+ sections.set(currentSection, currentContent.join('\n').trim());
101
+ }
102
+ currentSection = sectionMatch[1].trim();
103
+ currentContent = [];
104
+ }
105
+ else if (currentSection !== 'END') {
106
+ currentContent.push(line);
107
+ }
108
+ }
109
+ if (currentContent.length > 0 && currentSection !== 'END') {
110
+ sections.set(currentSection, currentContent.join('\n').trim());
111
+ }
112
+ return sections;
113
+ }
114
+ /**
115
+ * Build a map of service URL variables based on connection type
116
+ */
117
+ function buildServiceUrlMap(envVarsFromFile, connectTo) {
118
+ const urlMap = {};
119
+ const prefix = connectTo ? `${connectTo.toUpperCase()}_` : 'LOCAL_';
120
+ const serviceNames = new Set();
121
+ for (const key of Object.keys(envVarsFromFile)) {
122
+ const match = key.match(/^(LOCAL|STAGING|PRODUCTION)_(.+_URL)$/);
123
+ if (match) {
124
+ serviceNames.add(match[2]);
125
+ }
126
+ }
127
+ for (const serviceName of serviceNames) {
128
+ const prefixedKey = `${prefix}${serviceName}`;
129
+ const localKey = `LOCAL_${serviceName}`;
130
+ const value = envVarsFromFile[prefixedKey] || envVarsFromFile[localKey];
131
+ if (value) {
132
+ urlMap[serviceName] = value;
133
+ }
134
+ }
135
+ if (!urlMap['API_URL']) {
136
+ const apiUrl = envVarsFromFile[`${prefix}API_URL`] ||
137
+ envVarsFromFile['LOCAL_API_URL'] ||
138
+ envVarsFromFile['STAGING_API_URL'];
139
+ if (apiUrl) {
140
+ urlMap['API_URL'] = apiUrl;
141
+ }
142
+ }
143
+ return urlMap;
144
+ }
145
+ /**
146
+ * Build env content for a specific app
147
+ */
148
+ function buildAppEnvContent(sections, appName, serviceUrlMap) {
149
+ const parts = [];
150
+ const globalSection = sections.get('GLOBAL');
151
+ if (globalSection) {
152
+ parts.push(globalSection);
153
+ }
154
+ const appSection = sections.get(appName);
155
+ if (appSection) {
156
+ parts.push(appSection);
157
+ }
158
+ let envContent = parts.join('\n\n');
159
+ for (const [varName, value] of Object.entries(serviceUrlMap)) {
160
+ const pattern = new RegExp(`\\$\\{${varName}\\}`, 'g');
161
+ envContent = envContent.replace(pattern, value);
162
+ }
163
+ envContent = envContent
164
+ .split('\n')
165
+ .filter(line => {
166
+ const trimmed = line.trim();
167
+ return trimmed === '' || trimmed.includes('=') || !trimmed.startsWith('#');
168
+ })
169
+ .join('\n')
170
+ .replace(/\n{3,}/g, '\n\n')
171
+ .trim();
172
+ return envContent;
173
+ }
174
+ /**
175
+ * Build rebuild payload from resolved config
176
+ */
177
+ function buildRebuildPayload(resolved, config, publicKey, privateKey, configLoader) {
178
+ const envVars = configLoader.loadEnvVars(process.cwd());
179
+ // Build services map
180
+ const services = {};
181
+ for (const app of resolved.apps) {
182
+ if (app.services) {
183
+ for (const [name, svc] of Object.entries(app.services)) {
184
+ services[name] = { port: svc.port, healthcheck: svc.healthcheck };
185
+ }
186
+ }
187
+ else if (app.port) {
188
+ services[app.name] = { port: app.port };
189
+ }
190
+ }
191
+ // Build files bundle
192
+ const files = [];
193
+ const envFilesToMove = [];
194
+ // Process .env.genbox
195
+ const envGenboxPath = path.join(process.cwd(), '.env.genbox');
196
+ if (fs.existsSync(envGenboxPath)) {
197
+ const rawEnvContent = fs.readFileSync(envGenboxPath, 'utf-8');
198
+ const sections = parseEnvGenboxSections(rawEnvContent);
199
+ const globalSection = sections.get('GLOBAL') || '';
200
+ const envVarsFromFile = {};
201
+ for (const line of globalSection.split('\n')) {
202
+ const match = line.match(/^([A-Z_][A-Z0-9_]*)=(.*)$/);
203
+ if (match) {
204
+ let value = match[2].trim();
205
+ if ((value.startsWith('"') && value.endsWith('"')) ||
206
+ (value.startsWith("'") && value.endsWith("'"))) {
207
+ value = value.slice(1, -1);
208
+ }
209
+ envVarsFromFile[match[1]] = value;
210
+ }
211
+ }
212
+ let connectTo;
213
+ if (resolved.profile && config.profiles?.[resolved.profile]) {
214
+ const profile = config.profiles[resolved.profile];
215
+ connectTo = (0, config_loader_1.getProfileConnection)(profile);
216
+ }
217
+ const serviceUrlMap = buildServiceUrlMap(envVarsFromFile, connectTo);
218
+ if (connectTo && Object.keys(serviceUrlMap).length > 0) {
219
+ console.log(chalk_1.default.dim(` Using ${connectTo} URLs for variable expansion`));
220
+ }
221
+ for (const app of resolved.apps) {
222
+ const appPath = config.apps[app.name]?.path || app.name;
223
+ const repoPath = resolved.repos.find(r => r.name === app.name)?.path ||
224
+ (resolved.repos[0]?.path ? `${resolved.repos[0].path}/${appPath}` : `/home/dev/${config.project.name}/${appPath}`);
225
+ const servicesSections = Array.from(sections.keys()).filter(s => s.startsWith(`${app.name}/`));
226
+ if (servicesSections.length > 0) {
227
+ for (const serviceSectionName of servicesSections) {
228
+ const serviceName = serviceSectionName.split('/')[1];
229
+ const serviceEnvContent = buildAppEnvContent(sections, serviceSectionName, serviceUrlMap);
230
+ const stagingName = `${app.name}-${serviceName}.env`;
231
+ const targetPath = `${repoPath}/apps/${serviceName}/.env`;
232
+ files.push({
233
+ path: `/home/dev/.env-staging/${stagingName}`,
234
+ content: serviceEnvContent,
235
+ permissions: '0644',
236
+ });
237
+ envFilesToMove.push({ stagingName, targetPath });
238
+ }
239
+ }
240
+ else {
241
+ const appEnvContent = buildAppEnvContent(sections, app.name, serviceUrlMap);
242
+ files.push({
243
+ path: `/home/dev/.env-staging/${app.name}.env`,
244
+ content: appEnvContent,
245
+ permissions: '0644',
246
+ });
247
+ envFilesToMove.push({
248
+ stagingName: `${app.name}.env`,
249
+ targetPath: `${repoPath}/.env`,
250
+ });
251
+ }
252
+ }
253
+ }
254
+ // Generate setup script
255
+ const setupScript = generateSetupScript(resolved, config, envFilesToMove);
256
+ if (setupScript) {
257
+ files.push({
258
+ path: '/home/dev/setup-genbox.sh',
259
+ content: setupScript,
260
+ permissions: '0755',
261
+ });
262
+ }
263
+ const postDetails = [];
264
+ if (setupScript) {
265
+ postDetails.push('su - dev -c "/home/dev/setup-genbox.sh"');
266
+ }
267
+ // Build repos
268
+ const repos = {};
269
+ for (const repo of resolved.repos) {
270
+ repos[repo.name] = {
271
+ url: repo.url,
272
+ path: repo.path,
273
+ branch: repo.branch,
274
+ newBranch: repo.newBranch,
275
+ sourceBranch: repo.sourceBranch,
276
+ };
277
+ }
278
+ return {
279
+ publicKey,
280
+ services,
281
+ files,
282
+ postDetails,
283
+ repos,
284
+ privateKey,
285
+ gitToken: envVars.GIT_TOKEN,
286
+ };
287
+ }
288
+ /**
289
+ * Generate setup script
290
+ */
291
+ function generateSetupScript(resolved, config, envFilesToMove = []) {
292
+ const lines = [
293
+ '#!/bin/bash',
294
+ '# Generated by genbox rebuild',
295
+ 'set -e',
296
+ '',
297
+ ];
298
+ if (envFilesToMove.length > 0) {
299
+ lines.push('# Move .env files from staging to app directories');
300
+ for (const { stagingName, targetPath } of envFilesToMove) {
301
+ lines.push(`if [ -f "/home/dev/.env-staging/${stagingName}" ]; then`);
302
+ lines.push(` mkdir -p "$(dirname "${targetPath}")"`);
303
+ lines.push(` mv "/home/dev/.env-staging/${stagingName}" "${targetPath}"`);
304
+ lines.push(` echo "Moved .env to ${targetPath}"`);
305
+ lines.push('fi');
306
+ }
307
+ lines.push('rm -rf /home/dev/.env-staging 2>/dev/null || true');
308
+ lines.push('');
309
+ }
310
+ if (resolved.repos.length > 0) {
311
+ lines.push(`cd ${resolved.repos[0].path} || exit 1`);
312
+ lines.push('');
313
+ }
314
+ lines.push('# Install dependencies');
315
+ lines.push('if [ -f "pnpm-lock.yaml" ]; then');
316
+ lines.push(' echo "Installing dependencies with pnpm..."');
317
+ lines.push(' pnpm install --frozen-lockfile');
318
+ lines.push('elif [ -f "yarn.lock" ]; then');
319
+ lines.push(' echo "Installing dependencies with yarn..."');
320
+ lines.push(' yarn install --frozen-lockfile');
321
+ lines.push('elif [ -f "bun.lockb" ]; then');
322
+ lines.push(' echo "Installing dependencies with bun..."');
323
+ lines.push(' bun install --frozen-lockfile');
324
+ lines.push('elif [ -f "package-lock.json" ]; then');
325
+ lines.push(' echo "Installing dependencies with npm..."');
326
+ lines.push(' npm ci');
327
+ lines.push('fi');
328
+ const hasLocalApi = resolved.apps.some(a => a.name === 'api' || a.type === 'backend');
329
+ if (hasLocalApi) {
330
+ lines.push('');
331
+ lines.push('echo "Starting Docker services..."');
332
+ lines.push('if [ -f "docker-compose.yml" ] || [ -f "compose.yml" ]; then');
333
+ lines.push(' docker compose up -d');
334
+ lines.push('fi');
335
+ }
336
+ lines.push('');
337
+ lines.push('echo "Setup complete!"');
338
+ return lines.join('\n');
339
+ }
340
+ /**
341
+ * Prompt for branch options interactively
342
+ */
343
+ async function promptForBranchOptions(resolved, config) {
344
+ // Get the default branch from config or first repo
345
+ const defaultBranch = config.defaults?.branch || resolved.repos[0]?.branch || 'main';
346
+ console.log(chalk_1.default.blue('=== Branch Configuration ==='));
347
+ console.log(chalk_1.default.dim(`Default branch: ${defaultBranch}`));
348
+ console.log('');
349
+ const branchChoice = await prompts.select({
350
+ message: 'Branch option:',
351
+ choices: [
352
+ {
353
+ name: `Use default branch (${defaultBranch})`,
354
+ value: 'default',
355
+ },
356
+ {
357
+ name: 'Use a different existing branch',
358
+ value: 'existing',
359
+ },
360
+ {
361
+ name: 'Create a new branch',
362
+ value: 'new',
363
+ },
364
+ ],
365
+ default: 'default',
366
+ });
367
+ if (branchChoice === 'default') {
368
+ return resolved;
369
+ }
370
+ if (branchChoice === 'existing') {
371
+ const branchName = await prompts.input({
372
+ message: 'Enter branch name:',
373
+ default: defaultBranch,
374
+ validate: (value) => {
375
+ if (!value.trim())
376
+ return 'Branch name is required';
377
+ return true;
378
+ },
379
+ });
380
+ return {
381
+ ...resolved,
382
+ repos: resolved.repos.map(repo => ({
383
+ ...repo,
384
+ branch: branchName.trim(),
385
+ newBranch: undefined,
386
+ sourceBranch: undefined,
387
+ })),
388
+ };
389
+ }
390
+ if (branchChoice === 'new') {
391
+ const newBranchName = await prompts.input({
392
+ message: 'New branch name:',
393
+ validate: (value) => {
394
+ if (!value.trim())
395
+ return 'Branch name is required';
396
+ if (!/^[\w\-./]+$/.test(value))
397
+ return 'Invalid branch name (use letters, numbers, -, _, /, .)';
398
+ return true;
399
+ },
400
+ });
401
+ const sourceBranch = await prompts.input({
402
+ message: 'Create from branch:',
403
+ default: defaultBranch,
404
+ validate: (value) => {
405
+ if (!value.trim())
406
+ return 'Source branch is required';
407
+ return true;
408
+ },
409
+ });
410
+ return {
411
+ ...resolved,
412
+ repos: resolved.repos.map(repo => ({
413
+ ...repo,
414
+ branch: newBranchName.trim(),
415
+ newBranch: newBranchName.trim(),
416
+ sourceBranch: sourceBranch.trim(),
417
+ })),
418
+ };
419
+ }
420
+ return resolved;
421
+ }
422
+ exports.rebuildCommand = new commander_1.Command('rebuild')
423
+ .description('Rebuild an existing Genbox environment with updated configuration')
424
+ .argument('<name>', 'Name of the Genbox to rebuild')
425
+ .option('-p, --profile <profile>', 'Use a predefined profile')
426
+ .option('-b, --branch <branch>', 'Git branch to checkout')
427
+ .option('-nb, --new-branch <name>', 'Create a new branch with this name')
428
+ .option('-sb, --source-branch <branch>', 'Source branch to create new branch from')
429
+ .option('-y, --yes', 'Skip interactive prompts')
430
+ .action(async (name, options) => {
431
+ try {
432
+ // Find existing genbox
433
+ const spinner = (0, ora_1.default)(`Finding Genbox '${name}'...`).start();
434
+ let genbox;
435
+ try {
436
+ genbox = await findGenboxByName(name);
437
+ }
438
+ catch (error) {
439
+ spinner.fail(chalk_1.default.red(`Failed to find Genbox: ${error.message}`));
440
+ if (error instanceof api_1.AuthenticationError) {
441
+ console.log('');
442
+ console.log(chalk_1.default.yellow(' Please authenticate first:'));
443
+ console.log(chalk_1.default.cyan(' $ genbox login'));
444
+ }
445
+ return;
446
+ }
447
+ if (!genbox) {
448
+ spinner.fail(chalk_1.default.red(`Genbox '${name}' not found`));
449
+ return;
450
+ }
451
+ spinner.succeed(`Found Genbox '${name}'`);
452
+ // Load configuration
453
+ const configLoader = new config_loader_1.ConfigLoader();
454
+ const loadResult = await configLoader.load();
455
+ const configVersion = (0, schema_v4_1.getConfigVersion)(loadResult.config);
456
+ if (!loadResult.config || configVersion === 'unknown') {
457
+ console.error(chalk_1.default.red('No valid genbox.yaml configuration found'));
458
+ return;
459
+ }
460
+ const config = loadResult.config;
461
+ const profileResolver = new profile_resolver_1.ProfileResolver(configLoader);
462
+ // Build options for resolving
463
+ const createOptions = {
464
+ name,
465
+ profile: options.profile,
466
+ branch: options.branch,
467
+ newBranch: options.newBranch,
468
+ sourceBranch: options.sourceBranch,
469
+ yes: options.yes,
470
+ };
471
+ console.log(chalk_1.default.blue('Resolving configuration...'));
472
+ console.log('');
473
+ let resolved = await profileResolver.resolve(config, createOptions);
474
+ // Interactive branch selection if no branch was specified
475
+ if (!options.branch && !options.newBranch && !options.yes && resolved.repos.length > 0) {
476
+ resolved = await promptForBranchOptions(resolved, config);
477
+ }
478
+ // Display what will be rebuilt
479
+ console.log(chalk_1.default.bold('Rebuild Configuration:'));
480
+ console.log(chalk_1.default.dim('───────────────────────────────────────────────'));
481
+ console.log(` ${chalk_1.default.bold('Name:')} ${name}`);
482
+ console.log(` ${chalk_1.default.bold('Project:')} ${resolved.project.name}`);
483
+ if (resolved.profile) {
484
+ console.log(` ${chalk_1.default.bold('Profile:')} ${resolved.profile}`);
485
+ }
486
+ console.log('');
487
+ console.log(` ${chalk_1.default.bold('Apps:')}`);
488
+ for (const app of resolved.apps) {
489
+ console.log(` - ${app.name} (${app.type})`);
490
+ }
491
+ // Display branch info
492
+ if (resolved.repos.length > 0) {
493
+ console.log('');
494
+ console.log(` ${chalk_1.default.bold('Repos:')}`);
495
+ for (const repo of resolved.repos) {
496
+ const branchInfo = repo.newBranch
497
+ ? `${chalk_1.default.cyan(repo.newBranch)} ${chalk_1.default.dim(`(new, from ${repo.sourceBranch || 'main'})`)}`
498
+ : repo.branch
499
+ ? chalk_1.default.cyan(repo.branch)
500
+ : chalk_1.default.dim('default');
501
+ console.log(` - ${repo.name}: ${branchInfo}`);
502
+ }
503
+ }
504
+ console.log(chalk_1.default.dim('───────────────────────────────────────────────'));
505
+ // Confirm rebuild
506
+ if (!options.yes) {
507
+ console.log('');
508
+ console.log(chalk_1.default.yellow('Warning: Rebuild will reinstall the OS and rerun setup.'));
509
+ console.log(chalk_1.default.yellow('All unsaved work on the server will be lost.'));
510
+ console.log('');
511
+ const confirm = await prompts.confirm({
512
+ message: `Rebuild genbox '${name}'?`,
513
+ default: false,
514
+ });
515
+ if (!confirm) {
516
+ console.log(chalk_1.default.dim('Cancelled.'));
517
+ return;
518
+ }
519
+ }
520
+ // Get SSH keys
521
+ const publicKey = getPublicSshKey();
522
+ // Check if SSH auth is needed for git
523
+ let privateKeyContent;
524
+ const v3Config = config;
525
+ const usesSSH = v3Config.git_auth?.method === 'ssh' ||
526
+ Object.values(config.repos || {}).some(r => r.auth === 'ssh');
527
+ if (usesSSH && !options.yes) {
528
+ const injectKey = await prompts.confirm({
529
+ message: 'Inject SSH private key for git cloning?',
530
+ default: true,
531
+ });
532
+ if (injectKey) {
533
+ privateKeyContent = getPrivateSshKey();
534
+ if (privateKeyContent) {
535
+ console.log(chalk_1.default.dim(' Using local SSH private key'));
536
+ }
537
+ }
538
+ }
539
+ // Build payload
540
+ const payload = buildRebuildPayload(resolved, config, publicKey, privateKeyContent, configLoader);
541
+ // Execute rebuild
542
+ const rebuildSpinner = (0, ora_1.default)(`Rebuilding Genbox '${name}'...`).start();
543
+ try {
544
+ await rebuildGenbox(genbox._id, payload);
545
+ rebuildSpinner.succeed(chalk_1.default.green(`Genbox '${name}' rebuild initiated!`));
546
+ console.log('');
547
+ console.log(chalk_1.default.dim('Server is rebuilding. This may take a few minutes.'));
548
+ console.log(chalk_1.default.dim('SSH connection will be temporarily unavailable.'));
549
+ console.log('');
550
+ console.log(`Run ${chalk_1.default.cyan(`genbox status ${name}`)} to check progress.`);
551
+ }
552
+ catch (error) {
553
+ rebuildSpinner.fail(chalk_1.default.red(`Failed to rebuild: ${error.message}`));
554
+ if (error instanceof api_1.AuthenticationError) {
555
+ console.log('');
556
+ console.log(chalk_1.default.yellow(' Please authenticate first:'));
557
+ console.log(chalk_1.default.cyan(' $ genbox login'));
558
+ }
559
+ }
560
+ }
561
+ catch (error) {
562
+ if (error.name === 'ExitPromptError' || error.message?.includes('force closed')) {
563
+ console.log('');
564
+ console.log(chalk_1.default.dim('Cancelled.'));
565
+ return;
566
+ }
567
+ if (error instanceof api_1.AuthenticationError) {
568
+ console.log(chalk_1.default.red('Not logged in'));
569
+ console.log('');
570
+ console.log(chalk_1.default.yellow(' Please authenticate first:'));
571
+ console.log(chalk_1.default.cyan(' $ genbox login'));
572
+ return;
573
+ }
574
+ console.error(chalk_1.default.red(`Error: ${error.message}`));
575
+ }
576
+ });
@@ -656,10 +656,11 @@ function showSummary(detected) {
656
656
  console.log(` ${chalk_1.default.cyan(infra.name)}: ${infra.type} (${infra.image})`);
657
657
  }
658
658
  }
659
- // Git (root level)
659
+ // Git (root level) - show branch prominently
660
660
  if (detected.git?.remote) {
661
661
  console.log(`\n Git: ${detected.git.provider || 'git'} (${detected.git.type})`);
662
- console.log(chalk_1.default.dim(` Branch: ${detected.git.branch || 'unknown'}`));
662
+ console.log(` Remote: ${chalk_1.default.dim(detected.git.remote)}`);
663
+ console.log(` Branch: ${chalk_1.default.cyan(detected.git.branch || 'main')} ${chalk_1.default.dim('← default branch for new environments')}`);
663
664
  }
664
665
  // Per-app git repos (for multi-repo workspaces)
665
666
  const appsWithGit = Object.entries(detected.apps).filter(([, app]) => app.git);
@@ -669,6 +670,7 @@ function showSummary(detected) {
669
670
  const git = app.git;
670
671
  console.log(` ${chalk_1.default.cyan(name)}: ${git.provider} (${git.type})`);
671
672
  console.log(chalk_1.default.dim(` ${git.remote}`));
673
+ console.log(` Branch: ${chalk_1.default.cyan(git.branch || 'main')}`);
672
674
  }
673
675
  }
674
676
  // Service URLs (for staging URL configuration)
package/dist/index.js CHANGED
@@ -30,6 +30,7 @@ const resolve_1 = require("./commands/resolve");
30
30
  const validate_1 = require("./commands/validate");
31
31
  const migrate_1 = require("./commands/migrate");
32
32
  const ssh_setup_1 = require("./commands/ssh-setup");
33
+ const rebuild_1 = require("./commands/rebuild");
33
34
  program
34
35
  .addCommand(init_1.initCommand)
35
36
  .addCommand(create_1.createCommand)
@@ -52,5 +53,6 @@ program
52
53
  .addCommand(validate_1.validateCommand)
53
54
  .addCommand(migrate_1.migrateCommand)
54
55
  .addCommand(migrate_1.deprecationsCommand)
55
- .addCommand(ssh_setup_1.sshSetupCommand);
56
+ .addCommand(ssh_setup_1.sshSetupCommand)
57
+ .addCommand(rebuild_1.rebuildCommand);
56
58
  program.parse(process.argv);
@@ -114,7 +114,7 @@ class ProfileResolver {
114
114
  apps,
115
115
  infrastructure,
116
116
  database,
117
- repos: this.resolveRepos(config, apps),
117
+ repos: this.resolveRepos(config, apps, options),
118
118
  env: this.resolveEnvVars(config, apps, infrastructure, database, (0, config_loader_1.getProfileConnection)(profile)),
119
119
  hooks: config.hooks || {},
120
120
  profile: options.profile,
@@ -430,9 +430,13 @@ class ProfileResolver {
430
430
  /**
431
431
  * Resolve repositories to clone
432
432
  */
433
- resolveRepos(config, apps) {
433
+ resolveRepos(config, apps, options) {
434
434
  const repos = [];
435
435
  const seen = new Set();
436
+ // Determine the effective branch to use
437
+ // Priority: newBranch > branch (CLI) > defaults.branch (config) > repo.branch
438
+ const effectiveBranch = options.newBranch || options.branch;
439
+ const defaultBranch = config.defaults?.branch;
436
440
  for (const app of apps) {
437
441
  const appConfig = config.apps[app.name];
438
442
  // Check if app has specific repo field
@@ -443,7 +447,10 @@ class ProfileResolver {
443
447
  name: appConfig.repo,
444
448
  url: repoConfig.url,
445
449
  path: repoConfig.path,
446
- branch: repoConfig.branch,
450
+ branch: effectiveBranch || repoConfig.branch || defaultBranch,
451
+ // Track branch creation info for new branches
452
+ newBranch: options.newBranch,
453
+ sourceBranch: options.newBranch ? (options.sourceBranch || repoConfig.branch || defaultBranch || 'main') : undefined,
447
454
  });
448
455
  seen.add(repoConfig.url);
449
456
  }
@@ -456,7 +463,9 @@ class ProfileResolver {
456
463
  name: app.name,
457
464
  url: repoConfig.url,
458
465
  path: repoConfig.path,
459
- branch: repoConfig.branch,
466
+ branch: effectiveBranch || repoConfig.branch || defaultBranch,
467
+ newBranch: options.newBranch,
468
+ sourceBranch: options.newBranch ? (options.sourceBranch || repoConfig.branch || defaultBranch || 'main') : undefined,
460
469
  });
461
470
  seen.add(repoConfig.url);
462
471
  }
@@ -470,7 +479,9 @@ class ProfileResolver {
470
479
  name: mainRepo[0],
471
480
  url: mainRepo[1].url,
472
481
  path: mainRepo[1].path,
473
- branch: mainRepo[1].branch,
482
+ branch: effectiveBranch || mainRepo[1].branch || defaultBranch,
483
+ newBranch: options.newBranch,
484
+ sourceBranch: options.newBranch ? (options.sourceBranch || mainRepo[1].branch || defaultBranch || 'main') : undefined,
474
485
  });
475
486
  }
476
487
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "genbox",
3
- "version": "1.0.18",
3
+ "version": "1.0.20",
4
4
  "description": "Genbox CLI - AI-Powered Development Environments",
5
5
  "main": "dist/index.js",
6
6
  "bin": {