genbox 1.0.101 → 1.0.103

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.
package/dist/api.js CHANGED
@@ -16,6 +16,9 @@ exports.failSnapshotUpload = failSnapshotUpload;
16
16
  exports.listProjectSnapshots = listProjectSnapshots;
17
17
  exports.getLatestSnapshot = getLatestSnapshot;
18
18
  exports.getSnapshotDownloadUrl = getSnapshotDownloadUrl;
19
+ exports.getBackup = getBackup;
20
+ exports.getLatestBackup = getLatestBackup;
21
+ exports.getBackupDownloadUrl = getBackupDownloadUrl;
19
22
  const chalk_1 = __importDefault(require("chalk"));
20
23
  const config_store_1 = require("./config-store");
21
24
  const API_URL = process.env.GENBOX_API_URL || 'https://api.genbox.dev';
@@ -165,3 +168,24 @@ async function getLatestSnapshot(projectId, source) {
165
168
  async function getSnapshotDownloadUrl(snapshotId) {
166
169
  return fetchApi(`/database-snapshots/${snapshotId}/download`);
167
170
  }
171
+ /**
172
+ * Get a backup by ID
173
+ */
174
+ async function getBackup(backupId) {
175
+ return fetchApi(`/genboxes/backups/${backupId}`);
176
+ }
177
+ /**
178
+ * Get latest backup for a genbox name
179
+ */
180
+ async function getLatestBackup(genboxName, workspace) {
181
+ const result = await fetchApi(`/genboxes/backups/latest/${encodeURIComponent(genboxName)}?workspace=${encodeURIComponent(workspace)}`);
182
+ if (result?.error)
183
+ return null;
184
+ return result;
185
+ }
186
+ /**
187
+ * Get download URL for a backup
188
+ */
189
+ async function getBackupDownloadUrl(backupId) {
190
+ return fetchApi(`/genboxes/backups/${backupId}/download`);
191
+ }
@@ -0,0 +1,410 @@
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.backupCommand = void 0;
40
+ const commander_1 = require("commander");
41
+ const chalk_1 = __importDefault(require("chalk"));
42
+ const ora_1 = __importDefault(require("ora"));
43
+ const confirm_1 = __importDefault(require("@inquirer/confirm"));
44
+ const api_1 = require("../api");
45
+ const genbox_selector_1 = require("../genbox-selector");
46
+ const child_process_1 = require("child_process");
47
+ const os = __importStar(require("os"));
48
+ const path = __importStar(require("path"));
49
+ const fs = __importStar(require("fs"));
50
+ function getPrivateSshKey() {
51
+ const home = os.homedir();
52
+ const potentialKeys = [
53
+ path.join(home, '.ssh', 'id_rsa'),
54
+ path.join(home, '.ssh', 'id_ed25519'),
55
+ ];
56
+ for (const keyPath of potentialKeys) {
57
+ if (fs.existsSync(keyPath)) {
58
+ return keyPath;
59
+ }
60
+ }
61
+ throw new Error('No SSH private key found in ~/.ssh/');
62
+ }
63
+ function sshExec(ip, keyPath, command, timeoutSecs = 300) {
64
+ const sshOpts = `-i ${keyPath} -o IdentitiesOnly=yes -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -o ConnectTimeout=10`;
65
+ try {
66
+ const result = (0, child_process_1.execSync)(`ssh ${sshOpts} dev@${ip} "${command}"`, {
67
+ encoding: 'utf8',
68
+ timeout: (timeoutSecs + 5) * 1000,
69
+ stdio: ['pipe', 'pipe', 'pipe'],
70
+ maxBuffer: 50 * 1024 * 1024, // 50MB buffer for large outputs
71
+ });
72
+ return result.trim();
73
+ }
74
+ catch (error) {
75
+ if (error.stderr)
76
+ return `ERROR: ${error.stderr.toString().trim()}`;
77
+ if (error.stdout)
78
+ return error.stdout.toString().trim();
79
+ return `ERROR: ${error.message}`;
80
+ }
81
+ }
82
+ // Generate the backup script that runs on the genbox
83
+ function generateBackupScript(uploadUrl, callbackUrl, dbContainer, dbName) {
84
+ return `
85
+ #!/bin/bash
86
+ set -e
87
+ BACKUP_DIR="/tmp/genbox-backup-\$(date +%Y%m%d%H%M%S)"
88
+ ARCHIVE="\$BACKUP_DIR/backup.tar.gz"
89
+ UPLOAD_URL="${uploadUrl}"
90
+ CALLBACK_URL="${callbackUrl}"
91
+ DB_CONTAINER="${dbContainer || ''}"
92
+ DB_NAME="${dbName || ''}"
93
+
94
+ mkdir -p "\$BACKUP_DIR/data"
95
+ cd /home/dev
96
+
97
+ echo '{"status":"starting"}' > "\$BACKUP_DIR/manifest.json"
98
+ START_TIME=\$(date +%s)
99
+
100
+ # === 1. Git repos - save uncommitted changes as patches ===
101
+ echo "=== Backing up git repos ==="
102
+ REPOS_JSON="[]"
103
+ for d in \$(find . -maxdepth 4 -name .git -type d 2>/dev/null); do
104
+ repo=\$(dirname "\$d")
105
+ repo_name=\$(basename "\$repo")
106
+ cd "\$repo"
107
+
108
+ branch=\$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "unknown")
109
+ remote_url=\$(git remote get-url origin 2>/dev/null || echo "")
110
+
111
+ has_changes="false"
112
+ patch_file=""
113
+ stash_included="false"
114
+
115
+ # Check for uncommitted changes
116
+ if [ -n "\$(git status --porcelain 2>/dev/null)" ]; then
117
+ has_changes="true"
118
+ # Create patch of all changes (staged + unstaged + untracked)
119
+ mkdir -p "\$BACKUP_DIR/data/repos/\$repo_name"
120
+
121
+ # Save diff of tracked files
122
+ git diff HEAD > "\$BACKUP_DIR/data/repos/\$repo_name/changes.patch" 2>/dev/null || true
123
+
124
+ # Save list of untracked files
125
+ git ls-files --others --exclude-standard > "\$BACKUP_DIR/data/repos/\$repo_name/untracked.txt" 2>/dev/null || true
126
+
127
+ # Archive untracked files
128
+ if [ -s "\$BACKUP_DIR/data/repos/\$repo_name/untracked.txt" ]; then
129
+ tar -czf "\$BACKUP_DIR/data/repos/\$repo_name/untracked.tar.gz" -T "\$BACKUP_DIR/data/repos/\$repo_name/untracked.txt" 2>/dev/null || true
130
+ fi
131
+
132
+ patch_file="repos/\$repo_name"
133
+ echo " Saved changes from \$repo_name (branch: \$branch)"
134
+ fi
135
+
136
+ # Add to JSON array
137
+ REPOS_JSON=\$(echo "\$REPOS_JSON" | jq --arg path "\$repo" --arg url "\$remote_url" --arg branch "\$branch" --argjson changes "\$has_changes" --arg patch "\$patch_file" --argjson stash "\$stash_included" '. + [{path: \$path, repoUrl: \$url, branch: \$branch, hadUncommittedChanges: \$changes, patchFile: \$patch, stashIncluded: \$stash}]')
138
+
139
+ cd /home/dev
140
+ done
141
+
142
+ # === 2. Database dump ===
143
+ echo "=== Backing up database ==="
144
+ DB_JSON="null"
145
+ if [ -n "\$DB_CONTAINER" ] && [ -n "\$DB_NAME" ] && docker ps --format '{{.Names}}' | grep -q "\$DB_CONTAINER"; then
146
+ mkdir -p "\$BACKUP_DIR/data/database"
147
+ echo " Dumping \$DB_NAME from \$DB_CONTAINER..."
148
+
149
+ # Use mongodump with gzip
150
+ docker exec "\$DB_CONTAINER" mongodump --db="\$DB_NAME" --gzip --archive > "\$BACKUP_DIR/data/database/dump.gz" 2>/dev/null
151
+
152
+ if [ -f "\$BACKUP_DIR/data/database/dump.gz" ]; then
153
+ DUMP_SIZE=\$(stat -c%s "\$BACKUP_DIR/data/database/dump.gz" 2>/dev/null || stat -f%z "\$BACKUP_DIR/data/database/dump.gz" 2>/dev/null || echo "0")
154
+ DB_JSON=\$(jq -n --arg name "\$DB_NAME" --arg container "\$DB_CONTAINER" --arg file "database/dump.gz" --argjson size "\$DUMP_SIZE" '{name: \$name, container: \$container, dumpFile: \$file, compressedSizeBytes: \$size}')
155
+ echo " Database dump created: \$DUMP_SIZE bytes"
156
+ fi
157
+ fi
158
+
159
+ # === 3. Config files ===
160
+ echo "=== Backing up config files ==="
161
+ mkdir -p "\$BACKUP_DIR/data/configs"
162
+ CONFIG_FILES="[]"
163
+ for f in \$(find /home/dev -maxdepth 4 -name '.env*' -type f 2>/dev/null | grep -v node_modules | grep -v .git); do
164
+ rel_path=\${f#/home/dev/}
165
+ mkdir -p "\$BACKUP_DIR/data/configs/\$(dirname "\$rel_path")"
166
+ cp "\$f" "\$BACKUP_DIR/data/configs/\$rel_path"
167
+ CONFIG_FILES=\$(echo "\$CONFIG_FILES" | jq --arg f "\$rel_path" '. + [\$f]')
168
+ echo " Saved \$rel_path"
169
+ done
170
+
171
+ # === 4. Claude history ===
172
+ echo "=== Backing up Claude history ==="
173
+ CLAUDE_INCLUDED="false"
174
+ CLAUDE_SIZE="0"
175
+ if [ -d "/home/dev/.claude" ]; then
176
+ mkdir -p "\$BACKUP_DIR/data"
177
+ tar -czf "\$BACKUP_DIR/data/claude-history.tar.gz" -C /home/dev .claude 2>/dev/null && {
178
+ CLAUDE_INCLUDED="true"
179
+ CLAUDE_SIZE=\$(stat -c%s "\$BACKUP_DIR/data/claude-history.tar.gz" 2>/dev/null || stat -f%z "\$BACKUP_DIR/data/claude-history.tar.gz" 2>/dev/null || echo "0")
180
+ echo " Claude history saved: \$CLAUDE_SIZE bytes"
181
+ }
182
+ fi
183
+
184
+ # === 5. Create final archive ===
185
+ echo "=== Creating backup archive ==="
186
+ cd "\$BACKUP_DIR/data"
187
+ tar -czf "\$ARCHIVE" . 2>/dev/null
188
+ ARCHIVE_SIZE=\$(stat -c%s "\$ARCHIVE" 2>/dev/null || stat -f%z "\$ARCHIVE" 2>/dev/null || echo "0")
189
+ echo "Archive size: \$ARCHIVE_SIZE bytes"
190
+
191
+ # === 6. Upload to S3 ===
192
+ echo "=== Uploading to S3 ==="
193
+ UPLOAD_RESULT=\$(curl -s -w "%{http_code}" -o /dev/null -X PUT -H "Content-Type: application/gzip" --data-binary "@\$ARCHIVE" "\$UPLOAD_URL")
194
+ if [ "\$UPLOAD_RESULT" != "200" ]; then
195
+ echo "Upload failed with status: \$UPLOAD_RESULT"
196
+ # Cleanup
197
+ rm -rf "\$BACKUP_DIR"
198
+ exit 1
199
+ fi
200
+ echo "Upload successful!"
201
+
202
+ # === 7. Calculate duration ===
203
+ END_TIME=\$(date +%s)
204
+ DURATION=\$((END_TIME - START_TIME))
205
+
206
+ # === 8. Send callback ===
207
+ echo "=== Sending callback ==="
208
+ CALLBACK_DATA=\$(jq -n \\
209
+ --argjson success true \\
210
+ --argjson size "\$ARCHIVE_SIZE" \\
211
+ --argjson repos "\$REPOS_JSON" \\
212
+ --argjson database "\$DB_JSON" \\
213
+ --argjson claudeIncluded "\$CLAUDE_INCLUDED" \\
214
+ --argjson claudeSize "\$CLAUDE_SIZE" \\
215
+ --argjson configs "\$CONFIG_FILES" \\
216
+ --argjson duration "\$DURATION" \\
217
+ '{success: \$success, sizeBytes: \$size, repos: \$repos, database: \$database, claudeHistoryIncluded: \$claudeIncluded, claudeHistorySizeBytes: \$claudeSize, configFiles: \$configs, durationSeconds: \$duration}')
218
+
219
+ curl -s -X POST -H "Content-Type: application/json" -d "\$CALLBACK_DATA" "\$CALLBACK_URL"
220
+
221
+ # === Cleanup ===
222
+ rm -rf "\$BACKUP_DIR"
223
+ echo ""
224
+ echo "=== Backup complete! ==="
225
+ echo "\$CALLBACK_DATA"
226
+ `;
227
+ }
228
+ exports.backupCommand = new commander_1.Command('backup')
229
+ .description('Create a backup of genbox (git changes, database, configs)')
230
+ .argument('[name]', 'Name of the Genbox (optional - will prompt)')
231
+ .option('-d, --destroy', 'Destroy genbox after successful backup')
232
+ .option('--no-database', 'Skip database backup')
233
+ .option('--no-claude', 'Skip Claude history backup')
234
+ .action(async (name, options) => {
235
+ try {
236
+ // Select genbox
237
+ const { genbox: target, cancelled } = await (0, genbox_selector_1.selectGenbox)(name, {
238
+ selectMessage: 'Select a genbox to backup:',
239
+ });
240
+ if (cancelled) {
241
+ console.log(chalk_1.default.dim('Cancelled.'));
242
+ return;
243
+ }
244
+ if (!target) {
245
+ return;
246
+ }
247
+ if (target.status !== 'running') {
248
+ console.error(chalk_1.default.red(`Error: Genbox '${target.name}' is not running (status: ${target.status})`));
249
+ return;
250
+ }
251
+ if (!target.ipAddress) {
252
+ console.error(chalk_1.default.red(`Error: Genbox '${target.name}' has no IP address`));
253
+ return;
254
+ }
255
+ // Get SSH key
256
+ let keyPath;
257
+ try {
258
+ keyPath = getPrivateSshKey();
259
+ }
260
+ catch (error) {
261
+ console.error(chalk_1.default.red(error.message));
262
+ return;
263
+ }
264
+ // Confirm backup
265
+ console.log('');
266
+ console.log(chalk_1.default.blue(`Creating backup of ${target.name}...`));
267
+ console.log(chalk_1.default.dim(` IP: ${target.ipAddress}`));
268
+ console.log(chalk_1.default.dim(` Size: ${target.size}`));
269
+ if (target.database) {
270
+ console.log(chalk_1.default.dim(` Database: ${target.database.mode || 'none'}`));
271
+ }
272
+ console.log('');
273
+ if (options.destroy) {
274
+ const confirmed = await (0, confirm_1.default)({
275
+ message: chalk_1.default.yellow('This will destroy the genbox after backup. Continue?'),
276
+ default: false,
277
+ });
278
+ if (!confirmed) {
279
+ console.log(chalk_1.default.dim('Cancelled.'));
280
+ return;
281
+ }
282
+ }
283
+ // Step 1: Create backup record via API
284
+ const spinner = (0, ora_1.default)('Creating backup record...').start();
285
+ let backupResult;
286
+ try {
287
+ backupResult = await (0, api_1.fetchApi)(`/genboxes/${target._id}/backup`, {
288
+ method: 'POST',
289
+ });
290
+ spinner.succeed(`Backup record created: ${backupResult.backupName}`);
291
+ }
292
+ catch (error) {
293
+ spinner.fail('Failed to create backup record');
294
+ throw error;
295
+ }
296
+ // Step 2: Generate backup script and execute via SSH
297
+ spinner.start('Running backup on genbox...');
298
+ // Determine database container and name
299
+ let dbContainer;
300
+ let dbName;
301
+ if (options.database !== false && target.database) {
302
+ const dbConfig = target.database;
303
+ if (dbConfig.mode === 'local' || dbConfig.mode === 'copy') {
304
+ // Try to find MongoDB container
305
+ const containers = sshExec(target.ipAddress, keyPath, "docker ps --format '{{.Names}}' | grep -i mongo | head -1", 10);
306
+ if (containers && !containers.startsWith('ERROR:')) {
307
+ dbContainer = containers.trim();
308
+ // Get database name from .env or use default
309
+ dbName = dbConfig.name || 'goodpass';
310
+ }
311
+ }
312
+ }
313
+ // Build callback URL
314
+ const callbackUrl = `${process.env.GENBOX_API_URL || 'https://api.genbox.dev'}/genboxes/backups/${backupResult.backupId}/callback?token=${target.heartbeatToken}`;
315
+ // Generate and execute backup script
316
+ const backupScript = generateBackupScript(backupResult.uploadUrl, callbackUrl, dbContainer, dbName);
317
+ // Write script to genbox and execute
318
+ const escapedScript = backupScript.replace(/'/g, "'\\''");
319
+ const runCommand = `echo '${escapedScript}' > /tmp/backup-runner.sh && chmod +x /tmp/backup-runner.sh && /tmp/backup-runner.sh`;
320
+ spinner.text = 'Running backup script (this may take a few minutes)...';
321
+ const result = sshExec(target.ipAddress, keyPath, runCommand, 600); // 10 minute timeout
322
+ if (result.startsWith('ERROR:') || result.includes('Upload failed')) {
323
+ spinner.fail('Backup failed');
324
+ console.error(chalk_1.default.red(result));
325
+ // Report failure to API
326
+ try {
327
+ await (0, api_1.fetchApi)(`/genboxes/backups/${backupResult.backupId}/callback?token=${target.heartbeatToken}`, {
328
+ method: 'POST',
329
+ body: JSON.stringify({
330
+ success: false,
331
+ error: result.substring(0, 500),
332
+ }),
333
+ });
334
+ }
335
+ catch (e) {
336
+ // Ignore callback errors
337
+ }
338
+ return;
339
+ }
340
+ spinner.succeed('Backup completed successfully!');
341
+ // Parse the result to show summary
342
+ console.log('');
343
+ console.log(chalk_1.default.green('Backup Summary:'));
344
+ // Try to extract JSON from output
345
+ const jsonMatch = result.match(/\{[\s\S]*\}$/);
346
+ if (jsonMatch) {
347
+ try {
348
+ const summary = JSON.parse(jsonMatch[0]);
349
+ console.log(chalk_1.default.dim(` Size: ${formatBytes(summary.sizeBytes || 0)}`));
350
+ console.log(chalk_1.default.dim(` Duration: ${summary.durationSeconds}s`));
351
+ if (summary.repos && summary.repos.length > 0) {
352
+ const reposWithChanges = summary.repos.filter(r => r.hadUncommittedChanges);
353
+ console.log(chalk_1.default.dim(` Repos: ${summary.repos.length} (${reposWithChanges.length} with changes)`));
354
+ }
355
+ if (summary.database) {
356
+ console.log(chalk_1.default.dim(` Database: ${summary.database.name} (${formatBytes(summary.database.compressedSizeBytes || 0)})`));
357
+ }
358
+ if (summary.claudeHistoryIncluded) {
359
+ console.log(chalk_1.default.dim(` Claude history: ${formatBytes(summary.claudeHistorySizeBytes || 0)}`));
360
+ }
361
+ if (summary.configFiles && summary.configFiles.length > 0) {
362
+ console.log(chalk_1.default.dim(` Config files: ${summary.configFiles.length}`));
363
+ }
364
+ }
365
+ catch (e) {
366
+ // Couldn't parse JSON, just show raw output
367
+ console.log(chalk_1.default.dim(result.split('\n').slice(-5).join('\n')));
368
+ }
369
+ }
370
+ console.log('');
371
+ console.log(chalk_1.default.cyan(`Backup ID: ${backupResult.backupId}`));
372
+ console.log(chalk_1.default.dim(`Restore with: gb create ${target.name} --restore ${backupResult.backupId}`));
373
+ console.log(chalk_1.default.dim(`Or restore latest: gb create ${target.name} --restore latest`));
374
+ // Step 3: Optionally destroy genbox
375
+ if (options.destroy) {
376
+ console.log('');
377
+ spinner.start('Destroying genbox...');
378
+ try {
379
+ await (0, api_1.fetchApi)(`/genboxes/${target._id}`, { method: 'DELETE' });
380
+ spinner.succeed(`Genbox ${target.name} destroyed`);
381
+ console.log(chalk_1.default.dim('Billing has stopped.'));
382
+ }
383
+ catch (error) {
384
+ spinner.fail('Failed to destroy genbox');
385
+ console.error(chalk_1.default.yellow('Backup was successful, but destroy failed. You can manually destroy with:'));
386
+ console.log(chalk_1.default.dim(` gb destroy ${target.name}`));
387
+ }
388
+ }
389
+ }
390
+ catch (error) {
391
+ if (error.name === 'ExitPromptError' || error.message?.includes('force closed')) {
392
+ console.log('');
393
+ console.log(chalk_1.default.dim('Cancelled.'));
394
+ return;
395
+ }
396
+ if (error instanceof api_1.AuthenticationError) {
397
+ (0, api_1.handleApiError)(error);
398
+ return;
399
+ }
400
+ console.error(chalk_1.default.red(`Error: ${error.message}`));
401
+ }
402
+ });
403
+ function formatBytes(bytes) {
404
+ if (bytes === 0)
405
+ return '0 B';
406
+ const k = 1024;
407
+ const sizes = ['B', 'KB', 'MB', 'GB'];
408
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
409
+ return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`;
410
+ }
@@ -0,0 +1,193 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.backupsCommand = void 0;
7
+ const commander_1 = require("commander");
8
+ const chalk_1 = __importDefault(require("chalk"));
9
+ const api_1 = require("../api");
10
+ function formatBytes(bytes) {
11
+ if (bytes === 0)
12
+ return '0 B';
13
+ const k = 1024;
14
+ const sizes = ['B', 'KB', 'MB', 'GB'];
15
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
16
+ return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`;
17
+ }
18
+ function formatDate(dateStr) {
19
+ const date = new Date(dateStr);
20
+ const now = new Date();
21
+ const diffMs = now.getTime() - date.getTime();
22
+ const diffMins = Math.floor(diffMs / 60000);
23
+ const diffHours = Math.floor(diffMs / 3600000);
24
+ const diffDays = Math.floor(diffMs / 86400000);
25
+ if (diffMins < 1)
26
+ return 'just now';
27
+ if (diffMins < 60)
28
+ return `${diffMins}m ago`;
29
+ if (diffHours < 24)
30
+ return `${diffHours}h ago`;
31
+ if (diffDays < 7)
32
+ return `${diffDays}d ago`;
33
+ return date.toLocaleDateString('en-US', {
34
+ month: 'short',
35
+ day: 'numeric',
36
+ year: date.getFullYear() !== now.getFullYear() ? 'numeric' : undefined,
37
+ });
38
+ }
39
+ function getStatusColor(status) {
40
+ switch (status) {
41
+ case 'completed': return chalk_1.default.green;
42
+ case 'pending':
43
+ case 'uploading': return chalk_1.default.yellow;
44
+ case 'failed': return chalk_1.default.red;
45
+ default: return chalk_1.default.white;
46
+ }
47
+ }
48
+ exports.backupsCommand = new commander_1.Command('backups')
49
+ .description('List and manage genbox backups')
50
+ .option('-n, --name <genboxName>', 'Filter by genbox name')
51
+ .option('-l, --limit <number>', 'Number of backups to show', '20')
52
+ .option('--json', 'Output as JSON')
53
+ .action(async (options) => {
54
+ try {
55
+ // Build query params
56
+ const params = new URLSearchParams();
57
+ if (options.name)
58
+ params.set('genboxName', options.name);
59
+ if (options.limit)
60
+ params.set('limit', options.limit);
61
+ const result = await (0, api_1.fetchApi)(`/genboxes/backups?${params.toString()}`);
62
+ if (options.json) {
63
+ console.log(JSON.stringify(result, null, 2));
64
+ return;
65
+ }
66
+ if (result.backups.length === 0) {
67
+ console.log(chalk_1.default.dim('No backups found.'));
68
+ console.log('');
69
+ console.log(chalk_1.default.dim('Create a backup with: gb backup <genbox-name>'));
70
+ return;
71
+ }
72
+ console.log(chalk_1.default.blue(`Found ${result.total} backup${result.total !== 1 ? 's' : ''}:`));
73
+ console.log('');
74
+ // Simple list - one backup per genbox
75
+ for (const backup of result.backups) {
76
+ const statusColor = getStatusColor(backup.status);
77
+ const statusIcon = backup.status === 'completed' ? '✓' : backup.status === 'failed' ? '✗' : '○';
78
+ const statusBadge = statusColor(statusIcon);
79
+ // Size info
80
+ const sizeStr = backup.sizeBytes ? formatBytes(backup.sizeBytes) : '-';
81
+ // Date
82
+ const dateStr = formatDate(backup.createdAt);
83
+ // Contents summary
84
+ const contents = [];
85
+ if (backup.repos && backup.repos.some(r => r.hadUncommittedChanges)) {
86
+ contents.push('changes');
87
+ }
88
+ if (backup.database) {
89
+ contents.push('db');
90
+ }
91
+ if (backup.claudeHistoryIncluded) {
92
+ contents.push('claude');
93
+ }
94
+ const contentsStr = contents.length > 0 ? chalk_1.default.dim(`[${contents.join(', ')}]`) : '';
95
+ console.log(` ${statusBadge} ${chalk_1.default.cyan(backup.sourceGenboxName)} ${sizeStr} ${dateStr} ${contentsStr}`);
96
+ }
97
+ // Show restore hint
98
+ console.log('');
99
+ console.log(chalk_1.default.dim('─'.repeat(50)));
100
+ console.log(chalk_1.default.dim('Restore with: gb create <name> --restore'));
101
+ }
102
+ catch (error) {
103
+ if (error instanceof api_1.AuthenticationError) {
104
+ (0, api_1.handleApiError)(error);
105
+ return;
106
+ }
107
+ console.error(chalk_1.default.red(`Error: ${error.message}`));
108
+ }
109
+ });
110
+ // Subcommand to delete a backup by genbox name
111
+ exports.backupsCommand
112
+ .command('delete <genboxName>')
113
+ .description('Delete backup for a genbox')
114
+ .action(async (genboxName) => {
115
+ try {
116
+ // Find backup by genbox name first
117
+ const result = await (0, api_1.fetchApi)(`/genboxes/backups?genboxName=${encodeURIComponent(genboxName)}&limit=1`);
118
+ if (result.backups.length === 0) {
119
+ console.log(chalk_1.default.yellow(`No backup found for '${genboxName}'`));
120
+ return;
121
+ }
122
+ const backup = result.backups[0];
123
+ await (0, api_1.fetchApi)(`/genboxes/backups/${backup._id}`, { method: 'DELETE' });
124
+ console.log(chalk_1.default.green(`Backup for '${genboxName}' deleted.`));
125
+ }
126
+ catch (error) {
127
+ if (error instanceof api_1.AuthenticationError) {
128
+ (0, api_1.handleApiError)(error);
129
+ return;
130
+ }
131
+ console.error(chalk_1.default.red(`Error: ${error.message}`));
132
+ }
133
+ });
134
+ // Subcommand to show backup details by genbox name
135
+ exports.backupsCommand
136
+ .command('info <genboxName>')
137
+ .description('Show detailed info about a genbox backup')
138
+ .action(async (genboxName) => {
139
+ try {
140
+ // Find backup by genbox name
141
+ const result = await (0, api_1.fetchApi)(`/genboxes/backups?genboxName=${encodeURIComponent(genboxName)}&limit=1`);
142
+ if (result.backups.length === 0) {
143
+ console.log(chalk_1.default.yellow(`No backup found for '${genboxName}'`));
144
+ return;
145
+ }
146
+ const backup = result.backups[0];
147
+ console.log(chalk_1.default.blue(`Backup for: ${backup.sourceGenboxName}`));
148
+ console.log('');
149
+ console.log(`Status: ${getStatusColor(backup.status)(backup.status)}`);
150
+ console.log(`Created: ${new Date(backup.createdAt).toLocaleString()}`);
151
+ if (backup.durationSeconds) {
152
+ console.log(`Duration: ${backup.durationSeconds}s`);
153
+ }
154
+ console.log(`Size: ${backup.sizeBytes ? formatBytes(backup.sizeBytes) : '-'}`);
155
+ console.log('');
156
+ console.log(chalk_1.default.cyan('Source Configuration:'));
157
+ console.log(` Server: ${backup.sourceSize}`);
158
+ if (backup.sourceProfile) {
159
+ console.log(` Profile: ${backup.sourceProfile}`);
160
+ }
161
+ if (backup.sourceApps && backup.sourceApps.length > 0) {
162
+ console.log(` Apps: ${backup.sourceApps.join(', ')}`);
163
+ }
164
+ console.log('');
165
+ if (backup.repos && backup.repos.length > 0) {
166
+ console.log(chalk_1.default.cyan('Repos:'));
167
+ for (const repo of backup.repos) {
168
+ const changes = repo.hadUncommittedChanges ? chalk_1.default.yellow(' (has changes)') : '';
169
+ console.log(` ${repo.path}${changes}`);
170
+ }
171
+ console.log('');
172
+ }
173
+ if (backup.database) {
174
+ console.log(chalk_1.default.cyan('Database:'));
175
+ console.log(` Name: ${backup.database.name}`);
176
+ console.log('');
177
+ }
178
+ if (backup.claudeHistoryIncluded) {
179
+ console.log(chalk_1.default.cyan('Claude history: ') + chalk_1.default.green('included'));
180
+ console.log('');
181
+ }
182
+ console.log(chalk_1.default.cyan('Usage:'));
183
+ console.log(` Restore: gb create ${backup.sourceGenboxName} --restore`);
184
+ console.log(` Delete: gb backups delete ${backup.sourceGenboxName}`);
185
+ }
186
+ catch (error) {
187
+ if (error instanceof api_1.AuthenticationError) {
188
+ (0, api_1.handleApiError)(error);
189
+ return;
190
+ }
191
+ console.error(chalk_1.default.red(`Error: ${error.message}`));
192
+ }
193
+ });