@polderlabs/bizar 4.7.0 → 4.7.2
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/bizar-dash/dist/assets/main-DHZmbnxQ.js +361 -0
- package/bizar-dash/dist/assets/main-DHZmbnxQ.js.map +1 -0
- package/bizar-dash/dist/assets/main-DX_Jh8Wc.css +1 -0
- package/bizar-dash/dist/assets/{mobile-CWqPoGaT.js → mobile-BK8-ythT.js} +2 -2
- package/bizar-dash/dist/assets/mobile-BK8-ythT.js.map +1 -0
- package/bizar-dash/dist/assets/{mobile-i4Uv9eW8.js → mobile-Chvf9u_B.js} +1 -1
- package/bizar-dash/dist/assets/{mobile-i4Uv9eW8.js.map → mobile-Chvf9u_B.js.map} +1 -1
- package/bizar-dash/dist/index.html +3 -3
- package/bizar-dash/dist/mobile.html +2 -2
- package/bizar-dash/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -1
- package/bizar-dash/skills/publishing/SKILL.md +146 -0
- package/bizar-dash/src/server/api.mjs +8 -0
- package/bizar-dash/src/server/backup-store.mjs +525 -0
- package/bizar-dash/src/server/digest-store.mjs +558 -0
- package/bizar-dash/src/server/lib/rate-limit.mjs +122 -0
- package/bizar-dash/src/server/routes/backup.mjs +112 -0
- package/bizar-dash/src/server/routes/chat.mjs +14 -0
- package/bizar-dash/src/server/routes/digests.mjs +82 -0
- package/bizar-dash/src/server/routes-v2/events.mjs +14 -0
- package/bizar-dash/src/server/schedules-runner.mjs +126 -0
- package/bizar-dash/src/server/server.mjs +17 -0
- package/bizar-dash/src/web/App.tsx +8 -1
- package/bizar-dash/src/web/components/BackupRestore.tsx +330 -0
- package/bizar-dash/src/web/components/SearchModal.tsx +3 -1
- package/bizar-dash/src/web/components/chat/Composer.tsx +2 -0
- package/bizar-dash/src/web/styles/main.css +70 -8
- package/bizar-dash/src/web/views/Activity.tsx +11 -1
- package/bizar-dash/src/web/views/Agents.tsx +57 -42
- package/bizar-dash/src/web/views/Artifacts.tsx +38 -25
- package/bizar-dash/src/web/views/Chat.tsx +8 -0
- package/bizar-dash/src/web/views/History.tsx +4 -1
- package/bizar-dash/src/web/views/MiniMaxUsage.tsx +21 -4
- package/bizar-dash/src/web/views/Mods.tsx +30 -17
- package/bizar-dash/src/web/views/Overview.tsx +8 -1
- package/bizar-dash/src/web/views/Providers.tsx +16 -16
- package/bizar-dash/src/web/views/Schedules.tsx +33 -15
- package/bizar-dash/src/web/views/Settings.tsx +97 -1751
- package/bizar-dash/src/web/views/Skills.tsx +4 -1
- package/bizar-dash/src/web/views/Tasks.tsx +11 -2
- package/bizar-dash/src/web/views/memory/ConfigPanel.tsx +8 -2
- package/bizar-dash/src/web/views/memory/LightragPanel.tsx +3 -0
- package/bizar-dash/src/web/views/memory/ObsidianPanel.tsx +12 -4
- package/bizar-dash/src/web/views/memory/SemanticSearchPanel.tsx +3 -0
- package/bizar-dash/src/web/views/settings/ActivitySection.tsx +205 -0
- package/bizar-dash/src/web/views/settings/AgentSection.tsx +294 -0
- package/bizar-dash/src/web/views/settings/AuthSection.tsx +159 -0
- package/bizar-dash/src/web/views/settings/BackupSection.tsx +16 -0
- package/bizar-dash/src/web/views/settings/EnvVarsSection.tsx +16 -0
- package/bizar-dash/src/web/views/settings/GeneralSection.tsx +105 -0
- package/bizar-dash/src/web/views/settings/HeadroomSection.tsx +39 -0
- package/bizar-dash/src/web/views/settings/MemorySection.tsx +16 -0
- package/bizar-dash/src/web/views/settings/NetworkSection.tsx +87 -0
- package/bizar-dash/src/web/views/settings/NotificationsSection.tsx +34 -0
- package/bizar-dash/src/web/views/settings/ProvidersSection.tsx +16 -0
- package/bizar-dash/src/web/views/settings/SkillsSection.tsx +16 -0
- package/bizar-dash/src/web/views/settings/SystemLlmSection.tsx +81 -0
- package/bizar-dash/src/web/views/settings/ThemeSection.tsx +168 -0
- package/bizar-dash/src/web/views/settings/UpdatesSection.tsx +256 -0
- package/bizar-dash/tests/a11y.test.tsx +206 -0
- package/bizar-dash/tests/backup-restore.test.mjs +217 -0
- package/bizar-dash/tests/backup-restore.test.tsx +123 -0
- package/bizar-dash/tests/backup-store.test.mjs +300 -0
- package/bizar-dash/tests/cli-error-visibility.test.mjs +153 -0
- package/bizar-dash/tests/digest-generation.test.mjs +191 -0
- package/bizar-dash/tests/digest-store.test.mjs +264 -0
- package/bizar-dash/tests/rate-limit.test.mjs +298 -0
- package/cli/bin.mjs +96 -2
- package/cli/commands/minimax.mjs +20 -0
- package/cli/commands/util.mjs +154 -1
- package/cli/digest.mjs +149 -0
- package/package.json +1 -1
- package/bizar-dash/dist/assets/main-DAlLdW8I.css +0 -1
- package/bizar-dash/dist/assets/main-DGGq-iZI.js +0 -361
- package/bizar-dash/dist/assets/main-DGGq-iZI.js.map +0 -1
- 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
|
+
}
|