parrot-blackbox 2.1.2 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "parrot-blackbox",
3
- "version": "2.1.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",
@@ -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 });
@@ -103,8 +103,25 @@ export function buildUrgentBundle({ home = process.env.HOME } = {}) {
103
103
  */
104
104
  export async function runUrgentBackup(cfg = loadConfig(), state = loadState(), { onProgress } = {}) {
105
105
  const now = clock();
106
- const id = iso(now);
107
- journal('urgent', `start id=${id}`);
106
+
107
+ // ── Resume an interrupted upload ─────────────────────────────────────────
108
+ // If a previous urgent upload died mid-transfer (power cut / crash / Ctrl+C),
109
+ // a pending marker was persisted in state BEFORE any long work began. On the
110
+ // next run we REUSE that id so `planAndPlace` writes back into the SAME cloud
111
+ // directory — rclone copy is per-file idempotent, so already-uploaded files
112
+ // are skipped and only the remainder is transferred, then the manifest is
113
+ // rewritten once the last file lands. The bundle itself is regenerated (the
114
+ // staging dir is transient), which is fine because the sources are fixed.
115
+ const resumed = Boolean(state.urgentPending?.id);
116
+ const id = resumed ? state.urgentPending.id : iso(now);
117
+ if (!resumed) {
118
+ state.urgentPending = { id, since: iso(now) };
119
+ saveState(state); // persist the marker before any long work → crash-safe
120
+ journal('urgent', `start id=${id}`);
121
+ } else {
122
+ journal('urgent', `resume id=${id} (previous upload was interrupted)`);
123
+ }
124
+
108
125
  const bundle = buildUrgentBundle();
109
126
 
110
127
  const accounts = await refreshAccounts(cfg);
@@ -134,11 +151,16 @@ export async function runUrgentBackup(cfg = loadConfig(), state = loadState(), {
134
151
  totalSize: manifest.totalSize,
135
152
  entryCount: manifest.entries?.length || 0,
136
153
  };
154
+ // Only clear the pending marker once the upload FULLY landed (manifest is on
155
+ // the cloud + mirrored locally). If we crash before this line, the next run
156
+ // resumes the same id — re-copying is a no-op for finished files.
157
+ delete state.urgentPending;
137
158
  saveState(state);
138
159
  journal('urgent', `done id=${id} bytes=${manifest.totalSize}`);
139
160
 
140
161
  return {
141
162
  id,
163
+ resumed,
142
164
  manifest,
143
165
  sizeBytes: manifest.totalSize,
144
166
  skippedRepos: bundle.skippedRepos,
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
- initialValue: `./restored-${id}`,
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
- s.stop(`✔ Restored ${res.files} file(s), ${bytesHuman(res.bytes)} into ${toDir}`);
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);
@@ -423,7 +435,7 @@ const main = defineCommand({
423
435
  try {
424
436
  const r = await runUrgentBackup(undefined, undefined, { onProgress: progress });
425
437
  progress.stop();
426
- console.log(`${pc.green('✔')} Urgent backup stored (${bytesHuman(r.sizeBytes)}). Restore with: \`parrot-blackbox restore urgent\`.`);
438
+ console.log(`${pc.green('✔')} Urgent backup ${r.resumed ? 'resumed & ' : ''}stored (${bytesHuman(r.sizeBytes)}). Restore with: \`parrot-blackbox restore urgent\`.`);
427
439
  if (r.skippedRepos?.length) console.log(pc.dim(`Skipped ${r.skippedRepos.length} git-tracked folder(s).`));
428
440
  if (r.missing?.length) console.log(pc.dim(`Source(s) not present, skipped: ${r.missing.join(', ')}.`));
429
441
  } catch (e) {
@@ -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({ message: 'Restore into which directory?', initialValue: `./restored-${id}` });
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
- p.log.success(`✔ Restored ${res.files} file(s), ${bytesHuman(res.bytes)} into ${toDir}`);
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
 
@@ -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 || path.join(process.env.PBB_STATE_DIR || '.', 'manifests');
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 || path.join(process.env.PBB_STATE_DIR || '.', 'manifests');
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));
@@ -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
- const dir = process.env.PBB_MANIFESTS_DIR || path.join(process.env.PBB_STATE_DIR || '.', 'manifests');
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 split files ──
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 entries) {
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). */
@@ -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
- /** Recursive JSON listing of a remote path. */
47
- export async function lsjson(remotePath, { recursive = true } = {}) {
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.push('--json');
51
- const res = await rejectFalse(args);
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 };