parrot-blackbox 2.2.0 → 2.2.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/package.json +1 -1
- package/src/backup/restore.js +5 -1
- package/src/cli.js +15 -3
- package/src/commands/wizard.js +24 -8
- package/src/storage/allocator.js +8 -6
- package/src/storage/archive.js +60 -5
- package/src/storage/rclone.js +10 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "parrot-blackbox",
|
|
3
|
-
"version": "2.2.
|
|
3
|
+
"version": "2.2.2",
|
|
4
4
|
"description": "Crash-proof, multi-cloud backup & recovery automation for Parrot OS. Daily/weekly off-disk backups with automatic catch-up, smart storage across many MEGA + Google Drive accounts, Timeshift snapshot backups and one-command restore.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/cli.js",
|
package/src/backup/restore.js
CHANGED
|
@@ -60,8 +60,12 @@ export async function restoreSnapshot({ id, accounts, cfg, toDir, confirm = fals
|
|
|
60
60
|
|
|
61
61
|
if (missingFromChain.length === 0) {
|
|
62
62
|
console.log(`✓ All snapshots already present locally.`);
|
|
63
|
+
console.log(`⚠ This machine already has this snapshot (and its whole chain).`);
|
|
64
|
+
console.log(` If the system hasn't changed since this backup, restoring is effectively a`);
|
|
65
|
+
console.log(` no-op — but Timeshift will STILL overwrite the entire running system with the`);
|
|
66
|
+
console.log(` snapshot content and update the bootloader. On a fresh install this is exactly`);
|
|
67
|
+
console.log(` what you want; on this same machine you can safely abort now.`);
|
|
63
68
|
} else {
|
|
64
|
-
// Download and receive each missing snapshot in order (oldest to newest)
|
|
65
69
|
console.log(`\n⬇ Downloading ${missingFromChain.length} snapshot(s)...`);
|
|
66
70
|
for (const snapId of missingFromChain) {
|
|
67
71
|
await downloadAndReceiveSnapshot({ snapId, accounts, cfg, privileged, onProgress });
|
package/src/cli.js
CHANGED
|
@@ -165,12 +165,17 @@ async function restoreFlow(rest) {
|
|
|
165
165
|
p.log.warn('No accounts configured — cannot reach the cloud backups. Run `parrot-blackbox account add` first.');
|
|
166
166
|
return;
|
|
167
167
|
}
|
|
168
|
+
// Rescue paths first: System Snapshot and Urgent backup. The daily "Files"
|
|
169
|
+
// flow shows only when that job is enabled, keeping this a clean two-option
|
|
170
|
+
// restore for the default setup.
|
|
168
171
|
const kind = rest[0] || (await p.select({
|
|
169
172
|
message: 'Restore what?',
|
|
170
173
|
options: [
|
|
171
174
|
{ value: 'snapshot', label: 'System snapshot (Timeshift) — overwrites the whole system', hint: '[sudo]' },
|
|
172
|
-
{ value: 'files', label: 'File backup — recover fonts/images/docs into a folder' },
|
|
173
175
|
{ value: 'urgent', label: 'Urgent backup — user files + tool profiles (fresh install)' },
|
|
176
|
+
...(cfg.jobs?.files?.enabled
|
|
177
|
+
? [{ value: 'files', label: 'File backup — recover fonts/images/docs into a folder' }]
|
|
178
|
+
: []),
|
|
174
179
|
],
|
|
175
180
|
}));
|
|
176
181
|
if (p.isCancel(kind)) return;
|
|
@@ -185,13 +190,20 @@ async function restoreFlow(rest) {
|
|
|
185
190
|
if (p.isCancel(id)) return;
|
|
186
191
|
const toDir = rest[2] || (await p.text({
|
|
187
192
|
message: 'Restore to which directory?',
|
|
188
|
-
|
|
193
|
+
// Urgent backups restore your home (Desktop, .ssh, .gitconfig, …) — an
|
|
194
|
+
// identical machine reports "nothing changed", a fresh install overwrites.
|
|
195
|
+
initialValue: kind === 'urgent' ? (process.env.HOME || `./restored-${id}`) : `./restored-${id}`,
|
|
189
196
|
}));
|
|
190
197
|
const s = p.spinner();
|
|
191
198
|
s.start('Restoring…');
|
|
192
199
|
try {
|
|
193
200
|
const res = await restoreFiles({ id, toDir, accounts: accs, cfg, kind });
|
|
194
|
-
|
|
201
|
+
if (res.identical) {
|
|
202
|
+
s.stop(`✔ Nothing to restore — ${kind} backup already matches ${toDir} (${res.unchanged} file(s) identical).`);
|
|
203
|
+
} else {
|
|
204
|
+
const skip = res.unchanged ? ` (${res.unchanged} already up-to-date)` : '';
|
|
205
|
+
s.stop(`✔ Restored ${res.files} file(s), ${bytesHuman(res.bytes)} into ${toDir}${skip}`);
|
|
206
|
+
}
|
|
195
207
|
} catch (e) {
|
|
196
208
|
s.stop('✖ Restore failed.');
|
|
197
209
|
p.log.warn(e.message);
|
package/src/commands/wizard.js
CHANGED
|
@@ -355,12 +355,22 @@ async function restoreFileLike(kind, accs, cfg) {
|
|
|
355
355
|
options: artifacts.map((a) => ({ value: a.id, label: `${a.id} (${bytesHuman(a.totalSize)})` })).concat([{ value: '__back', label: '← Back' }]),
|
|
356
356
|
});
|
|
357
357
|
if (p.isCancel(id) || id === '__back') return;
|
|
358
|
-
const toDir = await p.text({
|
|
358
|
+
const toDir = await p.text({
|
|
359
|
+
message: 'Restore into which directory?',
|
|
360
|
+
// Urgent backups restore your home (Desktop, .ssh, .gitconfig, …) — an
|
|
361
|
+
// identical machine reports "nothing changed", a fresh install overwrites.
|
|
362
|
+
initialValue: kind === 'urgent' ? (process.env.HOME || `./restored-${id}`) : `./restored-${id}`,
|
|
363
|
+
});
|
|
359
364
|
if (p.isCancel(toDir) || !toDir) return;
|
|
360
365
|
fs.mkdirSync(toDir, { recursive: true });
|
|
361
366
|
try {
|
|
362
367
|
const res = await restoreFiles({ id, toDir, accounts: accs, cfg, kind });
|
|
363
|
-
|
|
368
|
+
if (res.identical) {
|
|
369
|
+
p.log.success(`✔ Nothing to restore — ${kind} backup already matches ${toDir} (${res.unchanged} file(s) identical).`);
|
|
370
|
+
} else {
|
|
371
|
+
const skip = res.unchanged ? ` (${res.unchanged} already up-to-date)` : '';
|
|
372
|
+
p.log.success(`✔ Restored ${res.files} file(s), ${bytesHuman(res.bytes)} into ${toDir}${skip}`);
|
|
373
|
+
}
|
|
364
374
|
} catch (e) {
|
|
365
375
|
p.log.warn(`✖ ${e.message}`);
|
|
366
376
|
}
|
|
@@ -371,14 +381,20 @@ async function restoreMenu() {
|
|
|
371
381
|
const accs = listAccounts();
|
|
372
382
|
if (!accs.length) { p.log.warn('No cloud accounts configured yet.'); return; }
|
|
373
383
|
const cfg = loadConfig();
|
|
384
|
+
// The two rescue paths come first: System Snapshot and Urgent backup. The
|
|
385
|
+
// daily "Files" flow shows only when that job is actually enabled, so this
|
|
386
|
+
// stays a clean two-option restore for the default setup.
|
|
387
|
+
const options = [
|
|
388
|
+
{ value: 'snapshot', label: '💽 System snapshot', hint: 'full system restore [sudo]' },
|
|
389
|
+
{ value: 'urgent', label: '⚡ Urgent backup', hint: 'user files + tool profiles (fresh install)' },
|
|
390
|
+
];
|
|
391
|
+
if (cfg.jobs?.files?.enabled) {
|
|
392
|
+
options.push({ value: 'files', label: '📄 Files', hint: 'recover documents, images, etc.' });
|
|
393
|
+
}
|
|
394
|
+
options.push({ value: 'back', label: '← Back' });
|
|
374
395
|
const kind = await p.select({
|
|
375
396
|
message: '♻️ Restore backup',
|
|
376
|
-
options
|
|
377
|
-
{ value: 'files', label: '📄 Files', hint: 'recover documents, images, etc.' },
|
|
378
|
-
{ value: 'urgent', label: '⚡ Urgent backup', hint: 'user files + tool profiles (fresh install)' },
|
|
379
|
-
{ value: 'snapshot', label: '💽 System snapshot', hint: 'full system restore [sudo]' },
|
|
380
|
-
{ value: 'back', label: '← Back' },
|
|
381
|
-
],
|
|
397
|
+
options,
|
|
382
398
|
});
|
|
383
399
|
if (p.isCancel(kind) || kind === 'back') return;
|
|
384
400
|
|
package/src/storage/allocator.js
CHANGED
|
@@ -24,12 +24,13 @@ import { createWriteStream } from 'node:fs';
|
|
|
24
24
|
import { spawn } from 'node:child_process';
|
|
25
25
|
import { copyToFile, copyBatch, mkdirRemote } from './rclone.js';
|
|
26
26
|
import { bytesHuman } from '../util/misc.js';
|
|
27
|
+
import { manifestsDir } from '../core/paths.js';
|
|
27
28
|
|
|
28
29
|
export { bytesHuman };
|
|
29
30
|
export const GiB = 1024 ** 3;
|
|
30
31
|
export const MANIFEST_NAME = '__MANIFEST__.json';
|
|
31
32
|
|
|
32
|
-
/** Walk a dir tree returning [{rel, abs, size, isDir}]. Symlinks skipped. */
|
|
33
|
+
/** Walk a dir tree returning [{rel, abs, size, isDir, mtimeMs}]. Symlinks skipped. */
|
|
33
34
|
export function walkFiles(localDir) {
|
|
34
35
|
const out = [];
|
|
35
36
|
const base = path.resolve(localDir);
|
|
@@ -45,10 +46,10 @@ export function walkFiles(localDir) {
|
|
|
45
46
|
}
|
|
46
47
|
if (ent.isSymbolicLink()) continue;
|
|
47
48
|
if (ent.isDirectory()) {
|
|
48
|
-
out.push({ rel: eRel, abs: eAbs, isDir: true, size: 0 });
|
|
49
|
+
out.push({ rel: eRel, abs: eAbs, isDir: true, size: 0, mtimeMs: st.mtimeMs });
|
|
49
50
|
rec(eAbs, eRel);
|
|
50
51
|
} else {
|
|
51
|
-
out.push({ rel: eRel, abs: eAbs, isDir: false, size: st.size });
|
|
52
|
+
out.push({ rel: eRel, abs: eAbs, isDir: false, size: st.size, mtimeMs: st.mtimeMs });
|
|
52
53
|
}
|
|
53
54
|
}
|
|
54
55
|
}
|
|
@@ -169,6 +170,7 @@ export async function planAndPlace(localDir, { kind, id, accounts, remoteRoot, c
|
|
|
169
170
|
rel: entry.rel,
|
|
170
171
|
type: 'file',
|
|
171
172
|
size: entry.size,
|
|
173
|
+
mtimeMs: entry.mtimeMs,
|
|
172
174
|
loc: [{ remote: acc.remote, path: `${basePath}/${entry.rel}`, start: 0, end: entry.size, size: entry.size }],
|
|
173
175
|
});
|
|
174
176
|
} else {
|
|
@@ -231,11 +233,11 @@ export async function planAndPlace(localDir, { kind, id, accounts, remoteRoot, c
|
|
|
231
233
|
placedBytes += len;
|
|
232
234
|
emit(placedBytes, `uploaded ${bytesHuman(placedBytes)}`, acc.remote);
|
|
233
235
|
}
|
|
234
|
-
manifest.entries.push({ rel, type: 'file', size: entry.size, split: true, loc: locs });
|
|
236
|
+
manifest.entries.push({ rel, type: 'file', size: entry.size, mtimeMs: entry.mtimeMs, split: true, loc: locs });
|
|
235
237
|
}
|
|
236
238
|
|
|
237
239
|
// Manifest: cloud + local mirror.
|
|
238
|
-
const manifestLocalDir = process.env.PBB_MANIFESTS_DIR ||
|
|
240
|
+
const manifestLocalDir = process.env.PBB_MANIFESTS_DIR || manifestsDir();
|
|
239
241
|
const manifestLocalPath = path.join(manifestLocalDir, `${kind}-${id}.json`);
|
|
240
242
|
fs.mkdirSync(manifestLocalDir, { recursive: true });
|
|
241
243
|
fs.writeFileSync(manifestLocalPath, JSON.stringify(manifest, null, 2));
|
|
@@ -379,7 +381,7 @@ export async function planAndPlaceStream(btrfsStream, { kind, id, accounts, remo
|
|
|
379
381
|
};
|
|
380
382
|
|
|
381
383
|
// Manifest: cloud + local mirror.
|
|
382
|
-
const manifestLocalDir = process.env.PBB_MANIFESTS_DIR ||
|
|
384
|
+
const manifestLocalDir = process.env.PBB_MANIFESTS_DIR || manifestsDir();
|
|
383
385
|
const manifestLocalPath = path.join(manifestLocalDir, `${kind}-${id}.json`);
|
|
384
386
|
fs.mkdirSync(manifestLocalDir, { recursive: true });
|
|
385
387
|
fs.writeFileSync(manifestLocalPath, JSON.stringify(manifest, null, 2));
|
package/src/storage/archive.js
CHANGED
|
@@ -15,6 +15,7 @@ import { execa } from 'execa';
|
|
|
15
15
|
import { catRemote, lsjson, purge, copyToFile, downloadBatch } from './rclone.js';
|
|
16
16
|
import { MANIFEST_NAME } from './allocator.js';
|
|
17
17
|
import { isValidBtrfsStreamManifest } from '../backup/btrfs-send.js';
|
|
18
|
+
import { manifestsDir } from '../core/paths.js';
|
|
18
19
|
|
|
19
20
|
export { MANIFEST_NAME };
|
|
20
21
|
|
|
@@ -28,8 +29,10 @@ function isPhantom(kind, manifest) {
|
|
|
28
29
|
return kind === 'snapshots' && manifest.schema === 2 && !isValidBtrfsStreamManifest(manifest);
|
|
29
30
|
}
|
|
30
31
|
|
|
31
|
-
function manifestMirrorPath(kind, id) {
|
|
32
|
-
|
|
32
|
+
export function manifestMirrorPath(kind, id) {
|
|
33
|
+
// Canonical state dir, NOT a CWD-relative `./manifests` (which would land in
|
|
34
|
+
// the user's home as stray junk and break the mirror fallback).
|
|
35
|
+
const dir = process.env.PBB_MANIFESTS_DIR || manifestsDir();
|
|
33
36
|
return path.join(dir, `${kind}-${id}.json`);
|
|
34
37
|
}
|
|
35
38
|
|
|
@@ -85,6 +88,15 @@ export async function restoreArtifact(manifest, destDir, { onProgress } = {}) {
|
|
|
85
88
|
let bytes = 0;
|
|
86
89
|
const entries = manifest.entries || [];
|
|
87
90
|
|
|
91
|
+
// ── Step 0: Smart diff — restore ONLY what differs. If everything already
|
|
92
|
+
// matches the backup locally, report "nothing changed" and touch NOTHING
|
|
93
|
+
// (no re-download, no re-copy, no overwrite). A file needs restoring when it
|
|
94
|
+
// is missing, has a different size, or a meaningfully different mtime.
|
|
95
|
+
const { unchanged, toRestore } = planRestoreDiff(manifest, destDir);
|
|
96
|
+
if (toRestore.length === 0) {
|
|
97
|
+
return { files: 0, bytes: 0, unchanged: unchanged.length, changed: 0, identical: true };
|
|
98
|
+
}
|
|
99
|
+
|
|
88
100
|
// ── Step 1: Create all directories up-front ──
|
|
89
101
|
for (const entry of entries) {
|
|
90
102
|
const target = path.join(destDir, ...entry.rel.split('/'));
|
|
@@ -95,11 +107,11 @@ export async function restoreArtifact(manifest, destDir, { onProgress } = {}) {
|
|
|
95
107
|
}
|
|
96
108
|
}
|
|
97
109
|
|
|
98
|
-
// ── Step 2: Separate whole files (batchable) from
|
|
110
|
+
// ── Step 2: Separate changed whole files (batchable) from changed splits ──
|
|
99
111
|
const wholeFiles = []; // single-location entries → batched download
|
|
100
112
|
const splitFiles = []; // multi-location entries → streaming reassembly
|
|
101
113
|
|
|
102
|
-
for (const entry of
|
|
114
|
+
for (const entry of toRestore) {
|
|
103
115
|
if (entry.type === 'dir') continue;
|
|
104
116
|
if (entry.loc.length === 1) {
|
|
105
117
|
wholeFiles.push(entry);
|
|
@@ -152,12 +164,15 @@ export async function restoreArtifact(manifest, destDir, { onProgress } = {}) {
|
|
|
152
164
|
const loc = entry.loc[0];
|
|
153
165
|
const r = await copyToFile(`${loc.remote}:${loc.path}`, target, { force: true });
|
|
154
166
|
if (!r.ok) throw new Error(`download failed for ${entry.rel}: ${r.error}`);
|
|
167
|
+
touchToEntry(target, entry);
|
|
155
168
|
files += 1;
|
|
156
169
|
bytes += entry.size;
|
|
157
170
|
if (typeof onProgress === 'function') onProgress({ done: files, text: `restored ${entry.rel}` });
|
|
158
171
|
}
|
|
159
172
|
} else {
|
|
160
173
|
for (const entry of batch.entries) {
|
|
174
|
+
const target = path.join(destDir, ...entry.rel.split('/'));
|
|
175
|
+
touchToEntry(target, entry);
|
|
161
176
|
files += 1;
|
|
162
177
|
bytes += entry.size;
|
|
163
178
|
if (typeof onProgress === 'function') onProgress({ done: files, text: `restored ${entry.rel}` });
|
|
@@ -184,12 +199,52 @@ export async function restoreArtifact(manifest, destDir, { onProgress } = {}) {
|
|
|
184
199
|
await streams.finished(out);
|
|
185
200
|
}
|
|
186
201
|
fs.renameSync(partAbs, target);
|
|
202
|
+
touchToEntry(target, entry);
|
|
187
203
|
files += 1;
|
|
188
204
|
bytes += entry.size;
|
|
189
205
|
if (typeof onProgress === 'function') onProgress({ done: files, text: `restored ${entry.rel}` });
|
|
190
206
|
}
|
|
191
207
|
|
|
192
|
-
return { files, bytes };
|
|
208
|
+
return { files, bytes, unchanged: unchanged.length, changed: toRestore.length, identical: false };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Compare a manifest's file entries against what already exists under destDir.
|
|
213
|
+
* A file needs restoring when it is missing locally, has a different size, or
|
|
214
|
+
* has a mtime more than ~2s away from the mtime recorded in the manifest
|
|
215
|
+
* (backups made before mtime recording simply fall back to size-only).
|
|
216
|
+
* @returns {{ unchanged: Array, toRestore: Array }}
|
|
217
|
+
*/
|
|
218
|
+
export function planRestoreDiff(manifest, destDir) {
|
|
219
|
+
const MTIME_TOLERANCE_MS = 2000;
|
|
220
|
+
const unchanged = [];
|
|
221
|
+
const toRestore = [];
|
|
222
|
+
for (const entry of (manifest.entries || [])) {
|
|
223
|
+
if (entry.type === 'dir') continue;
|
|
224
|
+
const target = path.join(destDir, ...entry.rel.split('/'));
|
|
225
|
+
let st = null;
|
|
226
|
+
try {
|
|
227
|
+
st = fs.statSync(target);
|
|
228
|
+
} catch {
|
|
229
|
+
st = null;
|
|
230
|
+
}
|
|
231
|
+
if (!st) { toRestore.push(entry); continue; }
|
|
232
|
+
if (st.size !== entry.size) { toRestore.push(entry); continue; }
|
|
233
|
+
if (entry.mtimeMs && Math.abs(st.mtimeMs - entry.mtimeMs) > MTIME_TOLERANCE_MS) { toRestore.push(entry); continue; }
|
|
234
|
+
unchanged.push(entry);
|
|
235
|
+
}
|
|
236
|
+
return { unchanged, toRestore };
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** Restore mtime from the manifest so a later diff reports the file identical. */
|
|
240
|
+
function touchToEntry(target, entry) {
|
|
241
|
+
if (!entry.mtimeMs) return;
|
|
242
|
+
try {
|
|
243
|
+
const t = entry.mtimeMs / 1000;
|
|
244
|
+
fs.utimesSync(target, t, t);
|
|
245
|
+
} catch {
|
|
246
|
+
/* best effort */
|
|
247
|
+
}
|
|
193
248
|
}
|
|
194
249
|
|
|
195
250
|
/** Purge an artifact from every account that hosts it (+ local mirror). */
|
package/src/storage/rclone.js
CHANGED
|
@@ -43,12 +43,18 @@ export async function listRemotes() {
|
|
|
43
43
|
return res.stdout.split('\n').map((l) => l.trim().replace(/:$/, '')).filter(Boolean);
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
-
/**
|
|
47
|
-
|
|
46
|
+
/** Build args for `rclone lsjson`. IMPORTANT: lsjson outputs JSON natively and
|
|
47
|
+
* has NO `--json` flag — passing one makes real rclone fail with
|
|
48
|
+
* "unknown flag: --json", which silently emptied every cloud listing. */
|
|
49
|
+
export function lsjsonArgs(remotePath, { recursive = true } = {}) {
|
|
48
50
|
const args = ['lsjson', remotePath];
|
|
49
51
|
if (recursive) args.push('--recursive');
|
|
50
|
-
args
|
|
51
|
-
|
|
52
|
+
return args;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Recursive JSON listing of a remote path. */
|
|
56
|
+
export async function lsjson(remotePath, { recursive = true } = {}) {
|
|
57
|
+
const res = await rejectFalse(lsjsonArgs(remotePath, { recursive }));
|
|
52
58
|
if (res.exitCode !== 0) return { ok: false, entries: [], error: res.stderr?.trim() };
|
|
53
59
|
try {
|
|
54
60
|
return { ok: true, entries: JSON.parse(res.stdout), error: null };
|