genbox 1.0.101 → 1.0.102
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 +24 -0
- package/dist/commands/backup.js +410 -0
- package/dist/commands/backups.js +193 -0
- package/dist/commands/create.js +254 -4
- package/dist/index.js +5 -1
- package/package.json +1 -1
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
|
+
});
|
package/dist/commands/create.js
CHANGED
|
@@ -81,6 +81,151 @@ function spawnSshConfigSetup(genboxId, name) {
|
|
|
81
81
|
// Allow parent to exit independently
|
|
82
82
|
child.unref();
|
|
83
83
|
}
|
|
84
|
+
/**
|
|
85
|
+
* Wait for genbox to reach a specific status
|
|
86
|
+
*/
|
|
87
|
+
async function waitForGenboxStatus(genboxId, targetStatus, maxAttempts = 120, // 10 minutes with 5s interval
|
|
88
|
+
intervalMs = 5000) {
|
|
89
|
+
for (let i = 0; i < maxAttempts; i++) {
|
|
90
|
+
try {
|
|
91
|
+
const genbox = await (0, api_1.fetchApi)(`/genboxes/${genboxId}`);
|
|
92
|
+
if (targetStatus.includes(genbox.status)) {
|
|
93
|
+
return { success: true, genbox };
|
|
94
|
+
}
|
|
95
|
+
if (genbox.status === 'failed' || genbox.status === 'error') {
|
|
96
|
+
return { success: false, error: `Genbox provisioning failed: ${genbox.statusMessage || 'unknown error'}` };
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
catch (error) {
|
|
100
|
+
// Ignore fetch errors and continue polling
|
|
101
|
+
}
|
|
102
|
+
await new Promise(resolve => setTimeout(resolve, intervalMs));
|
|
103
|
+
}
|
|
104
|
+
return { success: false, error: 'Timeout waiting for genbox to be ready' };
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Run restore script on genbox via SSH
|
|
108
|
+
*/
|
|
109
|
+
async function runRestore(ipAddress, downloadUrl, backup, onProgress) {
|
|
110
|
+
return new Promise((resolve) => {
|
|
111
|
+
// Build restore script
|
|
112
|
+
const restoreScript = `
|
|
113
|
+
set -e
|
|
114
|
+
|
|
115
|
+
BACKUP_DIR="/tmp/backup-restore"
|
|
116
|
+
DOWNLOAD_URL="${downloadUrl}"
|
|
117
|
+
|
|
118
|
+
echo "=== Starting Restore ==="
|
|
119
|
+
echo "Backup: ${backup.name}"
|
|
120
|
+
|
|
121
|
+
# Create temp directory
|
|
122
|
+
rm -rf "$BACKUP_DIR"
|
|
123
|
+
mkdir -p "$BACKUP_DIR"
|
|
124
|
+
cd "$BACKUP_DIR"
|
|
125
|
+
|
|
126
|
+
# Download backup
|
|
127
|
+
echo "Downloading backup..."
|
|
128
|
+
curl -sS -L -o backup.tar.gz "$DOWNLOAD_URL"
|
|
129
|
+
if [ ! -f backup.tar.gz ]; then
|
|
130
|
+
echo "ERROR: Failed to download backup"
|
|
131
|
+
exit 1
|
|
132
|
+
fi
|
|
133
|
+
|
|
134
|
+
# Extract
|
|
135
|
+
echo "Extracting backup..."
|
|
136
|
+
tar -xzf backup.tar.gz
|
|
137
|
+
rm backup.tar.gz
|
|
138
|
+
|
|
139
|
+
# Restore git patches (uncommitted changes)
|
|
140
|
+
if [ -d "repos" ]; then
|
|
141
|
+
echo "Restoring git changes..."
|
|
142
|
+
for patch in repos/*.patch; do
|
|
143
|
+
if [ -f "$patch" ]; then
|
|
144
|
+
# Extract repo path from patch filename (format: repo-path.patch)
|
|
145
|
+
repo_name=$(basename "$patch" .patch)
|
|
146
|
+
# Try to find the repo
|
|
147
|
+
if [ -d "/home/dev/$repo_name" ]; then
|
|
148
|
+
cd "/home/dev/$repo_name"
|
|
149
|
+
git apply "$BACKUP_DIR/repos/$repo_name.patch" 2>/dev/null || echo " Warning: Could not apply patch for $repo_name"
|
|
150
|
+
cd "$BACKUP_DIR"
|
|
151
|
+
echo " Applied patch: $repo_name"
|
|
152
|
+
fi
|
|
153
|
+
fi
|
|
154
|
+
done
|
|
155
|
+
fi
|
|
156
|
+
|
|
157
|
+
# Restore database
|
|
158
|
+
if [ -f "database.archive.gz" ]; then
|
|
159
|
+
echo "Restoring database..."
|
|
160
|
+
DB_NAME="${backup.database?.name || 'genbox_db'}"
|
|
161
|
+
mongorestore --gzip --archive="database.archive.gz" --nsFrom="\${DB_NAME}.*" --nsTo="\${DB_NAME}.*" --drop 2>/dev/null || {
|
|
162
|
+
# Try without namespace mapping
|
|
163
|
+
mongorestore --gzip --archive="database.archive.gz" --drop 2>/dev/null || echo " Warning: Database restore had issues"
|
|
164
|
+
}
|
|
165
|
+
echo " Database restored"
|
|
166
|
+
fi
|
|
167
|
+
|
|
168
|
+
# Restore Claude history
|
|
169
|
+
if [ -f "claude-history.tar.gz" ]; then
|
|
170
|
+
echo "Restoring Claude history..."
|
|
171
|
+
mkdir -p /home/dev/.claude
|
|
172
|
+
tar -xzf claude-history.tar.gz -C /home/dev/.claude 2>/dev/null || true
|
|
173
|
+
echo " Claude history restored"
|
|
174
|
+
fi
|
|
175
|
+
|
|
176
|
+
# Restore config files
|
|
177
|
+
if [ -f "configs.tar.gz" ]; then
|
|
178
|
+
echo "Restoring config files..."
|
|
179
|
+
tar -xzf configs.tar.gz -C /home/dev 2>/dev/null || true
|
|
180
|
+
echo " Config files restored"
|
|
181
|
+
fi
|
|
182
|
+
|
|
183
|
+
# Cleanup
|
|
184
|
+
rm -rf "$BACKUP_DIR"
|
|
185
|
+
|
|
186
|
+
echo ""
|
|
187
|
+
echo "=== Restore Complete ==="
|
|
188
|
+
echo "Run 'gb restart' to start services"
|
|
189
|
+
`;
|
|
190
|
+
const ssh = (0, child_process_1.spawn)('ssh', [
|
|
191
|
+
'-o', 'StrictHostKeyChecking=no',
|
|
192
|
+
'-o', 'UserKnownHostsFile=/dev/null',
|
|
193
|
+
'-o', 'ConnectTimeout=30',
|
|
194
|
+
`dev@${ipAddress}`,
|
|
195
|
+
restoreScript
|
|
196
|
+
]);
|
|
197
|
+
let stdout = '';
|
|
198
|
+
let stderr = '';
|
|
199
|
+
ssh.stdout.on('data', (data) => {
|
|
200
|
+
const line = data.toString();
|
|
201
|
+
stdout += line;
|
|
202
|
+
if (onProgress) {
|
|
203
|
+
// Extract meaningful progress messages
|
|
204
|
+
const lines = line.split('\n').filter((l) => l.trim());
|
|
205
|
+
for (const l of lines) {
|
|
206
|
+
if (l.startsWith('===') || l.startsWith('Downloading') || l.startsWith('Extracting') ||
|
|
207
|
+
l.startsWith('Restoring') || l.startsWith(' ')) {
|
|
208
|
+
onProgress(l.trim());
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
});
|
|
213
|
+
ssh.stderr.on('data', (data) => {
|
|
214
|
+
stderr += data.toString();
|
|
215
|
+
});
|
|
216
|
+
ssh.on('close', (code) => {
|
|
217
|
+
if (code === 0) {
|
|
218
|
+
resolve({ success: true });
|
|
219
|
+
}
|
|
220
|
+
else {
|
|
221
|
+
resolve({ success: false, error: stderr || `SSH exited with code ${code}` });
|
|
222
|
+
}
|
|
223
|
+
});
|
|
224
|
+
ssh.on('error', (error) => {
|
|
225
|
+
resolve({ success: false, error: error.message });
|
|
226
|
+
});
|
|
227
|
+
});
|
|
228
|
+
}
|
|
84
229
|
const DETECTED_DIR = '.genbox';
|
|
85
230
|
const PROJECT_CACHE_FILENAME = 'project.json';
|
|
86
231
|
/**
|
|
@@ -197,8 +342,12 @@ exports.createCommand = new commander_1.Command('create')
|
|
|
197
342
|
.option('-f, --from-branch <branch>', 'Source branch to create new branch from (defaults to config default or main)')
|
|
198
343
|
.option('-y, --yes', 'Skip interactive prompts')
|
|
199
344
|
.option('--dry-run', 'Show what would be created without actually creating')
|
|
345
|
+
.option('-r, --restore', 'Restore from backup (uses genbox name to find backup)')
|
|
200
346
|
.action(async (nameArg, options) => {
|
|
201
347
|
try {
|
|
348
|
+
// Handle restore mode
|
|
349
|
+
let restoreBackup = null;
|
|
350
|
+
let restoreDownloadUrl = null;
|
|
202
351
|
// Load configuration
|
|
203
352
|
const configLoader = new config_loader_1.ConfigLoader();
|
|
204
353
|
const loadResult = await configLoader.load();
|
|
@@ -217,6 +366,66 @@ exports.createCommand = new commander_1.Command('create')
|
|
|
217
366
|
// Support both v3 and v4 configs
|
|
218
367
|
const config = loadResult.config;
|
|
219
368
|
const profileResolver = new profile_resolver_1.ProfileResolver(configLoader);
|
|
369
|
+
// Get workspace early for backup resolution
|
|
370
|
+
const workspace = config.project?.name || 'default';
|
|
371
|
+
// Handle --restore option: fetch backup info by genbox name
|
|
372
|
+
if (options.restore) {
|
|
373
|
+
// Require genbox name for restore
|
|
374
|
+
if (!nameArg) {
|
|
375
|
+
console.log(chalk_1.default.red('Name required for restore'));
|
|
376
|
+
console.log(chalk_1.default.dim(' Usage: gb create <genbox-name> --restore'));
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
console.log(chalk_1.default.blue('=== Restore Mode ==='));
|
|
380
|
+
console.log('');
|
|
381
|
+
const restoreSpinner = (0, ora_1.default)(`Looking for backup of '${nameArg}'...`).start();
|
|
382
|
+
try {
|
|
383
|
+
// Find backup by genbox name
|
|
384
|
+
restoreBackup = await (0, api_1.getLatestBackup)(nameArg, workspace);
|
|
385
|
+
if (!restoreBackup) {
|
|
386
|
+
restoreSpinner.fail(chalk_1.default.red(`No backup found for '${nameArg}'`));
|
|
387
|
+
console.log('');
|
|
388
|
+
console.log(chalk_1.default.dim(' Create a backup first with: gb backup ' + nameArg));
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
// Verify backup is completed
|
|
392
|
+
if (restoreBackup.status !== 'completed') {
|
|
393
|
+
restoreSpinner.fail(chalk_1.default.red(`Backup is not ready (status: ${restoreBackup.status})`));
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
// Get download URL
|
|
397
|
+
const downloadResult = await (0, api_1.getBackupDownloadUrl)(restoreBackup._id);
|
|
398
|
+
restoreDownloadUrl = downloadResult.downloadUrl;
|
|
399
|
+
restoreSpinner.succeed(chalk_1.default.green('Backup found'));
|
|
400
|
+
// Display backup details
|
|
401
|
+
console.log('');
|
|
402
|
+
console.log(` ${chalk_1.default.bold('Size:')} ${restoreBackup.sourceSize}`);
|
|
403
|
+
console.log(` ${chalk_1.default.bold('Backed up:')} ${new Date(restoreBackup.createdAt).toLocaleString()}`);
|
|
404
|
+
// Show contents
|
|
405
|
+
const contents = [];
|
|
406
|
+
if (restoreBackup.repos && restoreBackup.repos.some(r => r.hadUncommittedChanges)) {
|
|
407
|
+
contents.push('uncommitted changes');
|
|
408
|
+
}
|
|
409
|
+
if (restoreBackup.database) {
|
|
410
|
+
contents.push('database');
|
|
411
|
+
}
|
|
412
|
+
if (restoreBackup.claudeHistoryIncluded) {
|
|
413
|
+
contents.push('Claude history');
|
|
414
|
+
}
|
|
415
|
+
if (contents.length > 0) {
|
|
416
|
+
console.log(` ${chalk_1.default.bold('Contains:')} ${contents.join(', ')}`);
|
|
417
|
+
}
|
|
418
|
+
console.log('');
|
|
419
|
+
}
|
|
420
|
+
catch (error) {
|
|
421
|
+
restoreSpinner.fail(chalk_1.default.red(`Failed to fetch backup: ${error.message}`));
|
|
422
|
+
if (error instanceof api_1.AuthenticationError) {
|
|
423
|
+
console.log(chalk_1.default.yellow(' Please authenticate first:'));
|
|
424
|
+
console.log(chalk_1.default.cyan(' $ genbox login'));
|
|
425
|
+
}
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
428
|
+
}
|
|
220
429
|
// Interactive name prompt if not provided
|
|
221
430
|
let name = nameArg;
|
|
222
431
|
if (!name && !options.yes) {
|
|
@@ -233,7 +442,6 @@ exports.createCommand = new commander_1.Command('create')
|
|
|
233
442
|
selectedProfile = await promptForProfile(config.profiles);
|
|
234
443
|
}
|
|
235
444
|
// Check if name is available in workspace, add suffix if taken
|
|
236
|
-
const workspace = config.project?.name || 'default';
|
|
237
445
|
try {
|
|
238
446
|
let { available } = await (0, api_1.checkNameAvailability)(name, workspace);
|
|
239
447
|
if (!available) {
|
|
@@ -601,9 +809,51 @@ exports.createCommand = new commander_1.Command('create')
|
|
|
601
809
|
}
|
|
602
810
|
// Display results
|
|
603
811
|
displayGenboxInfo(genbox, resolved);
|
|
604
|
-
//
|
|
605
|
-
|
|
606
|
-
|
|
812
|
+
// Handle restore if backup was specified
|
|
813
|
+
if (restoreBackup && restoreDownloadUrl && genbox._id) {
|
|
814
|
+
console.log('');
|
|
815
|
+
console.log(chalk_1.default.blue('=== Restoring from Backup ==='));
|
|
816
|
+
// Wait for genbox to be ready
|
|
817
|
+
const waitSpinner = (0, ora_1.default)('Waiting for genbox to be ready...').start();
|
|
818
|
+
const waitResult = await waitForGenboxStatus(genbox._id, ['running']);
|
|
819
|
+
if (!waitResult.success) {
|
|
820
|
+
waitSpinner.fail(chalk_1.default.red(waitResult.error || 'Failed to wait for genbox'));
|
|
821
|
+
console.log(chalk_1.default.dim(' You can manually restore later: gb connect, then run the restore script'));
|
|
822
|
+
return;
|
|
823
|
+
}
|
|
824
|
+
waitSpinner.succeed(chalk_1.default.green('Genbox is ready'));
|
|
825
|
+
// Get IP address from the ready genbox
|
|
826
|
+
const readyGenbox = waitResult.genbox;
|
|
827
|
+
const ipAddress = readyGenbox?.ipAddress || genbox.ipAddress;
|
|
828
|
+
if (!ipAddress) {
|
|
829
|
+
console.log(chalk_1.default.red('Could not get genbox IP address'));
|
|
830
|
+
console.log(chalk_1.default.dim(' You can manually restore by connecting and downloading the backup'));
|
|
831
|
+
return;
|
|
832
|
+
}
|
|
833
|
+
// Run restore
|
|
834
|
+
const restoreSpinner = (0, ora_1.default)('Restoring backup...').start();
|
|
835
|
+
const restoreResult = await runRestore(ipAddress, restoreDownloadUrl, restoreBackup, (msg) => {
|
|
836
|
+
restoreSpinner.text = msg;
|
|
837
|
+
});
|
|
838
|
+
if (restoreResult.success) {
|
|
839
|
+
restoreSpinner.succeed(chalk_1.default.green('Backup restored successfully!'));
|
|
840
|
+
console.log('');
|
|
841
|
+
console.log(chalk_1.default.green('✓ Restore complete'));
|
|
842
|
+
console.log('');
|
|
843
|
+
console.log(chalk_1.default.bold('Next steps:'));
|
|
844
|
+
console.log(` 1. Connect: ${chalk_1.default.cyan(`gb connect ${name}`)}`);
|
|
845
|
+
console.log(` 2. Start services: ${chalk_1.default.cyan('gb restart')}`);
|
|
846
|
+
}
|
|
847
|
+
else {
|
|
848
|
+
restoreSpinner.fail(chalk_1.default.red(`Restore failed: ${restoreResult.error}`));
|
|
849
|
+
console.log(chalk_1.default.dim(' You can manually restore by connecting and running the restore script'));
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
else {
|
|
853
|
+
// Inform user about server provisioning
|
|
854
|
+
console.log('');
|
|
855
|
+
console.log(chalk_1.default.dim('Server is provisioning. Run `genbox connect` once ready.'));
|
|
856
|
+
}
|
|
607
857
|
}
|
|
608
858
|
catch (error) {
|
|
609
859
|
spinner.fail(chalk_1.default.red(`Failed to create Genbox: ${error.message}`));
|
package/dist/index.js
CHANGED
|
@@ -33,6 +33,8 @@ const rebuild_1 = require("./commands/rebuild");
|
|
|
33
33
|
const extend_1 = require("./commands/extend");
|
|
34
34
|
const cleanup_ssh_1 = require("./commands/cleanup-ssh");
|
|
35
35
|
const restart_1 = require("./commands/restart");
|
|
36
|
+
const backup_1 = require("./commands/backup");
|
|
37
|
+
const backups_1 = require("./commands/backups");
|
|
36
38
|
program
|
|
37
39
|
.addCommand(init_1.initCommand)
|
|
38
40
|
.addCommand(create_1.createCommand)
|
|
@@ -58,5 +60,7 @@ program
|
|
|
58
60
|
.addCommand(rebuild_1.rebuildCommand)
|
|
59
61
|
.addCommand(extend_1.extendCommand)
|
|
60
62
|
.addCommand(cleanup_ssh_1.cleanupSshCommand)
|
|
61
|
-
.addCommand(restart_1.restartCommand)
|
|
63
|
+
.addCommand(restart_1.restartCommand)
|
|
64
|
+
.addCommand(backup_1.backupCommand)
|
|
65
|
+
.addCommand(backups_1.backupsCommand);
|
|
62
66
|
program.parse(process.argv);
|