genbox 1.0.63 → 1.0.65

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.
@@ -44,130 +44,344 @@ const ora_1 = __importDefault(require("ora"));
44
44
  const fs = __importStar(require("fs"));
45
45
  const path = __importStar(require("path"));
46
46
  const os = __importStar(require("os"));
47
+ const child_process_1 = require("child_process");
47
48
  const config_loader_1 = require("../config-loader");
48
49
  const profile_resolver_1 = require("../profile-resolver");
49
50
  const api_1 = require("../api");
50
51
  const genbox_selector_1 = require("../genbox-selector");
51
52
  const schema_v4_1 = require("../schema-v4");
52
53
  const db_utils_1 = require("../db-utils");
53
- function getPublicSshKey() {
54
- const home = os.homedir();
55
- const potentialKeys = [
56
- path.join(home, '.ssh', 'id_ed25519.pub'),
57
- path.join(home, '.ssh', 'id_rsa.pub'),
58
- ];
59
- for (const keyPath of potentialKeys) {
60
- if (fs.existsSync(keyPath)) {
61
- const content = fs.readFileSync(keyPath, 'utf-8').trim();
62
- if (content)
63
- return content;
64
- }
54
+ const utils_1 = require("../utils");
55
+ // ============================================================================
56
+ // SSH Utilities for Soft Rebuild
57
+ // ============================================================================
58
+ function sshExec(ip, keyPath, command, timeoutSecs = 30) {
59
+ const sshOpts = `-i ${keyPath} -o IdentitiesOnly=yes -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -o ConnectTimeout=${timeoutSecs}`;
60
+ try {
61
+ const result = (0, child_process_1.execSync)(`ssh ${sshOpts} dev@${ip} "${command.replace(/"/g, '\\"')}"`, {
62
+ encoding: 'utf8',
63
+ timeout: (timeoutSecs + 10) * 1000,
64
+ stdio: ['pipe', 'pipe', 'pipe'],
65
+ });
66
+ return { success: true, output: result.trim() };
65
67
  }
66
- throw new Error('No public SSH key found in ~/.ssh/');
67
- }
68
- function getPrivateSshKey() {
69
- const home = os.homedir();
70
- const potentialKeys = [
71
- path.join(home, '.ssh', 'id_ed25519'),
72
- path.join(home, '.ssh', 'id_rsa'),
73
- ];
74
- for (const keyPath of potentialKeys) {
75
- if (fs.existsSync(keyPath)) {
76
- return fs.readFileSync(keyPath, 'utf-8');
77
- }
68
+ catch (error) {
69
+ const stderr = error.stderr?.toString().trim() || '';
70
+ const stdout = error.stdout?.toString().trim() || '';
71
+ return { success: false, output: stdout, error: stderr || error.message };
78
72
  }
79
- return undefined;
80
73
  }
81
- async function rebuildGenbox(id, payload) {
82
- return (0, api_1.fetchApi)(`/genboxes/${id}/rebuild`, {
83
- method: 'POST',
84
- body: JSON.stringify(payload),
74
+ async function sshExecStream(ip, keyPath, command, options = {}) {
75
+ return new Promise((resolve) => {
76
+ const sshArgs = [
77
+ '-i', keyPath,
78
+ '-o', 'IdentitiesOnly=yes',
79
+ '-o', 'StrictHostKeyChecking=no',
80
+ '-o', 'UserKnownHostsFile=/dev/null',
81
+ '-o', 'LogLevel=ERROR',
82
+ '-o', `ConnectTimeout=${options.timeoutSecs || 30}`,
83
+ `dev@${ip}`,
84
+ command,
85
+ ];
86
+ const child = (0, child_process_1.spawn)('ssh', sshArgs, { stdio: ['pipe', 'pipe', 'pipe'] });
87
+ child.stdout?.on('data', (data) => {
88
+ const lines = data.toString().split('\n');
89
+ for (const line of lines) {
90
+ if (line.trim() && options.onStdout) {
91
+ options.onStdout(line);
92
+ }
93
+ }
94
+ });
95
+ child.stderr?.on('data', (data) => {
96
+ const lines = data.toString().split('\n');
97
+ for (const line of lines) {
98
+ if (line.trim() && options.onStderr) {
99
+ options.onStderr(line);
100
+ }
101
+ }
102
+ });
103
+ child.on('close', (code) => {
104
+ resolve({ success: code === 0, code: code || 0 });
105
+ });
106
+ child.on('error', () => {
107
+ resolve({ success: false, code: -1 });
108
+ });
85
109
  });
86
110
  }
87
- /**
88
- * Parse .env.genbox file into segregated sections
89
- */
90
- function parseEnvGenboxSections(content) {
91
- const sections = new Map();
92
- let currentSection = 'GLOBAL';
93
- let currentContent = [];
94
- for (const line of content.split('\n')) {
95
- const sectionMatch = line.match(/^# === ([^=]+) ===$/);
96
- if (sectionMatch) {
97
- if (currentContent.length > 0) {
98
- sections.set(currentSection, currentContent.join('\n').trim());
99
- }
100
- currentSection = sectionMatch[1].trim();
101
- currentContent = [];
102
- }
103
- else if (currentSection !== 'END') {
104
- currentContent.push(line);
105
- }
106
- }
107
- if (currentContent.length > 0 && currentSection !== 'END') {
108
- sections.set(currentSection, currentContent.join('\n').trim());
109
- }
110
- return sections;
111
+ async function scpUpload(localPath, ip, remotePath, keyPath) {
112
+ return new Promise((resolve) => {
113
+ const scpArgs = [
114
+ '-o', 'StrictHostKeyChecking=no',
115
+ '-o', 'UserKnownHostsFile=/dev/null',
116
+ '-o', 'ConnectTimeout=30',
117
+ '-i', keyPath,
118
+ localPath,
119
+ `dev@${ip}:${remotePath}`,
120
+ ];
121
+ const child = (0, child_process_1.spawn)('scp', scpArgs, { stdio: ['ignore', 'pipe', 'pipe'] });
122
+ let stderr = '';
123
+ child.stderr?.on('data', (data) => {
124
+ stderr += data.toString();
125
+ });
126
+ child.on('close', (code) => {
127
+ if (code === 0) {
128
+ resolve({ success: true });
129
+ }
130
+ else {
131
+ resolve({ success: false, error: stderr.trim() || 'SCP failed' });
132
+ }
133
+ });
134
+ child.on('error', (err) => {
135
+ resolve({ success: false, error: err.message });
136
+ });
137
+ });
111
138
  }
112
- /**
113
- * Build a map of service URL variables based on connection type
114
- */
115
- function buildServiceUrlMap(envVarsFromFile, connectTo) {
116
- const urlMap = {};
117
- const prefix = connectTo ? `${connectTo.toUpperCase()}_` : 'LOCAL_';
118
- const serviceNames = new Set();
119
- for (const key of Object.keys(envVarsFromFile)) {
120
- const match = key.match(/^(LOCAL|STAGING|PRODUCTION)_(.+_URL)$/);
121
- if (match) {
122
- serviceNames.add(match[2]);
139
+ async function runSoftRebuild(options) {
140
+ const { genbox, resolved, config, keyPath, envFiles, snapshotId, snapshotS3Key, gitToken, onStep, onLog } = options;
141
+ const ip = genbox.ipAddress;
142
+ const log = (line, type) => {
143
+ if (onLog) {
144
+ onLog(line, type);
123
145
  }
124
- }
125
- for (const serviceName of serviceNames) {
126
- const prefixedKey = `${prefix}${serviceName}`;
127
- const localKey = `LOCAL_${serviceName}`;
128
- const value = envVarsFromFile[prefixedKey] || envVarsFromFile[localKey];
129
- if (value) {
130
- urlMap[serviceName] = value;
146
+ };
147
+ try {
148
+ // Step 1: Stop all services
149
+ onStep?.('Stopping services...');
150
+ log('Stopping PM2 processes...', 'dim');
151
+ await sshExec(ip, keyPath, 'source ~/.nvm/nvm.sh 2>/dev/null; pm2 kill 2>/dev/null || true', 30);
152
+ // Find and stop docker compose in repo directories
153
+ for (const repo of resolved.repos) {
154
+ log(`Stopping Docker Compose in ${repo.path}...`, 'dim');
155
+ await sshExec(ip, keyPath, `cd ${repo.path} 2>/dev/null && docker compose down 2>/dev/null || true`, 60);
156
+ }
157
+ // Step 2: Clean up repo directories
158
+ onStep?.('Cleaning up repositories...');
159
+ for (const repo of resolved.repos) {
160
+ log(`Removing ${repo.path}...`, 'dim');
161
+ const cleanResult = await sshExec(ip, keyPath, `rm -rf ${repo.path}`, 30);
162
+ if (!cleanResult.success) {
163
+ log(`Warning: Failed to clean ${repo.path}: ${cleanResult.error}`, 'error');
164
+ }
131
165
  }
132
- }
133
- if (!urlMap['API_URL']) {
134
- const apiUrl = envVarsFromFile[`${prefix}API_URL`] ||
135
- envVarsFromFile['LOCAL_API_URL'] ||
136
- envVarsFromFile['STAGING_API_URL'];
137
- if (apiUrl) {
138
- urlMap['API_URL'] = apiUrl;
166
+ // Step 3: Clone repositories with correct branches
167
+ onStep?.('Cloning repositories...');
168
+ for (const repo of resolved.repos) {
169
+ const repoUrl = gitToken && repo.url.startsWith('https://')
170
+ ? repo.url.replace('https://', `https://${gitToken}@`)
171
+ : repo.url;
172
+ // Determine branch to checkout
173
+ const sourceBranch = repo.sourceBranch || repo.branch || 'main';
174
+ const targetBranch = repo.newBranch || repo.branch || 'main';
175
+ log(`Cloning ${repo.name} (branch: ${sourceBranch})...`, 'info');
176
+ // Create parent directory if needed
177
+ const parentDir = path.dirname(repo.path);
178
+ await sshExec(ip, keyPath, `mkdir -p ${parentDir}`, 10);
179
+ // Clone the repo
180
+ const cloneCmd = `git clone --depth=1 --single-branch --branch ${sourceBranch} '${repoUrl}' ${repo.path}`;
181
+ const cloneResult = await sshExecStream(ip, keyPath, cloneCmd, {
182
+ onStdout: (line) => log(line, 'dim'),
183
+ onStderr: (line) => {
184
+ // Git outputs progress to stderr, filter out non-errors
185
+ if (!line.includes('Cloning into') && !line.includes('Receiving objects') && !line.includes('Resolving deltas')) {
186
+ log(line, 'dim');
187
+ }
188
+ },
189
+ timeoutSecs: 120,
190
+ });
191
+ if (!cloneResult.success) {
192
+ return { success: false, error: `Failed to clone ${repo.name}` };
193
+ }
194
+ // Create new branch if needed
195
+ if (repo.newBranch && repo.newBranch !== sourceBranch) {
196
+ log(`Creating new branch: ${repo.newBranch}`, 'info');
197
+ const branchResult = await sshExec(ip, keyPath, `cd ${repo.path} && git checkout -b ${repo.newBranch}`, 10);
198
+ if (!branchResult.success) {
199
+ log(`Warning: Failed to create branch ${repo.newBranch}: ${branchResult.error}`, 'error');
200
+ }
201
+ }
202
+ log(`✓ Cloned ${repo.name}`, 'success');
203
+ }
204
+ // Step 4: Upload and place .env files
205
+ if (envFiles.length > 0) {
206
+ onStep?.('Setting up environment files...');
207
+ // Create staging directory
208
+ await sshExec(ip, keyPath, 'mkdir -p ~/.env-staging', 10);
209
+ for (const envFile of envFiles) {
210
+ log(`Writing ${envFile.stagingName}...`, 'dim');
211
+ // Write env file content to a temp file locally, then SCP it
212
+ const tempLocalPath = path.join(os.tmpdir(), `genbox-env-${Date.now()}-${envFile.stagingName}`);
213
+ fs.writeFileSync(tempLocalPath, envFile.content);
214
+ const scpResult = await scpUpload(tempLocalPath, ip, `~/.env-staging/${envFile.stagingName}`, keyPath);
215
+ fs.unlinkSync(tempLocalPath);
216
+ if (!scpResult.success) {
217
+ log(`Warning: Failed to upload ${envFile.stagingName}: ${scpResult.error}`, 'error');
218
+ continue;
219
+ }
220
+ // Move to final location
221
+ const parentDir = path.dirname(envFile.remotePath);
222
+ await sshExec(ip, keyPath, `mkdir -p ${parentDir}`, 10);
223
+ const moveResult = await sshExec(ip, keyPath, `mv ~/.env-staging/${envFile.stagingName} ${envFile.remotePath}`, 10);
224
+ if (!moveResult.success) {
225
+ log(`Warning: Failed to move ${envFile.stagingName} to ${envFile.remotePath}`, 'error');
226
+ }
227
+ }
139
228
  }
229
+ // Step 5: Install dependencies
230
+ onStep?.('Installing dependencies...');
231
+ for (const repo of resolved.repos) {
232
+ // Check for package.json
233
+ const hasPackageJson = await sshExec(ip, keyPath, `test -f ${repo.path}/package.json && echo yes || echo no`, 10);
234
+ if (hasPackageJson.output === 'yes') {
235
+ log(`Installing npm dependencies in ${repo.name}...`, 'info');
236
+ // Determine package manager (prefer pnpm)
237
+ const hasPnpmLock = await sshExec(ip, keyPath, `test -f ${repo.path}/pnpm-lock.yaml && echo yes || echo no`, 10);
238
+ const installCmd = hasPnpmLock.output === 'yes'
239
+ ? `cd ${repo.path} && source ~/.nvm/nvm.sh && pnpm install --frozen-lockfile`
240
+ : `cd ${repo.path} && source ~/.nvm/nvm.sh && npm install`;
241
+ const installResult = await sshExecStream(ip, keyPath, installCmd, {
242
+ onStdout: (line) => {
243
+ if (line.includes('Packages:') || line.includes('added') || line.includes('Done')) {
244
+ log(line, 'dim');
245
+ }
246
+ },
247
+ timeoutSecs: 300,
248
+ });
249
+ if (!installResult.success) {
250
+ log(`Warning: npm install failed in ${repo.name}`, 'error');
251
+ }
252
+ else {
253
+ log(`✓ Dependencies installed in ${repo.name}`, 'success');
254
+ }
255
+ }
256
+ }
257
+ // Step 6: Start Docker Compose services
258
+ onStep?.('Starting Docker services...');
259
+ for (const repo of resolved.repos) {
260
+ const hasDockerCompose = await sshExec(ip, keyPath, `test -f ${repo.path}/docker-compose.yml -o -f ${repo.path}/docker-compose.yaml -o -f ${repo.path}/compose.yaml && echo yes || echo no`, 10);
261
+ if (hasDockerCompose.output === 'yes') {
262
+ log(`Starting Docker Compose in ${repo.name}...`, 'info');
263
+ const composeResult = await sshExecStream(ip, keyPath, `cd ${repo.path} && docker compose up -d`, {
264
+ onStdout: (line) => log(line, 'dim'),
265
+ timeoutSecs: 180,
266
+ });
267
+ if (!composeResult.success) {
268
+ log(`Warning: Docker Compose failed in ${repo.name}`, 'error');
269
+ }
270
+ else {
271
+ log(`✓ Docker services started in ${repo.name}`, 'success');
272
+ }
273
+ }
274
+ }
275
+ // Step 7: Restore database if snapshot provided
276
+ if (snapshotId && snapshotS3Key) {
277
+ onStep?.('Restoring database...');
278
+ log('Fetching database snapshot...', 'info');
279
+ try {
280
+ // Get download URL from API
281
+ const downloadInfo = await (0, api_1.getSnapshotDownloadUrl)(snapshotId);
282
+ // Download snapshot on the server
283
+ log('Downloading snapshot to server...', 'dim');
284
+ const downloadCmd = `curl -sL -o /tmp/db-snapshot.gz '${downloadInfo.downloadUrl}'`;
285
+ const downloadResult = await sshExecStream(ip, keyPath, downloadCmd, {
286
+ timeoutSecs: 300,
287
+ });
288
+ if (!downloadResult.success) {
289
+ log('Warning: Failed to download database snapshot', 'error');
290
+ }
291
+ else {
292
+ // Wait for MongoDB to be ready
293
+ log('Waiting for MongoDB...', 'dim');
294
+ await sshExec(ip, keyPath, `
295
+ for i in {1..30}; do
296
+ if docker ps --format '{{.Names}}' | grep -q mongodb; then
297
+ container=$(docker ps --format '{{.Names}}' | grep mongodb | head -1)
298
+ if docker exec $container mongosh --quiet --eval "db.runCommand({ping:1})" 2>/dev/null; then
299
+ break
300
+ fi
301
+ fi
302
+ sleep 2
303
+ done
304
+ `, 120);
305
+ // Get MongoDB container name and port
306
+ const containerResult = await sshExec(ip, keyPath, "docker ps --format '{{.Names}}' | grep -i mongo | head -1", 10);
307
+ const mongoContainer = containerResult.output || 'mongodb';
308
+ // Get the MongoDB port
309
+ const portResult = await sshExec(ip, keyPath, `docker port ${mongoContainer} 27017 2>/dev/null | cut -d: -f2 || echo 27017`, 10);
310
+ const mongoPort = portResult.output.trim() || '27017';
311
+ // Restore the database
312
+ log('Restoring database from snapshot...', 'info');
313
+ const restoreCmd = `mongorestore --host localhost --port ${mongoPort} --archive=/tmp/db-snapshot.gz --gzip --drop`;
314
+ const restoreResult = await sshExecStream(ip, keyPath, restoreCmd, {
315
+ onStdout: (line) => log(line, 'dim'),
316
+ timeoutSecs: 300,
317
+ });
318
+ if (!restoreResult.success) {
319
+ log('Warning: Database restore may have failed (non-zero exit)', 'error');
320
+ }
321
+ else {
322
+ log('✓ Database restored', 'success');
323
+ }
324
+ // Cleanup
325
+ await sshExec(ip, keyPath, 'rm -f /tmp/db-snapshot.gz', 10);
326
+ }
327
+ }
328
+ catch (error) {
329
+ log(`Warning: Database restore failed: ${error.message}`, 'error');
330
+ }
331
+ }
332
+ // Step 8: Start PM2 services
333
+ onStep?.('Starting application services...');
334
+ for (const app of resolved.apps) {
335
+ const appConfig = config.apps[app.name];
336
+ if (!appConfig)
337
+ continue;
338
+ // Find the repo path for this app
339
+ const appPath = appConfig.path || app.name;
340
+ const repoPath = resolved.repos.find(r => r.name === app.name)?.path ||
341
+ (resolved.repos[0]?.path ? `${resolved.repos[0].path}/${appPath}` : null);
342
+ if (!repoPath)
343
+ continue;
344
+ // Check for PM2 ecosystem file or package.json scripts
345
+ const hasPm2Config = await sshExec(ip, keyPath, `test -f ${repoPath}/ecosystem.config.js -o -f ${repoPath}/ecosystem.config.cjs && echo yes || echo no`, 10);
346
+ if (hasPm2Config.output === 'yes') {
347
+ log(`Starting ${app.name} with PM2...`, 'info');
348
+ const pm2Result = await sshExecStream(ip, keyPath, `cd ${repoPath} && source ~/.nvm/nvm.sh && pm2 start ecosystem.config.js 2>/dev/null || pm2 start ecosystem.config.cjs`, {
349
+ onStdout: (line) => log(line, 'dim'),
350
+ timeoutSecs: 60,
351
+ });
352
+ if (pm2Result.success) {
353
+ log(`✓ Started ${app.name}`, 'success');
354
+ }
355
+ }
356
+ else {
357
+ // Check for start script in package.json
358
+ const hasStartScript = await sshExec(ip, keyPath, `grep -q '"start"' ${repoPath}/package.json 2>/dev/null && echo yes || echo no`, 10);
359
+ if (hasStartScript.output === 'yes') {
360
+ log(`Starting ${app.name} with PM2 (npm start)...`, 'info');
361
+ const pm2Result = await sshExecStream(ip, keyPath, `cd ${repoPath} && source ~/.nvm/nvm.sh && pm2 start npm --name ${app.name} -- start`, {
362
+ onStdout: (line) => log(line, 'dim'),
363
+ timeoutSecs: 60,
364
+ });
365
+ if (pm2Result.success) {
366
+ log(`✓ Started ${app.name}`, 'success');
367
+ }
368
+ }
369
+ }
370
+ }
371
+ // Step 9: Save PM2 process list
372
+ log('Saving PM2 process list...', 'dim');
373
+ await sshExec(ip, keyPath, 'source ~/.nvm/nvm.sh && pm2 save 2>/dev/null || true', 10);
374
+ return { success: true };
140
375
  }
141
- return urlMap;
142
- }
143
- /**
144
- * Build env content for a specific app
145
- */
146
- function buildAppEnvContent(sections, appName, serviceUrlMap) {
147
- const parts = [];
148
- const globalSection = sections.get('GLOBAL');
149
- if (globalSection) {
150
- parts.push(globalSection);
151
- }
152
- const appSection = sections.get(appName);
153
- if (appSection) {
154
- parts.push(appSection);
155
- }
156
- let envContent = parts.join('\n\n');
157
- for (const [varName, value] of Object.entries(serviceUrlMap)) {
158
- const pattern = new RegExp(`\\$\\{${varName}\\}`, 'g');
159
- envContent = envContent.replace(pattern, value);
376
+ catch (error) {
377
+ return { success: false, error: error.message };
160
378
  }
161
- envContent = envContent
162
- .split('\n')
163
- .filter(line => {
164
- const trimmed = line.trim();
165
- return trimmed === '' || trimmed.includes('=') || !trimmed.startsWith('#');
166
- })
167
- .join('\n')
168
- .replace(/\n{3,}/g, '\n\n')
169
- .trim();
170
- return envContent;
379
+ }
380
+ async function rebuildGenbox(id, payload) {
381
+ return (0, api_1.fetchApi)(`/genboxes/${id}/rebuild`, {
382
+ method: 'POST',
383
+ body: JSON.stringify(payload),
384
+ });
171
385
  }
172
386
  /**
173
387
  * Build rebuild payload from resolved config
@@ -188,26 +402,15 @@ function buildRebuildPayload(resolved, config, publicKey, privateKey, configLoad
188
402
  const envGenboxPath = path.join(process.cwd(), '.env.genbox');
189
403
  if (fs.existsSync(envGenboxPath)) {
190
404
  const rawEnvContent = fs.readFileSync(envGenboxPath, 'utf-8');
191
- const sections = parseEnvGenboxSections(rawEnvContent);
405
+ const sections = (0, utils_1.parseEnvGenboxSections)(rawEnvContent);
192
406
  const globalSection = sections.get('GLOBAL') || '';
193
- const envVarsFromFile = {};
194
- for (const line of globalSection.split('\n')) {
195
- const match = line.match(/^([A-Z_][A-Z0-9_]*)=(.*)$/);
196
- if (match) {
197
- let value = match[2].trim();
198
- if ((value.startsWith('"') && value.endsWith('"')) ||
199
- (value.startsWith("'") && value.endsWith("'"))) {
200
- value = value.slice(1, -1);
201
- }
202
- envVarsFromFile[match[1]] = value;
203
- }
204
- }
407
+ const envVarsFromFile = (0, utils_1.parseEnvVarsFromSection)(globalSection);
205
408
  let connectTo;
206
409
  if (resolved.profile && config.profiles?.[resolved.profile]) {
207
410
  const profile = config.profiles[resolved.profile];
208
411
  connectTo = (0, config_loader_1.getProfileConnection)(profile);
209
412
  }
210
- const serviceUrlMap = buildServiceUrlMap(envVarsFromFile, connectTo);
413
+ const serviceUrlMap = (0, utils_1.buildServiceUrlMap)(envVarsFromFile, connectTo);
211
414
  if (connectTo && Object.keys(serviceUrlMap).length > 0) {
212
415
  console.log(chalk_1.default.dim(` Using ${connectTo} URLs for variable expansion`));
213
416
  }
@@ -219,7 +422,7 @@ function buildRebuildPayload(resolved, config, publicKey, privateKey, configLoad
219
422
  if (servicesSections.length > 0) {
220
423
  for (const serviceSectionName of servicesSections) {
221
424
  const serviceName = serviceSectionName.split('/')[1];
222
- const serviceEnvContent = buildAppEnvContent(sections, serviceSectionName, serviceUrlMap);
425
+ const serviceEnvContent = (0, utils_1.buildAppEnvContent)(sections, serviceSectionName, serviceUrlMap);
223
426
  const stagingName = `${app.name}-${serviceName}.env`;
224
427
  const targetPath = `${repoPath}/apps/${serviceName}/.env`;
225
428
  files.push({
@@ -231,7 +434,7 @@ function buildRebuildPayload(resolved, config, publicKey, privateKey, configLoad
231
434
  }
232
435
  }
233
436
  else {
234
- const appEnvContent = buildAppEnvContent(sections, app.name, serviceUrlMap);
437
+ const appEnvContent = (0, utils_1.buildAppEnvContent)(sections, app.name, serviceUrlMap);
235
438
  files.push({
236
439
  path: `/home/dev/.env-staging/${app.name}.env`,
237
440
  content: appEnvContent,
@@ -253,8 +456,6 @@ function buildRebuildPayload(resolved, config, publicKey, privateKey, configLoad
253
456
  // Build repos
254
457
  const repos = {};
255
458
  for (const repo of resolved.repos) {
256
- // DEBUG: Log repo values
257
- console.log(chalk_1.default.dim(` [DEBUG] Repo ${repo.name}: branch=${repo.branch}, newBranch=${repo.newBranch}, sourceBranch=${repo.sourceBranch}`));
258
459
  repos[repo.name] = {
259
460
  url: repo.url,
260
461
  path: repo.path,
@@ -263,6 +464,8 @@ function buildRebuildPayload(resolved, config, publicKey, privateKey, configLoad
263
464
  sourceBranch: repo.sourceBranch,
264
465
  };
265
466
  }
467
+ // Get local git config for commits
468
+ const gitConfig = (0, utils_1.getGitConfig)();
266
469
  return {
267
470
  publicKey,
268
471
  services,
@@ -271,110 +474,10 @@ function buildRebuildPayload(resolved, config, publicKey, privateKey, configLoad
271
474
  repos,
272
475
  privateKey,
273
476
  gitToken: envVars.GIT_TOKEN,
477
+ gitUserName: gitConfig.userName,
478
+ gitUserEmail: gitConfig.userEmail,
274
479
  };
275
480
  }
276
- /**
277
- * Validate git configuration and warn about missing credentials
278
- */
279
- function validateGitCredentials(repos, gitToken, privateKey) {
280
- const warnings = [];
281
- const errors = [];
282
- for (const repo of repos) {
283
- const isHttps = repo.url.startsWith('https://');
284
- const isSsh = repo.url.startsWith('git@') || repo.url.includes('ssh://');
285
- const isPrivateRepo = repo.url.includes('github.com') && !repo.url.includes('/public/');
286
- if (isHttps && !gitToken && isPrivateRepo) {
287
- warnings.push(`Repository '${repo.name}' uses HTTPS URL but GIT_TOKEN is not set.`);
288
- warnings.push(` Add GIT_TOKEN=<your-github-token> to .env.genbox for private repos.`);
289
- }
290
- if (isSsh && !privateKey) {
291
- warnings.push(`Repository '${repo.name}' uses SSH URL but no SSH key was injected.`);
292
- }
293
- }
294
- return { warnings, errors };
295
- }
296
- /**
297
- * Prompt for branch options interactively
298
- */
299
- async function promptForBranchOptions(resolved, config) {
300
- // Get the default branch from config or first repo
301
- const defaultBranch = config.defaults?.branch || resolved.repos[0]?.branch || 'main';
302
- console.log(chalk_1.default.blue('=== Branch Configuration ==='));
303
- console.log(chalk_1.default.dim(`Default branch: ${defaultBranch}`));
304
- console.log('');
305
- const branchChoice = await prompts.select({
306
- message: 'Branch option:',
307
- choices: [
308
- {
309
- name: `Use default branch (${defaultBranch})`,
310
- value: 'default',
311
- },
312
- {
313
- name: 'Use a different existing branch',
314
- value: 'existing',
315
- },
316
- {
317
- name: 'Create a new branch',
318
- value: 'new',
319
- },
320
- ],
321
- default: 'default',
322
- });
323
- if (branchChoice === 'default') {
324
- return resolved;
325
- }
326
- if (branchChoice === 'existing') {
327
- const branchName = await prompts.input({
328
- message: 'Enter branch name:',
329
- default: defaultBranch,
330
- validate: (value) => {
331
- if (!value.trim())
332
- return 'Branch name is required';
333
- return true;
334
- },
335
- });
336
- return {
337
- ...resolved,
338
- repos: resolved.repos.map(repo => ({
339
- ...repo,
340
- branch: branchName.trim(),
341
- newBranch: undefined,
342
- sourceBranch: undefined,
343
- })),
344
- };
345
- }
346
- if (branchChoice === 'new') {
347
- const newBranchName = await prompts.input({
348
- message: 'New branch name:',
349
- validate: (value) => {
350
- if (!value.trim())
351
- return 'Branch name is required';
352
- if (!/^[\w\-./]+$/.test(value))
353
- return 'Invalid branch name (use letters, numbers, -, _, /, .)';
354
- return true;
355
- },
356
- });
357
- const sourceBranch = await prompts.input({
358
- message: 'Create from branch:',
359
- default: defaultBranch,
360
- validate: (value) => {
361
- if (!value.trim())
362
- return 'Source branch is required';
363
- return true;
364
- },
365
- });
366
- return {
367
- ...resolved,
368
- repos: resolved.repos.map(repo => ({
369
- ...repo,
370
- branch: newBranchName.trim(),
371
- newBranch: newBranchName.trim(),
372
- sourceBranch: sourceBranch.trim(),
373
- })),
374
- };
375
- }
376
- return resolved;
377
- }
378
481
  exports.rebuildCommand = new commander_1.Command('rebuild')
379
482
  .description('Rebuild an existing Genbox environment with updated configuration')
380
483
  .argument('[name]', 'Name of the Genbox to rebuild (optional - will prompt if not provided)')
@@ -387,6 +490,7 @@ exports.rebuildCommand = new commander_1.Command('rebuild')
387
490
  .option('--db-source <source>', 'Database source for copy mode: staging, production')
388
491
  .option('--db-dump <path>', 'Path to existing database dump file')
389
492
  .option('-y, --yes', 'Skip interactive prompts')
493
+ .option('--hard', 'Full rebuild (reinstall OS) instead of soft rebuild')
390
494
  .action(async (name, options) => {
391
495
  try {
392
496
  // Select genbox (interactive if no name provided)
@@ -484,7 +588,7 @@ exports.rebuildCommand = new commander_1.Command('rebuild')
484
588
  // Interactive branch selection only if no branch options specified and no stored branch
485
589
  // Skip if: -b, -f, -n, stored branch, or -y
486
590
  if (!options.branch && !options.fromBranch && !storedBranch && !options.newBranch && !options.yes && resolved.repos.length > 0) {
487
- resolved = await promptForBranchOptions(resolved, config);
591
+ resolved = await (0, utils_1.promptForBranchOptionsRebuild)(resolved, config);
488
592
  }
489
593
  // Display what will be rebuilt
490
594
  console.log(chalk_1.default.bold('Rebuild Configuration:'));
@@ -522,11 +626,20 @@ exports.rebuildCommand = new commander_1.Command('rebuild')
522
626
  // Confirm rebuild
523
627
  if (!options.yes) {
524
628
  console.log('');
525
- console.log(chalk_1.default.yellow('Warning: Rebuild will reinstall the OS and rerun setup.'));
526
- console.log(chalk_1.default.yellow('All unsaved work on the server will be lost.'));
629
+ if (options.hard) {
630
+ console.log(chalk_1.default.yellow('Warning: Hard rebuild will reinstall the OS and rerun setup.'));
631
+ console.log(chalk_1.default.yellow('All unsaved work on the server will be lost.'));
632
+ }
633
+ else {
634
+ console.log(chalk_1.default.blue('Soft rebuild will:'));
635
+ console.log(chalk_1.default.dim(' • Stop services (PM2, Docker Compose)'));
636
+ console.log(chalk_1.default.dim(' • Delete and re-clone repositories'));
637
+ console.log(chalk_1.default.dim(' • Reinstall dependencies and restart services'));
638
+ console.log(chalk_1.default.yellow('Note: Uncommitted changes in repos will be lost.'));
639
+ }
527
640
  console.log('');
528
641
  const confirm = await prompts.confirm({
529
- message: `Rebuild genbox '${selectedName}'?`,
642
+ message: `${options.hard ? 'Hard rebuild' : 'Rebuild'} genbox '${selectedName}'?`,
530
643
  default: false,
531
644
  });
532
645
  if (!confirm) {
@@ -535,7 +648,7 @@ exports.rebuildCommand = new commander_1.Command('rebuild')
535
648
  }
536
649
  }
537
650
  // Get SSH keys
538
- const publicKey = getPublicSshKey();
651
+ const publicKey = (0, utils_1.getPublicSshKey)();
539
652
  // Check if SSH auth is needed for git
540
653
  let privateKeyContent;
541
654
  const v3Config = config;
@@ -547,7 +660,7 @@ exports.rebuildCommand = new commander_1.Command('rebuild')
547
660
  default: true,
548
661
  });
549
662
  if (injectKey) {
550
- privateKeyContent = getPrivateSshKey();
663
+ privateKeyContent = (0, utils_1.getPrivateSshKey)();
551
664
  if (privateKeyContent) {
552
665
  console.log(chalk_1.default.dim(' Using local SSH private key'));
553
666
  }
@@ -674,7 +787,7 @@ exports.rebuildCommand = new commander_1.Command('rebuild')
674
787
  }
675
788
  // Validate git credentials before rebuilding
676
789
  const envVarsForValidation = configLoader.loadEnvVars(process.cwd());
677
- const gitValidation = validateGitCredentials(resolved.repos.map(r => ({ url: r.url, name: r.name })), envVarsForValidation.GIT_TOKEN, privateKeyContent);
790
+ const gitValidation = (0, utils_1.validateGitCredentials)(resolved.repos.map(r => ({ url: r.url, name: r.name })), envVarsForValidation.GIT_TOKEN, privateKeyContent);
678
791
  if (gitValidation.warnings.length > 0) {
679
792
  console.log('');
680
793
  console.log(chalk_1.default.yellow('⚠ Git Authentication Warnings:'));
@@ -698,6 +811,157 @@ exports.rebuildCommand = new commander_1.Command('rebuild')
698
811
  return;
699
812
  }
700
813
  }
814
+ // ================================================================
815
+ // SOFT REBUILD (default)
816
+ // ================================================================
817
+ if (!options.hard) {
818
+ // Verify server is accessible
819
+ if (!genbox.ipAddress) {
820
+ console.log(chalk_1.default.red('Error: Genbox has no IP address. It may still be provisioning.'));
821
+ console.log(chalk_1.default.dim(' Use --hard for a full rebuild, or wait for provisioning to complete.'));
822
+ return;
823
+ }
824
+ // Get SSH key path
825
+ let sshKeyPath;
826
+ try {
827
+ sshKeyPath = (0, utils_1.getPrivateSshKeyPath)();
828
+ }
829
+ catch (error) {
830
+ console.log(chalk_1.default.red(error.message));
831
+ return;
832
+ }
833
+ // Test SSH connection
834
+ console.log('');
835
+ console.log(chalk_1.default.dim('Testing SSH connection...'));
836
+ const testResult = sshExec(genbox.ipAddress, sshKeyPath, 'echo ok', 10);
837
+ if (!testResult.success || testResult.output !== 'ok') {
838
+ console.log(chalk_1.default.red('Error: Cannot connect to genbox via SSH.'));
839
+ console.log(chalk_1.default.dim(` ${testResult.error || 'Connection failed'}`));
840
+ console.log(chalk_1.default.dim(' Use --hard for a full rebuild if the server is unresponsive.'));
841
+ return;
842
+ }
843
+ console.log(chalk_1.default.green('✓ SSH connection verified'));
844
+ // Build env files for soft rebuild
845
+ const envFilesForSoftRebuild = [];
846
+ const envGenboxPath = path.join(process.cwd(), '.env.genbox');
847
+ if (fs.existsSync(envGenboxPath)) {
848
+ const rawEnvContent = fs.readFileSync(envGenboxPath, 'utf-8');
849
+ const sections = (0, utils_1.parseEnvGenboxSections)(rawEnvContent);
850
+ const globalSection = sections.get('GLOBAL') || '';
851
+ const envVarsFromFile = {};
852
+ for (const line of globalSection.split('\n')) {
853
+ const match = line.match(/^([A-Z_][A-Z0-9_]*)=(.*)$/);
854
+ if (match) {
855
+ let value = match[2].trim();
856
+ if ((value.startsWith('"') && value.endsWith('"')) ||
857
+ (value.startsWith("'") && value.endsWith("'"))) {
858
+ value = value.slice(1, -1);
859
+ }
860
+ envVarsFromFile[match[1]] = value;
861
+ }
862
+ }
863
+ let connectTo;
864
+ if (resolved.profile && config.profiles?.[resolved.profile]) {
865
+ const profile = config.profiles[resolved.profile];
866
+ connectTo = (0, config_loader_1.getProfileConnection)(profile);
867
+ }
868
+ const serviceUrlMap = (0, utils_1.buildServiceUrlMap)(envVarsFromFile, connectTo);
869
+ for (const app of resolved.apps) {
870
+ const appConfig = config.apps[app.name];
871
+ const appPath = appConfig?.path || app.name;
872
+ const repoPath = resolved.repos.find(r => r.name === app.name)?.path ||
873
+ (resolved.repos[0]?.path ? `${resolved.repos[0].path}/${appPath}` : `/home/dev/${config.project.name}/${appPath}`);
874
+ const servicesSections = Array.from(sections.keys()).filter(s => s.startsWith(`${app.name}/`));
875
+ if (servicesSections.length > 0) {
876
+ for (const serviceSectionName of servicesSections) {
877
+ const serviceName = serviceSectionName.split('/')[1];
878
+ const serviceEnvContent = (0, utils_1.buildAppEnvContent)(sections, serviceSectionName, serviceUrlMap);
879
+ envFilesForSoftRebuild.push({
880
+ stagingName: `${app.name}-${serviceName}.env`,
881
+ remotePath: `${repoPath}/apps/${serviceName}/.env`,
882
+ content: serviceEnvContent,
883
+ });
884
+ }
885
+ }
886
+ else {
887
+ const appEnvContent = (0, utils_1.buildAppEnvContent)(sections, app.name, serviceUrlMap);
888
+ envFilesForSoftRebuild.push({
889
+ stagingName: `${app.name}.env`,
890
+ remotePath: `${repoPath}/.env`,
891
+ content: appEnvContent,
892
+ });
893
+ }
894
+ }
895
+ }
896
+ // Get git token from env
897
+ const envVars = configLoader.loadEnvVars(process.cwd());
898
+ // Run soft rebuild
899
+ console.log('');
900
+ console.log(chalk_1.default.blue('=== Starting Soft Rebuild ==='));
901
+ console.log('');
902
+ let currentStep = '';
903
+ const rebuildResult = await runSoftRebuild({
904
+ genbox,
905
+ resolved,
906
+ config,
907
+ keyPath: sshKeyPath,
908
+ envFiles: envFilesForSoftRebuild,
909
+ snapshotId,
910
+ snapshotS3Key,
911
+ gitToken: envVars.GIT_TOKEN,
912
+ onStep: (step) => {
913
+ currentStep = step;
914
+ console.log(chalk_1.default.blue(`\n=== ${step} ===`));
915
+ },
916
+ onLog: (line, type) => {
917
+ switch (type) {
918
+ case 'error':
919
+ console.log(chalk_1.default.red(` ${line}`));
920
+ break;
921
+ case 'success':
922
+ console.log(chalk_1.default.green(` ${line}`));
923
+ break;
924
+ case 'info':
925
+ console.log(chalk_1.default.cyan(` ${line}`));
926
+ break;
927
+ case 'dim':
928
+ default:
929
+ console.log(chalk_1.default.dim(` ${line}`));
930
+ break;
931
+ }
932
+ },
933
+ });
934
+ console.log('');
935
+ if (rebuildResult.success) {
936
+ console.log(chalk_1.default.green(`✓ Soft rebuild completed successfully!`));
937
+ console.log('');
938
+ console.log(`Run ${chalk_1.default.cyan(`genbox status ${selectedName}`)} to check service status.`);
939
+ }
940
+ else {
941
+ console.log(chalk_1.default.red(`✗ Soft rebuild failed: ${rebuildResult.error}`));
942
+ console.log(chalk_1.default.dim(` Failed during: ${currentStep}`));
943
+ console.log(chalk_1.default.dim(` Use --hard for a full OS reinstall if needed.`));
944
+ }
945
+ // Notify API about the rebuild (for tracking)
946
+ try {
947
+ await (0, api_1.fetchApi)(`/genboxes/${genbox._id}/soft-rebuild-completed`, {
948
+ method: 'POST',
949
+ body: JSON.stringify({
950
+ success: rebuildResult.success,
951
+ branch: resolved.repos[0]?.branch,
952
+ newBranch: resolved.repos[0]?.newBranch,
953
+ sourceBranch: resolved.repos[0]?.sourceBranch,
954
+ }),
955
+ });
956
+ }
957
+ catch {
958
+ // Silently ignore API notification failures
959
+ }
960
+ return;
961
+ }
962
+ // ================================================================
963
+ // HARD REBUILD (--hard flag)
964
+ // ================================================================
701
965
  // Build payload
702
966
  const payload = buildRebuildPayload(resolved, config, publicKey, privateKeyContent, configLoader);
703
967
  // Add database info to payload if we have a snapshot
@@ -712,11 +976,11 @@ exports.rebuildCommand = new commander_1.Command('rebuild')
712
976
  else if (dbMode === 'local' || dbMode === 'fresh') {
713
977
  payload.database = { mode: 'local' };
714
978
  }
715
- // Execute rebuild
716
- const rebuildSpinner = (0, ora_1.default)(`Rebuilding Genbox '${selectedName}'...`).start();
979
+ // Execute hard rebuild via API
980
+ const rebuildSpinner = (0, ora_1.default)(`Hard rebuilding Genbox '${selectedName}'...`).start();
717
981
  try {
718
982
  await rebuildGenbox(genbox._id, payload);
719
- rebuildSpinner.succeed(chalk_1.default.green(`Genbox '${selectedName}' rebuild initiated!`));
983
+ rebuildSpinner.succeed(chalk_1.default.green(`Genbox '${selectedName}' hard rebuild initiated!`));
720
984
  console.log('');
721
985
  console.log(chalk_1.default.dim('Server is rebuilding. This may take a few minutes.'));
722
986
  console.log(chalk_1.default.dim('SSH connection will be temporarily unavailable.'));