@polderlabs/bizar 4.7.0 → 4.7.1

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.
Files changed (75) hide show
  1. package/bizar-dash/dist/assets/main-DHZmbnxQ.js +361 -0
  2. package/bizar-dash/dist/assets/main-DHZmbnxQ.js.map +1 -0
  3. package/bizar-dash/dist/assets/main-DX_Jh8Wc.css +1 -0
  4. package/bizar-dash/dist/assets/{mobile-CWqPoGaT.js → mobile-BK8-ythT.js} +2 -2
  5. package/bizar-dash/dist/assets/mobile-BK8-ythT.js.map +1 -0
  6. package/bizar-dash/dist/assets/{mobile-i4Uv9eW8.js → mobile-Chvf9u_B.js} +1 -1
  7. package/bizar-dash/dist/assets/{mobile-i4Uv9eW8.js.map → mobile-Chvf9u_B.js.map} +1 -1
  8. package/bizar-dash/dist/index.html +3 -3
  9. package/bizar-dash/dist/mobile.html +2 -2
  10. package/bizar-dash/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -1
  11. package/bizar-dash/skills/publishing/SKILL.md +146 -0
  12. package/bizar-dash/src/server/api.mjs +8 -0
  13. package/bizar-dash/src/server/backup-store.mjs +525 -0
  14. package/bizar-dash/src/server/digest-store.mjs +558 -0
  15. package/bizar-dash/src/server/lib/rate-limit.mjs +122 -0
  16. package/bizar-dash/src/server/routes/backup.mjs +112 -0
  17. package/bizar-dash/src/server/routes/chat.mjs +14 -0
  18. package/bizar-dash/src/server/routes/digests.mjs +82 -0
  19. package/bizar-dash/src/server/routes-v2/events.mjs +14 -0
  20. package/bizar-dash/src/server/schedules-runner.mjs +126 -0
  21. package/bizar-dash/src/server/server.mjs +17 -0
  22. package/bizar-dash/src/web/App.tsx +8 -1
  23. package/bizar-dash/src/web/components/BackupRestore.tsx +330 -0
  24. package/bizar-dash/src/web/components/SearchModal.tsx +3 -1
  25. package/bizar-dash/src/web/components/chat/Composer.tsx +2 -0
  26. package/bizar-dash/src/web/styles/main.css +70 -8
  27. package/bizar-dash/src/web/views/Activity.tsx +11 -1
  28. package/bizar-dash/src/web/views/Agents.tsx +57 -42
  29. package/bizar-dash/src/web/views/Artifacts.tsx +38 -25
  30. package/bizar-dash/src/web/views/Chat.tsx +8 -0
  31. package/bizar-dash/src/web/views/History.tsx +4 -1
  32. package/bizar-dash/src/web/views/MiniMaxUsage.tsx +21 -4
  33. package/bizar-dash/src/web/views/Mods.tsx +30 -17
  34. package/bizar-dash/src/web/views/Overview.tsx +8 -1
  35. package/bizar-dash/src/web/views/Providers.tsx +16 -16
  36. package/bizar-dash/src/web/views/Schedules.tsx +33 -15
  37. package/bizar-dash/src/web/views/Settings.tsx +97 -1751
  38. package/bizar-dash/src/web/views/Skills.tsx +4 -1
  39. package/bizar-dash/src/web/views/Tasks.tsx +11 -2
  40. package/bizar-dash/src/web/views/memory/ConfigPanel.tsx +8 -2
  41. package/bizar-dash/src/web/views/memory/LightragPanel.tsx +3 -0
  42. package/bizar-dash/src/web/views/memory/ObsidianPanel.tsx +12 -4
  43. package/bizar-dash/src/web/views/memory/SemanticSearchPanel.tsx +3 -0
  44. package/bizar-dash/src/web/views/settings/ActivitySection.tsx +205 -0
  45. package/bizar-dash/src/web/views/settings/AgentSection.tsx +294 -0
  46. package/bizar-dash/src/web/views/settings/AuthSection.tsx +159 -0
  47. package/bizar-dash/src/web/views/settings/BackupSection.tsx +16 -0
  48. package/bizar-dash/src/web/views/settings/EnvVarsSection.tsx +16 -0
  49. package/bizar-dash/src/web/views/settings/GeneralSection.tsx +105 -0
  50. package/bizar-dash/src/web/views/settings/HeadroomSection.tsx +39 -0
  51. package/bizar-dash/src/web/views/settings/MemorySection.tsx +16 -0
  52. package/bizar-dash/src/web/views/settings/NetworkSection.tsx +87 -0
  53. package/bizar-dash/src/web/views/settings/NotificationsSection.tsx +34 -0
  54. package/bizar-dash/src/web/views/settings/ProvidersSection.tsx +16 -0
  55. package/bizar-dash/src/web/views/settings/SkillsSection.tsx +16 -0
  56. package/bizar-dash/src/web/views/settings/SystemLlmSection.tsx +81 -0
  57. package/bizar-dash/src/web/views/settings/ThemeSection.tsx +168 -0
  58. package/bizar-dash/src/web/views/settings/UpdatesSection.tsx +256 -0
  59. package/bizar-dash/tests/a11y.test.tsx +206 -0
  60. package/bizar-dash/tests/backup-restore.test.mjs +217 -0
  61. package/bizar-dash/tests/backup-restore.test.tsx +123 -0
  62. package/bizar-dash/tests/backup-store.test.mjs +300 -0
  63. package/bizar-dash/tests/cli-error-visibility.test.mjs +153 -0
  64. package/bizar-dash/tests/digest-generation.test.mjs +191 -0
  65. package/bizar-dash/tests/digest-store.test.mjs +264 -0
  66. package/bizar-dash/tests/rate-limit.test.mjs +298 -0
  67. package/cli/bin.mjs +84 -1
  68. package/cli/commands/minimax.mjs +20 -0
  69. package/cli/commands/util.mjs +154 -1
  70. package/cli/digest.mjs +149 -0
  71. package/package.json +1 -1
  72. package/bizar-dash/dist/assets/main-DAlLdW8I.css +0 -1
  73. package/bizar-dash/dist/assets/main-DGGq-iZI.js +0 -361
  74. package/bizar-dash/dist/assets/main-DGGq-iZI.js.map +0 -1
  75. package/bizar-dash/dist/assets/mobile-CWqPoGaT.js.map +0 -1
@@ -0,0 +1,525 @@
1
+ /**
2
+ * src/server/backup-store.mjs
3
+ *
4
+ * v4.8.0 — Backup/restore core for BizarHarness state.
5
+ *
6
+ * Manages timestamped backup directories under
7
+ * ~/.local/share/bizar/backups/ containing copies of config, memory,
8
+ * usage logs, and project state. Each backup has a manifest.json
9
+ * tracking version, platform, and included paths.
10
+ */
11
+ import {
12
+ existsSync,
13
+ mkdirSync,
14
+ rmSync,
15
+ copyFileSync,
16
+ readFileSync,
17
+ writeFileSync,
18
+ readdirSync,
19
+ statSync,
20
+ lstatSync,
21
+ } from 'node:fs';
22
+ import { join, resolve, dirname, basename } from 'node:path';
23
+ import { homedir } from 'node:os';
24
+ import { fileURLToPath } from 'node:url';
25
+ import crypto from 'node:crypto';
26
+
27
+ // ── Constants ─────────────────────────────────────────────────────────────────
28
+
29
+ const BACKUP_PATHS = [
30
+ { src: '~/.config/bizar/', label: 'config', required: true },
31
+ { src: '~/.config/bizar/env.json', label: 'env', required: false },
32
+ { src: '~/.config/opencode/opencode.json', label: 'opencode', required: false },
33
+ { src: '~/.config/opencode/projects/', label: 'projects', required: false },
34
+ { src: '~/.local/share/bizar/memory/', label: 'memory', required: false },
35
+ { src: '~/.local/share/bizar/usage.jsonl', label: 'usage', required: false },
36
+ ];
37
+
38
+ // Project-level paths (resolved relative to projectRoot at call time)
39
+ const PROJECT_BACKUP_PATHS = [
40
+ { src: '.bizar/', label: 'project-state', required: false },
41
+ { src: '.opencode/skills/', label: 'project-skills', required: false },
42
+ { src: '.agents/skills/', label: 'project-agents', required: false },
43
+ ];
44
+
45
+ // ── Helpers ───────────────────────────────────────────────────────────────────
46
+
47
+ /** Expand ~ to home directory. */
48
+ function expandPath(p) {
49
+ return p.replace(/^~\//, homedir() + '/');
50
+ }
51
+
52
+ /** Resolve a potentially-~ path to an absolute path. */
53
+ function resolvePath(p) {
54
+ return resolve(expandPath(p));
55
+ }
56
+
57
+ /** Recursive directory size in bytes. */
58
+ function dirSize(dir) {
59
+ let size = 0;
60
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
61
+ const full = join(dir, entry.name);
62
+ if (entry.isDirectory()) {
63
+ size += dirSize(full);
64
+ } else {
65
+ size += statSync(full).size;
66
+ }
67
+ }
68
+ return size;
69
+ }
70
+
71
+ /**
72
+ * Safely copy a directory recursively, handling broken symlinks.
73
+ * Returns the number of items copied, or -1 on error.
74
+ */
75
+ function safeCopyDir(src, dst) {
76
+ let count = 0;
77
+ try {
78
+ mkdirSync(dst, { recursive: true });
79
+ for (const entry of readdirSync(src, { withFileTypes: true })) {
80
+ const srcPath = join(src, entry.name);
81
+ const dstPath = join(dst, entry.name);
82
+ try {
83
+ if (entry.isDirectory()) {
84
+ count += safeCopyDir(srcPath, dstPath);
85
+ } else if (entry.isSymbolicLink()) {
86
+ // Check if symlink target exists before copying
87
+ const linkTarget = readlinkSync(srcPath);
88
+ if (existsSync(join(src, linkTarget)) || existsSync(linkTarget)) {
89
+ copyFileSync(srcPath, dstPath);
90
+ count++;
91
+ }
92
+ // Skip broken symlinks
93
+ } else {
94
+ copyFileSync(srcPath, dstPath);
95
+ count++;
96
+ }
97
+ } catch (err) {
98
+ console.warn(`[backup-store] Failed to copy ${srcPath}: ${err.message}`);
99
+ }
100
+ }
101
+ } catch (err) {
102
+ console.warn(`[backup-store] Failed to copy directory ${src}: ${err.message}`);
103
+ return -1;
104
+ }
105
+ return count;
106
+ }
107
+
108
+ /**
109
+ * Copy a file or directory to a destination, creating parent dirs.
110
+ * Returns true if something was copied, false if source didn't exist.
111
+ * Errors during copy are logged but don't fail the entire backup.
112
+ */
113
+ function copyItem(src, dst, options = {}) {
114
+ const { onprogress } = options;
115
+ src = expandPath(src);
116
+ if (!existsSync(src)) return false;
117
+
118
+ try {
119
+ const stat = lstatSync(src);
120
+ mkdirSync(dirname(dst), { recursive: true });
121
+
122
+ if (stat.isDirectory()) {
123
+ const result = safeCopyDir(src, dst);
124
+ if (result >= 0 && onprogress) onprogress(src, dst);
125
+ return result >= 0;
126
+ } else if (stat.isSymbolicLink()) {
127
+ // Check if symlink target exists before copying
128
+ const linkTarget = readlinkSync(src);
129
+ if (!existsSync(linkTarget)) {
130
+ console.warn(`[backup-store] Skipping broken symlink: ${src}`);
131
+ return false;
132
+ }
133
+ copyFileSync(src, dst);
134
+ } else {
135
+ copyFileSync(src, dst);
136
+ }
137
+ // Enforce mode 0600 on the written file
138
+ try { writeFileSync(dst, readFileSync(dst), { mode: 0o600 }); } catch { /* ignore */ }
139
+ if (onprogress) onprogress(src, dst);
140
+ return true;
141
+ } catch (err) {
142
+ console.warn(`[backup-store] Failed to copy ${src} -> ${dst}: ${err.message}`);
143
+ return false;
144
+ }
145
+ }
146
+
147
+ /** Compute SHA-256 hex digest of a file. */
148
+ function fileHash(file) {
149
+ return crypto.createHash('sha256').update(readFileSync(file)).digest('hex');
150
+ }
151
+
152
+ /** Format bytes as human-readable string. */
153
+ function formatBytes(bytes) {
154
+ if (bytes < 1024) return `${bytes} B`;
155
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
156
+ if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
157
+ return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
158
+ }
159
+
160
+ /** Read dashboard version from package.json. */
161
+ function getBizarVersion() {
162
+ try {
163
+ const { dirname: dir } = fileURLToPath(import.meta.url);
164
+ const pkg = JSON.parse(
165
+ readFileSync(join(dir, '..', '..', '..', 'package.json'), 'utf8'),
166
+ );
167
+ return pkg?.version || 'unknown';
168
+ } catch {
169
+ return 'unknown';
170
+ }
171
+ }
172
+
173
+ /** Format a date as YYYY-MM-DD-HHMMSS. */
174
+ function stamp(date = new Date()) {
175
+ const y = date.getFullYear();
176
+ const m = String(date.getMonth() + 1).padStart(2, '0');
177
+ const d = String(date.getDate()).padStart(2, '0');
178
+ const hh = String(date.getHours()).padStart(2, '0');
179
+ const mm = String(date.getMinutes()).padStart(2, '0');
180
+ const ss = String(date.getSeconds()).padStart(2, '0');
181
+ return `${y}-${m}-${d}-${hh}${mm}${ss}`;
182
+ }
183
+
184
+ // ── Public API ────────────────────────────────────────────────────────────────
185
+
186
+ /**
187
+ * Create a new timestamped backup.
188
+ *
189
+ * @param {object} opts
190
+ * @param {string} [opts.outDir='~/.local/share/bizar/backups'] — backup root
191
+ * @param {string} [opts.label] — optional human label
192
+ * @param {string} [opts.projectRoot] — if set, also backs up project-level paths
193
+ * @returns {Promise<{ ok: boolean, path: string, manifest: object, sizeBytes: number, durationMs: number }>}
194
+ */
195
+ export async function createBackup({ outDir = '~/.local/share/bizar/backups', label = null, projectRoot = null } = {}) {
196
+ const startedAt = Date.now();
197
+ outDir = expandPath(outDir);
198
+ mkdirSync(outDir, { recursive: true });
199
+
200
+ const ts = stamp();
201
+ const name = label ? `bizar-${ts}-${label}` : `bizar-${ts}`;
202
+ const backupPath = join(outDir, name);
203
+ mkdirSync(backupPath, { recursive: true });
204
+
205
+ const backed = [];
206
+ const skipped = [];
207
+
208
+ for (const item of BACKUP_PATHS) {
209
+ const src = expandPath(item.src);
210
+ const dst = join(backupPath, item.label);
211
+ if (existsSync(src)) {
212
+ copyItem(src, dst);
213
+ backed.push({ label: item.label, src: item.src });
214
+ } else if (item.required) {
215
+ skipped.push({ label: item.label, reason: 'required source missing' });
216
+ } else {
217
+ skipped.push({ label: item.label, reason: 'optional source not present' });
218
+ }
219
+ }
220
+
221
+ // Project-level paths if projectRoot provided
222
+ if (projectRoot) {
223
+ const resolvedProject = resolve(projectRoot);
224
+ for (const item of PROJECT_BACKUP_PATHS) {
225
+ const src = join(resolvedProject, item.src);
226
+ const dst = join(backupPath, item.label);
227
+ if (existsSync(src)) {
228
+ copyItem(src, dst);
229
+ backed.push({ label: item.label, src: item.src });
230
+ } else {
231
+ skipped.push({ label: item.label, reason: 'optional source not present' });
232
+ }
233
+ }
234
+ }
235
+
236
+ const manifest = {
237
+ version: getBizarVersion(),
238
+ createdAt: new Date().toISOString(),
239
+ name,
240
+ label,
241
+ // Store full path entries so restore knows where files came from
242
+ paths: backed.map((b) => ({ label: b.label, src: b.src })),
243
+ skipped,
244
+ bizarVersion: getBizarVersion(),
245
+ platform: process.platform,
246
+ nodeVersion: process.version,
247
+ };
248
+
249
+ writeFileSync(join(backupPath, 'manifest.json'), JSON.stringify(manifest, null, 2), {
250
+ encoding: 'utf8',
251
+ mode: 0o600,
252
+ });
253
+
254
+ let sizeBytes = 0;
255
+ try { sizeBytes = dirSize(backupPath); } catch { /* ignore */ }
256
+
257
+ return {
258
+ ok: true,
259
+ path: backupPath,
260
+ manifest,
261
+ sizeBytes,
262
+ durationMs: Date.now() - startedAt,
263
+ };
264
+ }
265
+
266
+ /**
267
+ * List all available backups.
268
+ *
269
+ * @param {object} opts
270
+ * @param {string} [opts.inDir='~/.local/share/bizar/backups']
271
+ * @returns {Promise<Array<{ path: string, createdAt: string, sizeBytes: number, sizeFormatted: string, manifest: object | null }>>}
272
+ */
273
+ export async function listBackups({ inDir = '~/.local/share/bizar/backups' } = {}) {
274
+ inDir = expandPath(inDir);
275
+ if (!existsSync(inDir)) return [];
276
+
277
+ const entries = readdirSync(inDir, { withFileTypes: true })
278
+ .filter((e) => e.isDirectory())
279
+ .map((e) => e.name)
280
+ .filter((n) => n.startsWith('bizar-'))
281
+ .sort()
282
+ .reverse();
283
+
284
+ return entries.map((name) => {
285
+ const backupPath = join(inDir, name);
286
+ const manifestPath = join(backupPath, 'manifest.json');
287
+ let manifest = null;
288
+ try {
289
+ if (existsSync(manifestPath)) {
290
+ manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
291
+ }
292
+ } catch { /* corrupt manifest — treat as unknown */ }
293
+
294
+ let sizeBytes = 0;
295
+ try { sizeBytes = dirSize(backupPath); } catch { /* ignore */ }
296
+
297
+ return {
298
+ path: backupPath,
299
+ name,
300
+ createdAt: manifest?.createdAt || new Date(0).toISOString(),
301
+ sizeBytes,
302
+ sizeFormatted: formatBytes(sizeBytes),
303
+ manifest,
304
+ };
305
+ });
306
+ }
307
+
308
+ /**
309
+ * Restore files from a backup.
310
+ *
311
+ * @param {object} opts
312
+ * @param {string} opts.backupPath — absolute path to the backup directory
313
+ * @param {boolean} [opts.dryRun=false] — if true, report without modifying
314
+ * @param {'overwrite'|'merge'|'skip'} [opts.conflictStrategy='merge']
315
+ * @param {string} [opts.projectRoot] — if set, restore project-level paths to this root
316
+ * @returns {Promise<{ ok: boolean, restored: string[], skipped: string[], errors: string[] }>}
317
+ */
318
+ export async function restoreBackup({
319
+ backupPath,
320
+ dryRun = false,
321
+ conflictStrategy = 'merge',
322
+ projectRoot = null,
323
+ }) {
324
+ backupPath = resolve(backupPath);
325
+ const manifestPath = join(backupPath, 'manifest.json');
326
+
327
+ if (!existsSync(backupPath)) {
328
+ return { ok: false, restored: [], skipped: [], errors: [`Backup not found: ${backupPath}`] };
329
+ }
330
+
331
+ let manifest;
332
+ try {
333
+ manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
334
+ } catch {
335
+ return { ok: false, restored: [], skipped: [], errors: ['manifest.json is missing or corrupt'] };
336
+ }
337
+
338
+ const restored = [];
339
+ const skipped = [];
340
+ const errors = [];
341
+
342
+ for (const entry of manifest.paths || []) {
343
+ // Support both old format (string[]) and new format ({label, src}[])
344
+ const label = typeof entry === 'string' ? entry : entry.label;
345
+ const originalSrc = typeof entry === 'string' ? null : entry.src;
346
+
347
+ const src = join(backupPath, label);
348
+ if (!existsSync(src)) {
349
+ errors.push(`Backup entry missing: ${label}`);
350
+ continue;
351
+ }
352
+
353
+ // Determine destination
354
+ let dst;
355
+ if (PROJECT_BACKUP_PATHS.some((p) => p.label === label) && projectRoot) {
356
+ // Project-level path — restore relative to projectRoot using original source path
357
+ const srcPath = originalSrc || label;
358
+ dst = join(resolve(projectRoot), srcPath);
359
+ } else {
360
+ // Global path — restore to original location
361
+ const original = BACKUP_PATHS.find((p) => p.label === label);
362
+ if (!original) {
363
+ errors.push(`Unknown label in manifest: ${label}`);
364
+ continue;
365
+ }
366
+ dst = expandPath(originalSrc || original.src);
367
+ }
368
+
369
+ const targetExists = existsSync(dst);
370
+
371
+ if (dryRun) {
372
+ restored.push(`${label} (dry-run: would ${targetExists ? conflictStrategy : 'create'})`);
373
+ continue;
374
+ }
375
+
376
+ if (!targetExists) {
377
+ // Target doesn't exist — just copy
378
+ try {
379
+ copyItem(src, dst);
380
+ restored.push(label);
381
+ } catch (err) {
382
+ errors.push(`Failed to restore ${label}: ${err.message}`);
383
+ }
384
+ continue;
385
+ }
386
+
387
+ // Target exists — apply conflict strategy
388
+ if (conflictStrategy === 'skip') {
389
+ skipped.push(label);
390
+ } else if (conflictStrategy === 'overwrite') {
391
+ try {
392
+ if (statSync(dst).isDirectory()) rmSync(dst, { recursive: true, force: true });
393
+ copyItem(src, dst);
394
+ restored.push(label);
395
+ } catch (err) {
396
+ errors.push(`Failed to overwrite ${label}: ${err.message}`);
397
+ }
398
+ } else {
399
+ // merge: overlay newer files
400
+ try {
401
+ overlayDir(src, dst);
402
+ restored.push(label);
403
+ } catch (err) {
404
+ errors.push(`Failed to merge ${label}: ${err.message}`);
405
+ }
406
+ }
407
+ }
408
+
409
+ return {
410
+ ok: errors.length === 0,
411
+ restored,
412
+ skipped,
413
+ errors,
414
+ };
415
+ }
416
+
417
+ /**
418
+ * Overlay newer files from srcDir onto dstDir (merge strategy).
419
+ * Files in src newer than dst are copied; dst-only files are preserved.
420
+ */
421
+ function overlayDir(srcDir, dstDir) {
422
+ mkdirSync(dstDir, { recursive: true });
423
+ for (const entry of readdirSync(srcDir, { withFileTypes: true })) {
424
+ const src = join(srcDir, entry.name);
425
+ const dst = join(dstDir, entry.name);
426
+ if (entry.isDirectory()) {
427
+ overlayDir(src, dst);
428
+ } else {
429
+ const srcTime = statSync(src).mtimeMs;
430
+ const dstTime = existsSync(dst) ? statSync(dst).mtimeMs : 0;
431
+ if (srcTime > dstTime) {
432
+ mkdirSync(dirname(dst), { recursive: true });
433
+ copyFileSync(src, dst);
434
+ try { writeFileSync(dst, readFileSync(dst), { mode: 0o600 }); } catch { /* ignore */ }
435
+ }
436
+ }
437
+ }
438
+ }
439
+
440
+ /**
441
+ * Delete a backup directory.
442
+ *
443
+ * @param {object} opts
444
+ * @param {string} opts.backupPath
445
+ * @returns {Promise<{ ok: boolean, error?: string }>}
446
+ */
447
+ export async function deleteBackup({ backupPath }) {
448
+ backupPath = resolve(backupPath);
449
+ if (!existsSync(backupPath)) {
450
+ return { ok: false, error: 'Backup not found' };
451
+ }
452
+ try {
453
+ rmSync(backupPath, { recursive: true, force: true });
454
+ return { ok: true };
455
+ } catch (err) {
456
+ return { ok: false, error: err.message };
457
+ }
458
+ }
459
+
460
+ /**
461
+ * Verify a backup's integrity.
462
+ *
463
+ * @param {object} opts
464
+ * @param {string} opts.backupPath
465
+ * @returns {Promise<{ ok: boolean, issues: string[] }>}
466
+ */
467
+ export async function verifyBackup({ backupPath }) {
468
+ backupPath = resolve(backupPath);
469
+ const issues = [];
470
+
471
+ if (!existsSync(backupPath)) {
472
+ return { ok: false, issues: ['Backup directory does not exist'] };
473
+ }
474
+
475
+ const manifestPath = join(backupPath, 'manifest.json');
476
+ if (!existsSync(manifestPath)) {
477
+ issues.push('manifest.json is missing');
478
+ return { ok: false, issues };
479
+ }
480
+
481
+ let manifest;
482
+ try {
483
+ manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
484
+ } catch {
485
+ issues.push('manifest.json is not valid JSON');
486
+ return { ok: false, issues };
487
+ }
488
+
489
+ // Collect labels (support both old string[] and new {label}[] format)
490
+ const labels = (manifest.paths || []).map((p) => (typeof p === 'string' ? p : p.label));
491
+
492
+ for (const label of labels) {
493
+ const entryPath = join(backupPath, label);
494
+ if (!existsSync(entryPath)) {
495
+ issues.push(`Expected backup entry is missing: ${label}`);
496
+ }
497
+ }
498
+
499
+ // Check for unexpected entries
500
+ const unexpected = readdirSync(backupPath).filter(
501
+ (n) => n !== 'manifest.json' && !labels.includes(n),
502
+ );
503
+ if (unexpected.length > 0) {
504
+ issues.push(`Unexpected files in backup: ${unexpected.join(', ')}`);
505
+ }
506
+
507
+ return { ok: issues.length === 0, issues };
508
+ }
509
+
510
+ /**
511
+ * Get manifest for a backup without full verify.
512
+ */
513
+ export async function getManifest({ backupPath }) {
514
+ backupPath = resolve(backupPath);
515
+ const manifestPath = join(backupPath, 'manifest.json');
516
+ if (!existsSync(manifestPath)) {
517
+ return { ok: false, error: 'manifest.json not found' };
518
+ }
519
+ try {
520
+ const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
521
+ return { ok: true, manifest };
522
+ } catch {
523
+ return { ok: false, error: 'Failed to parse manifest.json' };
524
+ }
525
+ }