zuppaclaude 1.3.3 → 1.3.5
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/bin/zuppaclaude.js +71 -0
- package/lib/components/backup.js +13 -8
- package/lib/components/cloud.js +280 -44
- package/lib/components/index.js +3 -1
- package/lib/components/session.js +33 -16
- package/lib/components/updater.js +216 -0
- package/package.json +1 -1
package/bin/zuppaclaude.js
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
const { Installer } = require('../lib/installer');
|
|
9
9
|
const { Settings } = require('../lib/settings');
|
|
10
10
|
const { Logger } = require('../lib/utils/logger');
|
|
11
|
+
const { UpdateManager } = require('../lib/components/updater');
|
|
11
12
|
|
|
12
13
|
const args = process.argv.slice(2);
|
|
13
14
|
const command = args[0] || 'install';
|
|
@@ -23,11 +24,26 @@ function getCloudArg(args) {
|
|
|
23
24
|
return null;
|
|
24
25
|
}
|
|
25
26
|
|
|
27
|
+
/**
|
|
28
|
+
* Check for updates at startup (non-blocking for some commands)
|
|
29
|
+
*/
|
|
30
|
+
async function checkUpdates(skipCommands = ['version', 'v', '-v', '--version', 'help', 'h', '-h', '--help', 'update']) {
|
|
31
|
+
if (skipCommands.includes(command)) {
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const updater = new UpdateManager();
|
|
36
|
+
await updater.checkAndUpdate();
|
|
37
|
+
}
|
|
38
|
+
|
|
26
39
|
async function main() {
|
|
27
40
|
const logger = new Logger();
|
|
28
41
|
|
|
29
42
|
logger.banner();
|
|
30
43
|
|
|
44
|
+
// Check for updates at startup
|
|
45
|
+
await checkUpdates();
|
|
46
|
+
|
|
31
47
|
try {
|
|
32
48
|
switch (command) {
|
|
33
49
|
case 'install':
|
|
@@ -192,6 +208,37 @@ async function main() {
|
|
|
192
208
|
}
|
|
193
209
|
break;
|
|
194
210
|
|
|
211
|
+
case 'update':
|
|
212
|
+
const updateMgr = new UpdateManager();
|
|
213
|
+
const updateCmd = args[1] || 'check';
|
|
214
|
+
|
|
215
|
+
switch (updateCmd) {
|
|
216
|
+
case 'check':
|
|
217
|
+
await updateMgr.status();
|
|
218
|
+
break;
|
|
219
|
+
case 'now':
|
|
220
|
+
case 'install':
|
|
221
|
+
const result = await updateMgr.checkForUpdates();
|
|
222
|
+
if (result.hasUpdate) {
|
|
223
|
+
await updateMgr.update();
|
|
224
|
+
} else {
|
|
225
|
+
logger.success('You are already on the latest version');
|
|
226
|
+
}
|
|
227
|
+
break;
|
|
228
|
+
case 'enable':
|
|
229
|
+
case 'on':
|
|
230
|
+
updateMgr.enableAutoUpdate();
|
|
231
|
+
break;
|
|
232
|
+
case 'disable':
|
|
233
|
+
case 'off':
|
|
234
|
+
updateMgr.disableAutoUpdate();
|
|
235
|
+
break;
|
|
236
|
+
default:
|
|
237
|
+
logger.error(`Unknown update command: ${updateCmd}`);
|
|
238
|
+
showUpdateHelp();
|
|
239
|
+
}
|
|
240
|
+
break;
|
|
241
|
+
|
|
195
242
|
case 'version':
|
|
196
243
|
case 'v':
|
|
197
244
|
case '-v':
|
|
@@ -233,6 +280,7 @@ Commands:
|
|
|
233
280
|
settings, s Manage settings
|
|
234
281
|
session Manage Claude Code sessions
|
|
235
282
|
cloud Manage cloud remotes (rclone)
|
|
283
|
+
update Check for updates and manage auto-update
|
|
236
284
|
version, v Show version
|
|
237
285
|
help, h Show this help
|
|
238
286
|
|
|
@@ -249,6 +297,12 @@ Cloud Commands:
|
|
|
249
297
|
cloud download <r> Download backups from remote
|
|
250
298
|
cloud backups <r> List cloud backups
|
|
251
299
|
|
|
300
|
+
Update Commands:
|
|
301
|
+
update Check for updates
|
|
302
|
+
update now Update to latest version
|
|
303
|
+
update enable Enable auto-update (default)
|
|
304
|
+
update disable Disable auto-update
|
|
305
|
+
|
|
252
306
|
Session Commands:
|
|
253
307
|
session list List all sessions
|
|
254
308
|
session backup Backup sessions only
|
|
@@ -262,6 +316,7 @@ Examples:
|
|
|
262
316
|
npx zuppaclaude backup --cloud gdrive # Backup to Google Drive
|
|
263
317
|
npx zuppaclaude restore 2026-01-05T12-00-00 # Restore from backup
|
|
264
318
|
npx zuppaclaude cloud setup # Configure cloud
|
|
319
|
+
npx zuppaclaude update # Check for updates
|
|
265
320
|
`);
|
|
266
321
|
}
|
|
267
322
|
|
|
@@ -321,4 +376,20 @@ Supported providers (via rclone):
|
|
|
321
376
|
`);
|
|
322
377
|
}
|
|
323
378
|
|
|
379
|
+
function showUpdateHelp() {
|
|
380
|
+
console.log(`
|
|
381
|
+
Update Commands:
|
|
382
|
+
check Check for updates (default)
|
|
383
|
+
now Update to latest version immediately
|
|
384
|
+
enable Enable auto-update at startup
|
|
385
|
+
disable Disable auto-update
|
|
386
|
+
|
|
387
|
+
Examples:
|
|
388
|
+
zuppaclaude update # Check for updates
|
|
389
|
+
zuppaclaude update now # Update immediately
|
|
390
|
+
zuppaclaude update enable # Enable auto-update
|
|
391
|
+
zuppaclaude update disable # Disable auto-update
|
|
392
|
+
`);
|
|
393
|
+
}
|
|
394
|
+
|
|
324
395
|
main();
|
package/lib/components/backup.js
CHANGED
|
@@ -40,26 +40,30 @@ class BackupManager {
|
|
|
40
40
|
this.logger.warning('No sessions to backup');
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
-
// Step 2: Backup settings to
|
|
43
|
+
// Step 2: Backup settings to settings/{timestamp} directory
|
|
44
44
|
this.logger.step('Step 2/3: Backing up ZuppaClaude settings...');
|
|
45
45
|
|
|
46
|
+
let settingsBackupPath = null;
|
|
46
47
|
if (sessionResult) {
|
|
47
|
-
const
|
|
48
|
+
const settingsDir = path.join(this.backupDir, 'settings', sessionResult.timestamp);
|
|
49
|
+
this.platform.ensureDir(settingsDir);
|
|
50
|
+
settingsBackupPath = path.join(settingsDir, 'zc-settings.json');
|
|
48
51
|
const currentSettings = this.settings.load();
|
|
49
52
|
|
|
50
53
|
if (currentSettings) {
|
|
51
54
|
fs.writeFileSync(settingsBackupPath, JSON.stringify(currentSettings, null, 2));
|
|
52
|
-
this.logger.success(
|
|
55
|
+
this.logger.success(`Settings backed up to settings/${sessionResult.timestamp}/`);
|
|
53
56
|
} else {
|
|
54
57
|
this.logger.info('No settings to backup');
|
|
55
58
|
}
|
|
56
59
|
|
|
57
|
-
// Update manifest with settings info
|
|
60
|
+
// Update manifest in sessions backup with settings info
|
|
58
61
|
const manifestPath = path.join(sessionResult.path, 'manifest.json');
|
|
59
62
|
if (fs.existsSync(manifestPath)) {
|
|
60
63
|
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
|
|
61
64
|
manifest.settings = currentSettings ? true : false;
|
|
62
65
|
manifest.backupType = 'full';
|
|
66
|
+
manifest.settingsPath = `settings/${sessionResult.timestamp}`;
|
|
63
67
|
fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2));
|
|
64
68
|
}
|
|
65
69
|
}
|
|
@@ -119,9 +123,10 @@ class BackupManager {
|
|
|
119
123
|
this.logger.info('Step 1/3: Using local backup');
|
|
120
124
|
}
|
|
121
125
|
|
|
122
|
-
const
|
|
126
|
+
const sessionsBackupPath = path.join(this.backupDir, 'sessions', backupId);
|
|
127
|
+
const settingsBackupDir = path.join(this.backupDir, 'settings', backupId);
|
|
123
128
|
|
|
124
|
-
if (!fs.existsSync(
|
|
129
|
+
if (!fs.existsSync(sessionsBackupPath)) {
|
|
125
130
|
this.logger.error(`Backup not found: ${backupId}`);
|
|
126
131
|
this.logger.info('Run "npx zuppaclaude backup list" to see available backups');
|
|
127
132
|
return false;
|
|
@@ -138,11 +143,11 @@ class BackupManager {
|
|
|
138
143
|
this.logger.info('Step 2/3: Sessions restore skipped');
|
|
139
144
|
}
|
|
140
145
|
|
|
141
|
-
// Step 3: Restore settings
|
|
146
|
+
// Step 3: Restore settings from settings/{timestamp}/
|
|
142
147
|
if (!sessionsOnly) {
|
|
143
148
|
this.logger.step('Step 3/3: Restoring settings...');
|
|
144
149
|
|
|
145
|
-
const settingsBackupPath = path.join(
|
|
150
|
+
const settingsBackupPath = path.join(settingsBackupDir, 'zc-settings.json');
|
|
146
151
|
|
|
147
152
|
if (fs.existsSync(settingsBackupPath)) {
|
|
148
153
|
try {
|
package/lib/components/cloud.js
CHANGED
|
@@ -386,7 +386,57 @@ class CloudManager {
|
|
|
386
386
|
}
|
|
387
387
|
|
|
388
388
|
/**
|
|
389
|
-
*
|
|
389
|
+
* Create zip file from directory
|
|
390
|
+
*/
|
|
391
|
+
createZip(sourceDir, zipPath) {
|
|
392
|
+
try {
|
|
393
|
+
const parentDir = path.dirname(sourceDir);
|
|
394
|
+
const folderName = path.basename(sourceDir);
|
|
395
|
+
|
|
396
|
+
if (this.platform.isWindows) {
|
|
397
|
+
// Use PowerShell on Windows
|
|
398
|
+
this.platform.exec(
|
|
399
|
+
`powershell -command "Compress-Archive -Path '${sourceDir}' -DestinationPath '${zipPath}' -Force"`,
|
|
400
|
+
{ silent: true }
|
|
401
|
+
);
|
|
402
|
+
} else {
|
|
403
|
+
// Use zip on macOS/Linux
|
|
404
|
+
this.platform.exec(
|
|
405
|
+
`cd "${parentDir}" && zip -r "${zipPath}" "${folderName}"`,
|
|
406
|
+
{ silent: true }
|
|
407
|
+
);
|
|
408
|
+
}
|
|
409
|
+
return true;
|
|
410
|
+
} catch (error) {
|
|
411
|
+
this.logger.warning(`Failed to create zip: ${error.message}`);
|
|
412
|
+
return false;
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* Extract zip file
|
|
418
|
+
*/
|
|
419
|
+
extractZip(zipPath, destDir) {
|
|
420
|
+
try {
|
|
421
|
+
this.platform.ensureDir(destDir);
|
|
422
|
+
|
|
423
|
+
if (this.platform.isWindows) {
|
|
424
|
+
this.platform.exec(
|
|
425
|
+
`powershell -command "Expand-Archive -Path '${zipPath}' -DestinationPath '${destDir}' -Force"`,
|
|
426
|
+
{ silent: true }
|
|
427
|
+
);
|
|
428
|
+
} else {
|
|
429
|
+
this.platform.exec(`unzip -o "${zipPath}" -d "${destDir}"`, { silent: true });
|
|
430
|
+
}
|
|
431
|
+
return true;
|
|
432
|
+
} catch (error) {
|
|
433
|
+
this.logger.warning(`Failed to extract zip: ${error.message}`);
|
|
434
|
+
return false;
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* Upload backup to cloud (as zip)
|
|
390
440
|
*/
|
|
391
441
|
async upload(remote, backupId = null) {
|
|
392
442
|
if (!this.isRcloneInstalled()) {
|
|
@@ -401,20 +451,16 @@ class CloudManager {
|
|
|
401
451
|
return false;
|
|
402
452
|
}
|
|
403
453
|
|
|
404
|
-
//
|
|
405
|
-
let sourcePath = this.backupDir;
|
|
406
|
-
let destPath = `${remote}:${this.cloudPath}`;
|
|
407
|
-
|
|
454
|
+
// Upload specific backup or all backups
|
|
408
455
|
if (backupId) {
|
|
409
|
-
|
|
410
|
-
if (!fs.existsSync(sourcePath)) {
|
|
411
|
-
this.logger.error(`Backup not found: ${backupId}`);
|
|
412
|
-
return false;
|
|
413
|
-
}
|
|
414
|
-
destPath = `${remote}:${this.cloudPath}/${backupId}`;
|
|
456
|
+
return await this.uploadSingleBackup(remote, backupId);
|
|
415
457
|
}
|
|
416
458
|
|
|
417
|
-
|
|
459
|
+
// Upload all backups (sessions and settings folders)
|
|
460
|
+
const sessionsDir = path.join(this.backupDir, 'sessions');
|
|
461
|
+
const settingsDir = path.join(this.backupDir, 'settings');
|
|
462
|
+
|
|
463
|
+
if (!fs.existsSync(sessionsDir) && !fs.existsSync(settingsDir)) {
|
|
418
464
|
this.logger.error('No backups to upload');
|
|
419
465
|
this.logger.info('Run "npx zuppaclaude backup" first');
|
|
420
466
|
return false;
|
|
@@ -424,23 +470,130 @@ class CloudManager {
|
|
|
424
470
|
this.logger.step(`Uploading to ${remote}...`);
|
|
425
471
|
console.log('');
|
|
426
472
|
|
|
473
|
+
let uploaded = 0;
|
|
474
|
+
|
|
475
|
+
// Get all backup timestamps from sessions folder
|
|
476
|
+
if (fs.existsSync(sessionsDir)) {
|
|
477
|
+
const backupFolders = fs.readdirSync(sessionsDir).filter(name => {
|
|
478
|
+
return fs.statSync(path.join(sessionsDir, name)).isDirectory();
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
for (const folder of backupFolders) {
|
|
482
|
+
const success = await this.uploadSingleBackup(remote, folder);
|
|
483
|
+
if (success) uploaded++;
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
console.log('');
|
|
488
|
+
this.logger.success(`Uploaded ${uploaded} backup(s) to ${remote}:${this.cloudPath}/`);
|
|
489
|
+
console.log('');
|
|
490
|
+
return true;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/**
|
|
494
|
+
* Upload a single backup as zip
|
|
495
|
+
*/
|
|
496
|
+
async uploadSingleBackup(remote, backupId) {
|
|
497
|
+
const sessionsPath = path.join(this.backupDir, 'sessions', backupId);
|
|
498
|
+
const settingsPath = path.join(this.backupDir, 'settings', backupId);
|
|
499
|
+
|
|
500
|
+
if (!fs.existsSync(sessionsPath)) {
|
|
501
|
+
this.logger.error(`Backup not found: ${backupId}`);
|
|
502
|
+
return false;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
// Create temp directory for zip
|
|
506
|
+
const tempDir = path.join(this.backupDir, '.temp');
|
|
507
|
+
this.platform.ensureDir(tempDir);
|
|
508
|
+
|
|
509
|
+
const zipFileName = `${backupId}.zip`;
|
|
510
|
+
const zipPath = path.join(tempDir, zipFileName);
|
|
511
|
+
|
|
512
|
+
this.logger.info(`Compressing ${backupId}...`);
|
|
513
|
+
|
|
514
|
+
// Create a combined backup folder
|
|
515
|
+
const combinedDir = path.join(tempDir, backupId);
|
|
516
|
+
this.platform.ensureDir(combinedDir);
|
|
517
|
+
|
|
518
|
+
// Copy sessions
|
|
519
|
+
const sessionsDestDir = path.join(combinedDir, 'sessions');
|
|
520
|
+
this.platform.ensureDir(sessionsDestDir);
|
|
427
521
|
try {
|
|
428
|
-
|
|
429
|
-
|
|
522
|
+
this.platform.exec(`cp -r "${sessionsPath}"/* "${sessionsDestDir}/"`, { silent: true });
|
|
523
|
+
} catch (e) {
|
|
524
|
+
// Fallback: copy the directory itself
|
|
525
|
+
this.platform.exec(`cp -r "${sessionsPath}" "${combinedDir}/"`, { silent: true });
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
// Copy settings if exists and has content
|
|
529
|
+
if (fs.existsSync(settingsPath)) {
|
|
530
|
+
const settingsFiles = fs.readdirSync(settingsPath);
|
|
531
|
+
if (settingsFiles.length > 0) {
|
|
532
|
+
const settingsDestDir = path.join(combinedDir, 'settings');
|
|
533
|
+
this.platform.ensureDir(settingsDestDir);
|
|
534
|
+
try {
|
|
535
|
+
this.platform.exec(`cp -r "${settingsPath}"/* "${settingsDestDir}/"`, { silent: true });
|
|
536
|
+
} catch (e) {
|
|
537
|
+
// Ignore if empty
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
// Create zip
|
|
543
|
+
const zipCreated = this.createZip(combinedDir, zipPath);
|
|
544
|
+
if (!zipCreated) {
|
|
545
|
+
this.logger.error('Failed to create zip');
|
|
546
|
+
return false;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
const zipSize = fs.statSync(zipPath).size;
|
|
550
|
+
this.logger.info(`Zip size: ${this.formatSize(zipSize)}`);
|
|
551
|
+
|
|
552
|
+
// Upload zip
|
|
553
|
+
const destPath = `${remote}:${this.cloudPath}/${zipFileName}`;
|
|
554
|
+
|
|
555
|
+
try {
|
|
556
|
+
this.logger.info(`Uploading ${zipFileName}...`);
|
|
557
|
+
const cmd = `rclone copy "${zipPath}" "${remote}:${this.cloudPath}/" --progress`;
|
|
430
558
|
this.platform.exec(cmd, { silent: false, stdio: 'inherit' });
|
|
431
559
|
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
560
|
+
this.logger.success(`Uploaded ${zipFileName}`);
|
|
561
|
+
|
|
562
|
+
// Cleanup: delete temp files
|
|
563
|
+
try {
|
|
564
|
+
fs.unlinkSync(zipPath);
|
|
565
|
+
fs.rmSync(combinedDir, { recursive: true, force: true });
|
|
566
|
+
} catch (e) {
|
|
567
|
+
// Ignore cleanup errors
|
|
568
|
+
}
|
|
569
|
+
|
|
435
570
|
return true;
|
|
436
571
|
} catch (error) {
|
|
437
572
|
this.logger.error(`Upload failed: ${error.message}`);
|
|
573
|
+
|
|
574
|
+
// Cleanup on error
|
|
575
|
+
try {
|
|
576
|
+
fs.unlinkSync(zipPath);
|
|
577
|
+
fs.rmSync(combinedDir, { recursive: true, force: true });
|
|
578
|
+
} catch (e) {
|
|
579
|
+
// Ignore
|
|
580
|
+
}
|
|
581
|
+
|
|
438
582
|
return false;
|
|
439
583
|
}
|
|
440
584
|
}
|
|
441
585
|
|
|
442
586
|
/**
|
|
443
|
-
*
|
|
587
|
+
* Format file size
|
|
588
|
+
*/
|
|
589
|
+
formatSize(bytes) {
|
|
590
|
+
if (bytes < 1024) return bytes + ' B';
|
|
591
|
+
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
|
|
592
|
+
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
/**
|
|
596
|
+
* Download backup from cloud (downloads and extracts zip)
|
|
444
597
|
*/
|
|
445
598
|
async download(remote, backupId = null) {
|
|
446
599
|
if (!this.isRcloneInstalled()) {
|
|
@@ -454,26 +607,42 @@ class CloudManager {
|
|
|
454
607
|
return false;
|
|
455
608
|
}
|
|
456
609
|
|
|
457
|
-
|
|
458
|
-
let destPath = this.backupDir;
|
|
459
|
-
|
|
610
|
+
// Download specific backup or all backups
|
|
460
611
|
if (backupId) {
|
|
461
|
-
|
|
462
|
-
destPath = path.join(this.backupDir, backupId);
|
|
612
|
+
return await this.downloadSingleBackup(remote, backupId);
|
|
463
613
|
}
|
|
464
614
|
|
|
465
|
-
|
|
466
|
-
|
|
615
|
+
// Download all backups
|
|
467
616
|
console.log('');
|
|
468
617
|
this.logger.step(`Downloading from ${remote}...`);
|
|
469
618
|
console.log('');
|
|
470
619
|
|
|
471
620
|
try {
|
|
472
|
-
|
|
473
|
-
|
|
621
|
+
// List all zip files in cloud
|
|
622
|
+
const cmd = `rclone lsf "${remote}:${this.cloudPath}/" --files-only 2>/dev/null`;
|
|
623
|
+
const output = this.platform.exec(cmd, { silent: true });
|
|
624
|
+
|
|
625
|
+
if (!output) {
|
|
626
|
+
this.logger.warning('No backups found on cloud');
|
|
627
|
+
return false;
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
const zipFiles = output.split('\n').filter(f => f.endsWith('.zip'));
|
|
631
|
+
|
|
632
|
+
if (zipFiles.length === 0) {
|
|
633
|
+
this.logger.warning('No backup archives found on cloud');
|
|
634
|
+
return false;
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
let downloaded = 0;
|
|
638
|
+
for (const zipFile of zipFiles) {
|
|
639
|
+
const backupName = zipFile.replace('.zip', '');
|
|
640
|
+
const success = await this.downloadSingleBackup(remote, backupName);
|
|
641
|
+
if (success) downloaded++;
|
|
642
|
+
}
|
|
474
643
|
|
|
475
644
|
console.log('');
|
|
476
|
-
this.logger.success(`
|
|
645
|
+
this.logger.success(`Downloaded ${downloaded} backup(s) from ${remote}`);
|
|
477
646
|
console.log('');
|
|
478
647
|
return true;
|
|
479
648
|
} catch (error) {
|
|
@@ -483,7 +652,78 @@ class CloudManager {
|
|
|
483
652
|
}
|
|
484
653
|
|
|
485
654
|
/**
|
|
486
|
-
*
|
|
655
|
+
* Download and extract a single backup
|
|
656
|
+
*/
|
|
657
|
+
async downloadSingleBackup(remote, backupId) {
|
|
658
|
+
const zipFileName = `${backupId}.zip`;
|
|
659
|
+
const tempDir = path.join(this.backupDir, '.temp');
|
|
660
|
+
this.platform.ensureDir(tempDir);
|
|
661
|
+
const zipPath = path.join(tempDir, zipFileName);
|
|
662
|
+
|
|
663
|
+
try {
|
|
664
|
+
// Download zip
|
|
665
|
+
this.logger.info(`Downloading ${zipFileName}...`);
|
|
666
|
+
const cmd = `rclone copy "${remote}:${this.cloudPath}/${zipFileName}" "${tempDir}/" --progress`;
|
|
667
|
+
this.platform.exec(cmd, { silent: false, stdio: 'inherit' });
|
|
668
|
+
|
|
669
|
+
if (!fs.existsSync(zipPath)) {
|
|
670
|
+
this.logger.error(`Backup not found: ${backupId}`);
|
|
671
|
+
return false;
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
// Extract zip
|
|
675
|
+
this.logger.info(`Extracting ${zipFileName}...`);
|
|
676
|
+
const extractDir = path.join(tempDir, 'extract');
|
|
677
|
+
this.platform.ensureDir(extractDir);
|
|
678
|
+
this.extractZip(zipPath, extractDir);
|
|
679
|
+
|
|
680
|
+
// Move sessions and settings to proper locations
|
|
681
|
+
const extractedBackup = path.join(extractDir, backupId);
|
|
682
|
+
|
|
683
|
+
if (fs.existsSync(path.join(extractedBackup, 'sessions'))) {
|
|
684
|
+
const sessionsDir = path.join(this.backupDir, 'sessions', backupId);
|
|
685
|
+
this.platform.ensureDir(path.dirname(sessionsDir));
|
|
686
|
+
if (fs.existsSync(sessionsDir)) {
|
|
687
|
+
fs.rmSync(sessionsDir, { recursive: true, force: true });
|
|
688
|
+
}
|
|
689
|
+
fs.renameSync(path.join(extractedBackup, 'sessions'), sessionsDir);
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
if (fs.existsSync(path.join(extractedBackup, 'settings'))) {
|
|
693
|
+
const settingsDir = path.join(this.backupDir, 'settings', backupId);
|
|
694
|
+
this.platform.ensureDir(path.dirname(settingsDir));
|
|
695
|
+
if (fs.existsSync(settingsDir)) {
|
|
696
|
+
fs.rmSync(settingsDir, { recursive: true, force: true });
|
|
697
|
+
}
|
|
698
|
+
fs.renameSync(path.join(extractedBackup, 'settings'), settingsDir);
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
// Cleanup
|
|
702
|
+
try {
|
|
703
|
+
fs.unlinkSync(zipPath);
|
|
704
|
+
fs.rmSync(extractDir, { recursive: true, force: true });
|
|
705
|
+
} catch (e) {
|
|
706
|
+
// Ignore cleanup errors
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
this.logger.success(`Downloaded ${backupId}`);
|
|
710
|
+
return true;
|
|
711
|
+
} catch (error) {
|
|
712
|
+
this.logger.error(`Failed to download ${backupId}: ${error.message}`);
|
|
713
|
+
|
|
714
|
+
// Cleanup on error
|
|
715
|
+
try {
|
|
716
|
+
if (fs.existsSync(zipPath)) fs.unlinkSync(zipPath);
|
|
717
|
+
} catch (e) {
|
|
718
|
+
// Ignore
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
return false;
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
/**
|
|
726
|
+
* List cloud backups (zip files)
|
|
487
727
|
*/
|
|
488
728
|
async listCloudBackups(remote) {
|
|
489
729
|
if (!this.isRcloneInstalled()) {
|
|
@@ -497,29 +737,25 @@ class CloudManager {
|
|
|
497
737
|
}
|
|
498
738
|
|
|
499
739
|
try {
|
|
500
|
-
const cmd = `rclone
|
|
740
|
+
const cmd = `rclone lsf "${remote}:${this.cloudPath}/" --files-only 2>/dev/null`;
|
|
501
741
|
const output = this.platform.exec(cmd, { silent: true });
|
|
502
742
|
|
|
743
|
+
console.log('');
|
|
744
|
+
console.log('\x1b[35m═══════════════════════════════════════════════════════════════════\x1b[0m');
|
|
745
|
+
console.log(`\x1b[35m Cloud Backups (${remote})\x1b[0m`);
|
|
746
|
+
console.log('\x1b[35m═══════════════════════════════════════════════════════════════════\x1b[0m');
|
|
747
|
+
console.log('');
|
|
748
|
+
|
|
503
749
|
if (!output) {
|
|
504
|
-
|
|
750
|
+
console.log(' No backups found');
|
|
751
|
+
console.log('');
|
|
505
752
|
return [];
|
|
506
753
|
}
|
|
507
754
|
|
|
508
755
|
const backups = output
|
|
509
756
|
.split('\n')
|
|
510
|
-
.filter(
|
|
511
|
-
.map(
|
|
512
|
-
const parts = line.trim().split(/\s+/);
|
|
513
|
-
const name = parts[parts.length - 1];
|
|
514
|
-
return name;
|
|
515
|
-
})
|
|
516
|
-
.filter(name => name && name.match(/^\d{4}-\d{2}-\d{2}T/));
|
|
517
|
-
|
|
518
|
-
console.log('');
|
|
519
|
-
console.log('\x1b[35m═══════════════════════════════════════════════════════════════════\x1b[0m');
|
|
520
|
-
console.log(`\x1b[35m Cloud Backups (${remote})\x1b[0m`);
|
|
521
|
-
console.log('\x1b[35m═══════════════════════════════════════════════════════════════════\x1b[0m');
|
|
522
|
-
console.log('');
|
|
757
|
+
.filter(f => f.endsWith('.zip'))
|
|
758
|
+
.map(f => f.replace('.zip', ''));
|
|
523
759
|
|
|
524
760
|
if (backups.length === 0) {
|
|
525
761
|
console.log(' No backups found');
|
package/lib/components/index.js
CHANGED
|
@@ -11,6 +11,7 @@ const { SessionManager } = require('./session');
|
|
|
11
11
|
const { CloudManager } = require('./cloud');
|
|
12
12
|
const { BackupManager } = require('./backup');
|
|
13
13
|
const { CommandsInstaller } = require('./commands');
|
|
14
|
+
const { UpdateManager } = require('./updater');
|
|
14
15
|
|
|
15
16
|
module.exports = {
|
|
16
17
|
SuperClaudeInstaller,
|
|
@@ -21,5 +22,6 @@ module.exports = {
|
|
|
21
22
|
SessionManager,
|
|
22
23
|
CloudManager,
|
|
23
24
|
BackupManager,
|
|
24
|
-
CommandsInstaller
|
|
25
|
+
CommandsInstaller,
|
|
26
|
+
UpdateManager
|
|
25
27
|
};
|
|
@@ -119,6 +119,21 @@ class SessionManager {
|
|
|
119
119
|
return projects;
|
|
120
120
|
}
|
|
121
121
|
|
|
122
|
+
/**
|
|
123
|
+
* Format timestamp as Jan-05-2026-13.56
|
|
124
|
+
*/
|
|
125
|
+
formatTimestamp(date = new Date()) {
|
|
126
|
+
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
|
|
127
|
+
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
|
128
|
+
const month = months[date.getMonth()];
|
|
129
|
+
const day = String(date.getDate()).padStart(2, '0');
|
|
130
|
+
const year = date.getFullYear();
|
|
131
|
+
const hours = String(date.getHours()).padStart(2, '0');
|
|
132
|
+
const minutes = String(date.getMinutes()).padStart(2, '0');
|
|
133
|
+
|
|
134
|
+
return `${month}-${day}-${year}-${hours}.${minutes}`;
|
|
135
|
+
}
|
|
136
|
+
|
|
122
137
|
/**
|
|
123
138
|
* Backup all sessions
|
|
124
139
|
*/
|
|
@@ -130,10 +145,9 @@ class SessionManager {
|
|
|
130
145
|
return null;
|
|
131
146
|
}
|
|
132
147
|
|
|
133
|
-
// Create backup directory with timestamp
|
|
134
|
-
const timestamp =
|
|
135
|
-
const
|
|
136
|
-
const sessionsBackupPath = path.join(backupPath, 'sessions');
|
|
148
|
+
// Create backup directory with timestamp (Jan-05-2026-13.56 format)
|
|
149
|
+
const timestamp = this.formatTimestamp();
|
|
150
|
+
const sessionsBackupPath = path.join(this.backupDir, 'sessions', timestamp);
|
|
137
151
|
|
|
138
152
|
this.platform.ensureDir(sessionsBackupPath);
|
|
139
153
|
|
|
@@ -144,7 +158,8 @@ class SessionManager {
|
|
|
144
158
|
let backedUp = 0;
|
|
145
159
|
let totalSize = 0;
|
|
146
160
|
const manifest = {
|
|
147
|
-
timestamp:
|
|
161
|
+
timestamp: timestamp,
|
|
162
|
+
timestampISO: new Date().toISOString(),
|
|
148
163
|
version: require('../../package.json').version,
|
|
149
164
|
projects: []
|
|
150
165
|
};
|
|
@@ -186,7 +201,7 @@ class SessionManager {
|
|
|
186
201
|
const historyPath = path.join(this.claudeDir, 'history.jsonl');
|
|
187
202
|
if (fs.existsSync(historyPath)) {
|
|
188
203
|
try {
|
|
189
|
-
fs.copyFileSync(historyPath, path.join(
|
|
204
|
+
fs.copyFileSync(historyPath, path.join(sessionsBackupPath, 'history.jsonl'));
|
|
190
205
|
const historyStats = fs.statSync(historyPath);
|
|
191
206
|
manifest.history = {
|
|
192
207
|
size: historyStats.size,
|
|
@@ -201,17 +216,17 @@ class SessionManager {
|
|
|
201
216
|
|
|
202
217
|
// Save manifest
|
|
203
218
|
fs.writeFileSync(
|
|
204
|
-
path.join(
|
|
219
|
+
path.join(sessionsBackupPath, 'manifest.json'),
|
|
205
220
|
JSON.stringify(manifest, null, 2)
|
|
206
221
|
);
|
|
207
222
|
|
|
208
223
|
console.log('');
|
|
209
224
|
this.logger.success(`Backup complete: ${backedUp} sessions, ${this.formatSize(totalSize)}`);
|
|
210
|
-
this.logger.info(`Location: ${
|
|
225
|
+
this.logger.info(`Location: ${sessionsBackupPath}`);
|
|
211
226
|
console.log('');
|
|
212
227
|
|
|
213
228
|
return {
|
|
214
|
-
path:
|
|
229
|
+
path: sessionsBackupPath,
|
|
215
230
|
sessions: backedUp,
|
|
216
231
|
size: totalSize,
|
|
217
232
|
timestamp: timestamp
|
|
@@ -222,18 +237,20 @@ class SessionManager {
|
|
|
222
237
|
* List available backups
|
|
223
238
|
*/
|
|
224
239
|
listBackups() {
|
|
225
|
-
|
|
240
|
+
const sessionsDir = path.join(this.backupDir, 'sessions');
|
|
241
|
+
|
|
242
|
+
if (!fs.existsSync(sessionsDir)) {
|
|
226
243
|
this.logger.warning('No backups found');
|
|
227
244
|
return [];
|
|
228
245
|
}
|
|
229
246
|
|
|
230
|
-
const backups = fs.readdirSync(
|
|
247
|
+
const backups = fs.readdirSync(sessionsDir)
|
|
231
248
|
.filter(name => {
|
|
232
|
-
const backupPath = path.join(
|
|
249
|
+
const backupPath = path.join(sessionsDir, name);
|
|
233
250
|
return fs.statSync(backupPath).isDirectory();
|
|
234
251
|
})
|
|
235
252
|
.map(name => {
|
|
236
|
-
const backupPath = path.join(
|
|
253
|
+
const backupPath = path.join(sessionsDir, name);
|
|
237
254
|
const manifestPath = path.join(backupPath, 'manifest.json');
|
|
238
255
|
|
|
239
256
|
let manifest = null;
|
|
@@ -271,7 +288,6 @@ class SessionManager {
|
|
|
271
288
|
console.log('');
|
|
272
289
|
|
|
273
290
|
for (const backup of backups) {
|
|
274
|
-
const dateStr = backup.id.replace('T', ' ').replace(/-/g, ':').slice(0, 16).replace(/:/g, '-').slice(0,10) + ' ' + backup.id.slice(11, 16).replace(/-/g, ':');
|
|
275
291
|
console.log(` 📦 ${backup.id}`);
|
|
276
292
|
console.log(` ${backup.projects} projects, ${backup.sessions} sessions`);
|
|
277
293
|
console.log('');
|
|
@@ -284,7 +300,7 @@ class SessionManager {
|
|
|
284
300
|
* Restore from backup
|
|
285
301
|
*/
|
|
286
302
|
restore(backupId) {
|
|
287
|
-
const backupPath = path.join(this.backupDir, backupId);
|
|
303
|
+
const backupPath = path.join(this.backupDir, 'sessions', backupId);
|
|
288
304
|
|
|
289
305
|
if (!fs.existsSync(backupPath)) {
|
|
290
306
|
this.logger.error(`Backup not found: ${backupId}`);
|
|
@@ -306,7 +322,8 @@ class SessionManager {
|
|
|
306
322
|
let restored = 0;
|
|
307
323
|
|
|
308
324
|
for (const project of manifest.projects) {
|
|
309
|
-
|
|
325
|
+
// Sessions are directly under the backup path (not in a nested sessions folder)
|
|
326
|
+
const projectBackupPath = path.join(backupPath, project.id);
|
|
310
327
|
const projectDestPath = path.join(this.projectsDir, project.id);
|
|
311
328
|
|
|
312
329
|
if (!fs.existsSync(projectBackupPath)) continue;
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Auto-Update Manager
|
|
3
|
+
* Checks for updates and auto-updates if enabled
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const { execSync } = require('child_process');
|
|
7
|
+
const https = require('https');
|
|
8
|
+
const { Logger } = require('../utils/logger');
|
|
9
|
+
const { Settings } = require('../settings');
|
|
10
|
+
|
|
11
|
+
class UpdateManager {
|
|
12
|
+
constructor() {
|
|
13
|
+
this.logger = new Logger();
|
|
14
|
+
this.settings = new Settings();
|
|
15
|
+
this.packageName = 'zuppaclaude';
|
|
16
|
+
this.currentVersion = require('../../package.json').version;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Get latest version from npm
|
|
21
|
+
*/
|
|
22
|
+
async getLatestVersion() {
|
|
23
|
+
return new Promise((resolve, reject) => {
|
|
24
|
+
const url = `https://registry.npmjs.org/${this.packageName}/latest`;
|
|
25
|
+
|
|
26
|
+
https.get(url, (res) => {
|
|
27
|
+
let data = '';
|
|
28
|
+
|
|
29
|
+
res.on('data', (chunk) => {
|
|
30
|
+
data += chunk;
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
res.on('end', () => {
|
|
34
|
+
try {
|
|
35
|
+
const json = JSON.parse(data);
|
|
36
|
+
resolve(json.version);
|
|
37
|
+
} catch (e) {
|
|
38
|
+
reject(e);
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
}).on('error', (err) => {
|
|
42
|
+
reject(err);
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Compare versions (returns true if latest > current)
|
|
49
|
+
*/
|
|
50
|
+
isNewerVersion(latest, current) {
|
|
51
|
+
const latestParts = latest.split('.').map(Number);
|
|
52
|
+
const currentParts = current.split('.').map(Number);
|
|
53
|
+
|
|
54
|
+
for (let i = 0; i < 3; i++) {
|
|
55
|
+
if (latestParts[i] > currentParts[i]) return true;
|
|
56
|
+
if (latestParts[i] < currentParts[i]) return false;
|
|
57
|
+
}
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Check for updates
|
|
63
|
+
*/
|
|
64
|
+
async checkForUpdates(silent = false) {
|
|
65
|
+
try {
|
|
66
|
+
const latestVersion = await this.getLatestVersion();
|
|
67
|
+
const hasUpdate = this.isNewerVersion(latestVersion, this.currentVersion);
|
|
68
|
+
|
|
69
|
+
if (hasUpdate && !silent) {
|
|
70
|
+
console.log('');
|
|
71
|
+
console.log('\x1b[33m╔═══════════════════════════════════════════════════════════════════╗\x1b[0m');
|
|
72
|
+
console.log('\x1b[33m║ Update Available! ║\x1b[0m');
|
|
73
|
+
console.log('\x1b[33m╚═══════════════════════════════════════════════════════════════════╝\x1b[0m');
|
|
74
|
+
console.log('');
|
|
75
|
+
console.log(` Current version: \x1b[31m${this.currentVersion}\x1b[0m`);
|
|
76
|
+
console.log(` Latest version: \x1b[32m${latestVersion}\x1b[0m`);
|
|
77
|
+
console.log('');
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return {
|
|
81
|
+
hasUpdate,
|
|
82
|
+
currentVersion: this.currentVersion,
|
|
83
|
+
latestVersion
|
|
84
|
+
};
|
|
85
|
+
} catch (error) {
|
|
86
|
+
// Silently fail - don't interrupt user workflow
|
|
87
|
+
return {
|
|
88
|
+
hasUpdate: false,
|
|
89
|
+
currentVersion: this.currentVersion,
|
|
90
|
+
latestVersion: null,
|
|
91
|
+
error: error.message
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Update to latest version
|
|
98
|
+
*/
|
|
99
|
+
async update() {
|
|
100
|
+
try {
|
|
101
|
+
this.logger.step('Updating ZuppaClaude...');
|
|
102
|
+
|
|
103
|
+
// Use npm to update globally
|
|
104
|
+
execSync(`npm install -g ${this.packageName}@latest`, {
|
|
105
|
+
stdio: 'inherit'
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
// Verify update
|
|
109
|
+
const newVersion = await this.getLatestVersion();
|
|
110
|
+
this.logger.success(`Updated to v${newVersion}`);
|
|
111
|
+
|
|
112
|
+
return true;
|
|
113
|
+
} catch (error) {
|
|
114
|
+
this.logger.error(`Update failed: ${error.message}`);
|
|
115
|
+
this.logger.info(`Manual update: npm install -g ${this.packageName}@latest`);
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Check and auto-update if enabled
|
|
122
|
+
* Called at startup
|
|
123
|
+
*/
|
|
124
|
+
async checkAndUpdate() {
|
|
125
|
+
const userSettings = this.settings.load() || {};
|
|
126
|
+
const autoUpdate = userSettings.autoUpdate !== false; // Default true
|
|
127
|
+
|
|
128
|
+
const result = await this.checkForUpdates(true);
|
|
129
|
+
|
|
130
|
+
if (result.hasUpdate) {
|
|
131
|
+
if (autoUpdate) {
|
|
132
|
+
console.log('');
|
|
133
|
+
console.log(`\x1b[36m[i]\x1b[0m New version available: v${result.latestVersion}`);
|
|
134
|
+
console.log('\x1b[36m[i]\x1b[0m Auto-updating...');
|
|
135
|
+
console.log('');
|
|
136
|
+
|
|
137
|
+
const updated = await this.update();
|
|
138
|
+
|
|
139
|
+
if (updated) {
|
|
140
|
+
console.log('');
|
|
141
|
+
console.log('\x1b[32m[✓]\x1b[0m Update complete! Please restart to use the new version.');
|
|
142
|
+
console.log('');
|
|
143
|
+
return { updated: true, version: result.latestVersion };
|
|
144
|
+
}
|
|
145
|
+
} else {
|
|
146
|
+
// Just notify
|
|
147
|
+
console.log('');
|
|
148
|
+
console.log(`\x1b[33m[!]\x1b[0m Update available: v${this.currentVersion} → v${result.latestVersion}`);
|
|
149
|
+
console.log(`\x1b[33m[!]\x1b[0m Run: npm install -g ${this.packageName}@latest`);
|
|
150
|
+
console.log('');
|
|
151
|
+
return { updated: false, version: result.latestVersion };
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return { updated: false, version: this.currentVersion };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Enable auto-update
|
|
160
|
+
*/
|
|
161
|
+
enableAutoUpdate() {
|
|
162
|
+
const userSettings = this.settings.load() || {};
|
|
163
|
+
userSettings.autoUpdate = true;
|
|
164
|
+
this.settings.save(userSettings);
|
|
165
|
+
this.logger.success('Auto-update enabled');
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Disable auto-update
|
|
170
|
+
*/
|
|
171
|
+
disableAutoUpdate() {
|
|
172
|
+
const userSettings = this.settings.load() || {};
|
|
173
|
+
userSettings.autoUpdate = false;
|
|
174
|
+
this.settings.save(userSettings);
|
|
175
|
+
this.logger.success('Auto-update disabled');
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Show update status
|
|
180
|
+
*/
|
|
181
|
+
async status() {
|
|
182
|
+
const userSettings = this.settings.load() || {};
|
|
183
|
+
const autoUpdate = userSettings.autoUpdate !== false;
|
|
184
|
+
|
|
185
|
+
console.log('');
|
|
186
|
+
console.log('\x1b[35m═══════════════════════════════════════════════════════════════════\x1b[0m');
|
|
187
|
+
console.log('\x1b[35m Update Status\x1b[0m');
|
|
188
|
+
console.log('\x1b[35m═══════════════════════════════════════════════════════════════════\x1b[0m');
|
|
189
|
+
console.log('');
|
|
190
|
+
console.log(` Current version: \x1b[36m${this.currentVersion}\x1b[0m`);
|
|
191
|
+
console.log(` Auto-update: ${autoUpdate ? '\x1b[32menabled\x1b[0m' : '\x1b[31mdisabled\x1b[0m'}`);
|
|
192
|
+
console.log('');
|
|
193
|
+
|
|
194
|
+
this.logger.info('Checking for updates...');
|
|
195
|
+
const result = await this.checkForUpdates(true);
|
|
196
|
+
|
|
197
|
+
if (result.latestVersion) {
|
|
198
|
+
console.log(` Latest version: \x1b[36m${result.latestVersion}\x1b[0m`);
|
|
199
|
+
|
|
200
|
+
if (result.hasUpdate) {
|
|
201
|
+
console.log('');
|
|
202
|
+
console.log(' \x1b[33mUpdate available!\x1b[0m');
|
|
203
|
+
console.log(` Run: \x1b[36mnpm install -g ${this.packageName}@latest\x1b[0m`);
|
|
204
|
+
} else {
|
|
205
|
+
console.log('');
|
|
206
|
+
console.log(' \x1b[32mYou are up to date!\x1b[0m');
|
|
207
|
+
}
|
|
208
|
+
} else {
|
|
209
|
+
console.log(' \x1b[31mCould not check for updates\x1b[0m');
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
console.log('');
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
module.exports = { UpdateManager };
|