bus-agent 2.3.0

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/lib/backup.js ADDED
@@ -0,0 +1,256 @@
1
+ /**
2
+ * CoCo Backup & Restore — Bus State Management Module
3
+ *
4
+ * Export functions, no process.exit, accepts busDir as parameter.
5
+ * Used by: coco-cli.js, backup.js (thin CLI wrapper)
6
+ */
7
+ const fs = require('fs');
8
+ const path = require('path');
9
+ const zlib = require('zlib');
10
+ const crypto = require('crypto');
11
+
12
+ const BACKUP_DIR_NAME = '.backups';
13
+
14
+ /**
15
+ * Create a compressed backup of the bus directory
16
+ */
17
+ function createBackup(busDir, outFile) {
18
+ if (!fs.existsSync(busDir)) throw new Error(`Bus directory not found: ${busDir}`);
19
+
20
+ const backupDir = path.join(path.dirname(busDir), BACKUP_DIR_NAME);
21
+ if (!fs.existsSync(backupDir)) fs.mkdirSync(backupDir, { recursive: true });
22
+
23
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
24
+ const output = outFile || path.join(backupDir, `coco-bus-${timestamp}.coco`);
25
+
26
+ console.log(`\n CoCo Backup — Creating backup...`);
27
+ console.log(` Source: ${busDir}`);
28
+ console.log(` Output: ${output}\n`);
29
+
30
+ // Collect all bus files
31
+ const entries = [];
32
+ function walk(dir, relativePath = '') {
33
+ if (!fs.existsSync(dir)) return;
34
+ for (const item of fs.readdirSync(dir, { withFileTypes: true })) {
35
+ const full = path.join(dir, item.name);
36
+ const rel = relativePath ? `${relativePath}/${item.name}` : item.name;
37
+ if (item.isDirectory()) walk(full, rel);
38
+ else if (item.isFile()) {
39
+ try { entries.push({ path: rel, content: fs.readFileSync(full, 'utf-8') }); }
40
+ catch (err) { console.warn(` ⚠️ Cannot read: ${rel} (${err.message})`); }
41
+ }
42
+ }
43
+ }
44
+ walk(busDir);
45
+
46
+ const metadata = {
47
+ version: '2.1.0', tool: 'coco-backup', created_at: new Date().toISOString(),
48
+ hostname: require('os').hostname(), bus_path: busDir, file_count: entries.length,
49
+ checksum: crypto.createHash('sha256').update(entries.map(e => e.path + e.content).join('')).digest('hex'),
50
+ };
51
+
52
+ const compressed = zlib.gzipSync(JSON.stringify({ metadata, entries }));
53
+ fs.writeFileSync(output, compressed);
54
+
55
+ const sizeKB = (compressed.length / 1024).toFixed(1);
56
+ console.log(` ✅ Backup complete: ${entries.length} files, ${sizeKB} KB`);
57
+ console.log(` Checksum: ${metadata.checksum.substring(0, 16)}...\n`);
58
+
59
+ return output;
60
+ }
61
+
62
+ /**
63
+ * Restore bus from a backup file
64
+ */
65
+ function restoreBackup(file, busDir) {
66
+ if (!fs.existsSync(file)) throw new Error(`Backup file not found: ${file}`);
67
+
68
+ console.log(`\n CoCo Restore — Restoring from backup...`);
69
+ console.log(` Source: ${file}`);
70
+
71
+ let archive;
72
+ try {
73
+ archive = JSON.parse(zlib.gunzipSync(fs.readFileSync(file)).toString('utf-8'));
74
+ } catch (err) { throw new Error(`Cannot read backup: ${err.message}`); }
75
+
76
+ const meta = archive.metadata;
77
+ console.log(` Backup: ${meta.created_at}`);
78
+ console.log(` Files: ${meta.file_count}`);
79
+ console.log(` Host: ${meta.hostname}\n`);
80
+
81
+ const actualChecksum = crypto.createHash('sha256').update(archive.entries.map(e => e.path + e.content).join('')).digest('hex');
82
+ if (actualChecksum !== meta.checksum) throw new Error('Backup checksum mismatch — file may be corrupted');
83
+ console.log(' ✅ Checksum verified');
84
+
85
+ // Pre-restore backup
86
+ const backupDir = path.join(path.dirname(busDir), BACKUP_DIR_NAME);
87
+ const preBackupPath = path.join(backupDir, 'pre-restore-auto.coco');
88
+ console.log(' Creating pre-restore backup...');
89
+ try { createBackup(busDir, preBackupPath); console.log(` Pre-restore backup saved: ${preBackupPath}`); } catch {}
90
+
91
+ // Restore files
92
+ let restored = 0, errors = 0;
93
+ for (const entry of archive.entries) {
94
+ const targetPath = path.join(busDir, entry.path);
95
+ try {
96
+ if (!fs.existsSync(path.dirname(targetPath))) fs.mkdirSync(path.dirname(targetPath), { recursive: true });
97
+ fs.writeFileSync(targetPath, entry.content, 'utf-8');
98
+ restored++;
99
+ } catch (err) { console.error(` ❌ Failed to restore ${entry.path}: ${err.message}`); errors++; }
100
+ }
101
+
102
+ console.log(`\n ✅ Restore complete: ${restored} files${errors > 0 ? `, ${errors} errors` : ''}\n`);
103
+ return { restored, errors };
104
+ }
105
+
106
+ /**
107
+ * List available backups
108
+ */
109
+ function listBackups(busDir) {
110
+ const backupDir = path.join(path.dirname(busDir), BACKUP_DIR_NAME);
111
+ if (!fs.existsSync(backupDir)) { console.log('No backups found.'); return []; }
112
+
113
+ const files = fs.readdirSync(backupDir).filter(f => f.endsWith('.coco')).sort().reverse();
114
+ if (files.length === 0) { console.log('No backups found.'); return []; }
115
+
116
+ console.log(`\n CoCo Backups (${files.length} total):`);
117
+ console.log(' ──────────────────────────────────────────────');
118
+
119
+ let totalSize = 0;
120
+ for (const f of files) {
121
+ const filePath = path.join(backupDir, f);
122
+ const sizeKB = (fs.statSync(filePath).size / 1024).toFixed(1);
123
+ totalSize += fs.statSync(filePath).size;
124
+ try {
125
+ const meta = JSON.parse(zlib.gunzipSync(fs.readFileSync(filePath)).toString('utf-8')).metadata;
126
+ console.log(` ${f.substring(0, 40).padEnd(42)} ${sizeKB.padStart(7)} KB (${meta.file_count} files, ${meta.created_at.substring(0, 19)})`);
127
+ } catch { console.log(` ${f.substring(0, 40).padEnd(42)} ${sizeKB.padStart(7)} KB (corrupted?)`); }
128
+ }
129
+
130
+ console.log(` ──────────────────────────────────────────────`);
131
+ console.log(` Total: ${(totalSize / 1024 / 1024).toFixed(2)} MB\n`);
132
+ return files;
133
+ }
134
+
135
+ /**
136
+ * Show backup info
137
+ */
138
+ function backupInfo(file) {
139
+ if (!fs.existsSync(file)) throw new Error(`File not found: ${file}`);
140
+ const archive = JSON.parse(zlib.gunzipSync(fs.readFileSync(file)).toString('utf-8'));
141
+ const meta = archive.metadata;
142
+
143
+ console.log(`\n Backup Info: ${path.basename(file)}`);
144
+ console.log(' ──────────────────────────────────────────────');
145
+ console.log(` Created: ${meta.created_at}`);
146
+ console.log(` Hostname: ${meta.hostname}`);
147
+ console.log(` Bus path: ${meta.bus_path}`);
148
+ console.log(` File count: ${meta.file_count}`);
149
+ console.log(` Checksum: ${meta.checksum.substring(0, 24)}...`);
150
+
151
+ const dirs = {};
152
+ for (const entry of archive.entries) {
153
+ const dir = path.dirname(entry.path) || '.';
154
+ dirs[dir] = (dirs[dir] || 0) + 1;
155
+ }
156
+ console.log(' Contents:');
157
+ for (const [dir, count] of Object.entries(dirs).sort()) console.log(` ${dir.padEnd(30)} ${count} file(s)`);
158
+ console.log();
159
+ }
160
+
161
+ /**
162
+ * Diff backup vs current state
163
+ */
164
+ function diffBackup(file, busDir) {
165
+ if (!fs.existsSync(file)) throw new Error(`Backup file not found: ${file}`);
166
+ const archive = JSON.parse(zlib.gunzipSync(fs.readFileSync(file)).toString('utf-8'));
167
+
168
+ console.log(`\n CoCo Diff — Backup vs Current State`);
169
+ console.log(` Backup: ${archive.metadata.created_at}`);
170
+ console.log(' ──────────────────────────────────────────────');
171
+
172
+ const backupIndex = {};
173
+ for (const entry of archive.entries) backupIndex[entry.path] = entry.content;
174
+
175
+ const currentIndex = {};
176
+ function walk(dir, rel = '') {
177
+ if (!fs.existsSync(dir)) return;
178
+ for (const item of fs.readdirSync(dir, { withFileTypes: true })) {
179
+ const full = path.join(dir, item.name);
180
+ const r = rel ? `${rel}/${item.name}` : item.name;
181
+ if (item.isDirectory()) walk(full, r);
182
+ else if (item.isFile()) { try { currentIndex[r] = fs.readFileSync(full, 'utf-8'); } catch {} }
183
+ }
184
+ }
185
+ walk(busDir);
186
+
187
+ let added = 0, removed = 0, modified = 0, unchanged = 0;
188
+ const allPaths = new Set([...Object.keys(backupIndex), ...Object.keys(currentIndex)]);
189
+ for (const p of allPaths) {
190
+ const inB = p in backupIndex, inC = p in currentIndex;
191
+ if (!inB && inC) { added++; console.log(` ➕ Added: ${p}`); }
192
+ else if (inB && !inC) { removed++; console.log(` ➖ Removed: ${p}`); }
193
+ else if (backupIndex[p] === currentIndex[p]) unchanged++;
194
+ else { modified++; console.log(` ✏️ Modified: ${p} (${(backupIndex[p].length / 1024).toFixed(1)} KB → ${(currentIndex[p].length / 1024).toFixed(1)} KB)`); }
195
+ }
196
+ console.log(` ──────────────────────────────────────────────`);
197
+ console.log(` Added: ${added}, Removed: ${removed}, Modified: ${modified}, Unchanged: ${unchanged}\n`);
198
+ }
199
+
200
+ /**
201
+ * Cleanup old backups
202
+ */
203
+ function cleanupBackups(busDir, maxAgeDays = 30) {
204
+ const backupDir = path.join(path.dirname(busDir), BACKUP_DIR_NAME);
205
+ if (!fs.existsSync(backupDir)) { console.log('No backups to clean up.'); return; }
206
+
207
+ const cutoff = Date.now() - (maxAgeDays * 86400000);
208
+ const files = fs.readdirSync(backupDir).filter(f => f.endsWith('.coco'))
209
+ .map(f => ({ name: f, path: path.join(backupDir, f), stat: fs.statSync(path.join(backupDir, f)) }))
210
+ .filter(f => f.stat.mtimeMs < cutoff).sort((a, b) => a.stat.mtimeMs - b.stat.mtimeMs);
211
+
212
+ if (files.length === 0) { console.log(`No backups older than ${maxAgeDays} days.`); return; }
213
+
214
+ let deleted = 0, freed = 0;
215
+ for (const f of files) {
216
+ const sizeKB = (f.stat.size / 1024).toFixed(1);
217
+ fs.unlinkSync(f.path);
218
+ deleted++; freed += f.stat.size;
219
+ console.log(` 🗑️ ${f.name.substring(0, 40).padEnd(42)} ${sizeKB} KB`);
220
+ }
221
+ console.log(`\n ✅ Deleted ${deleted} backups, freed ${(freed / 1024).toFixed(1)} KB\n`);
222
+ }
223
+
224
+ /**
225
+ * Auto-backup — only if changes detected
226
+ */
227
+ function autoBackup(busDir) {
228
+ const backupDir = path.join(path.dirname(busDir), BACKUP_DIR_NAME);
229
+ if (!fs.existsSync(backupDir)) fs.mkdirSync(backupDir, { recursive: true });
230
+
231
+ const existing = fs.readdirSync(backupDir).filter(f => f.endsWith('.coco')).sort().reverse();
232
+ let lastChecksum = null;
233
+ if (existing.length > 0) {
234
+ try { lastChecksum = JSON.parse(zlib.gunzipSync(fs.readFileSync(path.join(backupDir, existing[0]))).toString('utf-8')).metadata.checksum; } catch {}
235
+ }
236
+
237
+ const backupPath = createBackup(busDir);
238
+ if (lastChecksum) {
239
+ try {
240
+ const newChecksum = JSON.parse(zlib.gunzipSync(fs.readFileSync(backupPath)).toString('utf-8')).metadata.checksum;
241
+ if (newChecksum === lastChecksum) { fs.unlinkSync(backupPath); console.log(' ℹ️ No changes since last backup — skipped.\n'); return { skipped: true }; }
242
+ } catch {}
243
+ }
244
+ return { skipped: false, path: backupPath };
245
+ }
246
+
247
+ function watchMode(busDir, intervalMinutes = 30) {
248
+ const intervalMs = intervalMinutes * 60 * 1000;
249
+ console.log(`\n CoCo Auto-Backup — Every ${intervalMinutes} minutes`);
250
+ console.log(` Bus: ${busDir}`);
251
+ console.log(` Press Ctrl+C to stop\n`);
252
+ autoBackup(busDir);
253
+ setInterval(() => { console.log(`[${new Date().toISOString()}] Auto-backup check...`); autoBackup(busDir); }, intervalMs);
254
+ }
255
+
256
+ module.exports = { createBackup, restoreBackup, listBackups, backupInfo, diffBackup, cleanupBackups, autoBackup, watchMode, BACKUP_DIR_NAME };