parrot-blackbox 1.0.12 → 1.0.13

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/README.md CHANGED
@@ -107,6 +107,25 @@ and pending backups.
107
107
 
108
108
  ---
109
109
 
110
+ ## 🤖 Background Automation (Set it and forget it)
111
+
112
+ To make `parrot-blackbox` take weekly snapshots and upload them to your cloud accounts completely automatically in the background, install the background service:
113
+
114
+ ```bash
115
+ parrot-blackbox schedule install
116
+ parrot-blackbox daemon start
117
+ ```
118
+
119
+ Once started, the daemon runs silently as a background process. It wakes up every Saturday at 22:00, takes a Timeshift snapshot, and trickles the upload to your cloud accounts.
120
+ If your laptop happens to be powered off or completely offline on Saturday night, the daemon is smart enough to immediately catch up and run the missed backup the very next time you connect to WiFi!
121
+
122
+ You can check on the daemon at any time using:
123
+ ```bash
124
+ parrot-blackbox status
125
+ ```
126
+
127
+ ---
128
+
110
129
  ## Managing cloud accounts (rclone remotes)
111
130
 
112
131
  Every MEGA / Google Drive login becomes one rclone remote = one pool account.
@@ -449,6 +468,7 @@ Every account is exactly one rclone remote (`mega-1:`, `gdrive-6:` …).
449
468
  percentage** (water-filling), then most free.
450
469
  - A file that doesn't fit any single account is split at `chunkSize` boundaries
451
470
  and the pieces are spread across accounts — restore reassembles byte-perfect.
471
+ - **BTRFS native support** — BTRFS root-owned snapshot files (e.g. `/etc/credstore`) are securely backed up to the cloud without `EACCES` permission errors via an internal privileged helper that integrates with the familiar interactive sudo prompt.
452
472
  - The manifest is written to the cloud **and** mirrored locally, so a wiped
453
473
  machine can still find and restore everything.
454
474
 
@@ -495,18 +515,27 @@ parrot-blackbox restore files <backup-id> ~/recovered
495
515
  ```bash
496
516
  # on the fresh Parrot:
497
517
  npm install -g parrot-blackbox
498
- rclone config # re-add the same remotes
518
+ rclone config # re-add the same remotes
499
519
  parrot-blackbox account add mega mega-1 # …add every account
500
- parrot-blackbox snapshot list # see what's in the cloud
520
+ parrot-blackbox snapshot list # see what's in the cloud
501
521
  parrot-blackbox restore snapshot <snapshot-id> --yes
502
522
  ```
503
523
 
504
- This downloads the snapshot into Timeshift, then runs the interactive restore
505
- (expect a `sudo` password prompt) — it **overwrites the fresh install's system
506
- root** with the snapshot. After a reboot you're back to your old system; then
507
- `sudo apt update && sudo apt upgrade` to move onto a newer Parrot release if
508
- you installed one. Encryption or not makes no difference — snapshot restore is
509
- run from inside the OS.
524
+ #### What happens during a snapshot restore on a fresh install?
525
+ When you run the restore command, `parrot-blackbox` does the following:
526
+ 1. **Downloads the snapshot:** It pulls the exact system snapshot from your cloud accounts, reassembles the byte-chunks, and places it into your local Timeshift directory.
527
+ 2. **Runs Timeshift Restore:** It then hands over control to Timeshift (`sudo timeshift --restore`).
528
+ 3. **Overwrites the OS:** Timeshift systematically replaces the system files of your *fresh* install with the files from your *snapshot*. Your programs, configurations, user settings, and installed packages are exactly reverted to how they were when the snapshot was taken.
529
+ 4. **Bootloader update:** Finally, Timeshift updates your GRUB bootloader and `/etc/fstab` to match the UUIDs of your new disk partitions, ensuring the system can boot.
530
+
531
+ #### What about Disk Encryption (Passphrases)?
532
+ **Disk encryption (LUKS) operates at the hardware/disk level, while snapshots operate at the file level.**
533
+ If your old system had a passphrase (encrypted), but you decide to fresh-install Parrot OS *without* a passphrase (unencrypted), **the restore will still work perfectly.**
534
+ - The snapshot only contains your files and programs, not the LUKS encryption container.
535
+ - When Timeshift restores the files onto your new unencrypted fresh install, it simply places the files into the unencrypted disk.
536
+ - Timeshift automatically detects the new partition layout, updates your boot configurations, and after a reboot, you will have your exact old system back, but running on an unencrypted drive without any password prompts on boot!
537
+
538
+ *Note: After rebooting into your restored system, it is always a good idea to run `sudo apt update && sudo apt upgrade` to ensure all packages are perfectly aligned with the latest Parrot OS repositories.*
510
539
 
511
540
  ---
512
541
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "parrot-blackbox",
3
- "version": "1.0.12",
3
+ "version": "1.0.13",
4
4
  "description": "parrot-blackbox — 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",
@@ -18,7 +18,7 @@ import fs from 'node:fs';
18
18
  import path from 'node:path';
19
19
  import streams from 'node:stream/promises';
20
20
  import { createWriteStream } from 'node:fs';
21
- import { copyToFile, mkdirRemote } from './rclone.js';
21
+ import { copyToFile, copyBatch, mkdirRemote } from './rclone.js';
22
22
  import { bytesHuman } from '../util/misc.js';
23
23
 
24
24
  export { bytesHuman };
@@ -69,10 +69,7 @@ export function chooseAccount(needed, accounts) {
69
69
  }
70
70
 
71
71
  /**
72
- * Place a whole local dir into the pool.
73
- * @param {string} localDir artifact directory to place
74
- * @param {object} opts {kind, id, accounts, remoteRoot, chunkSize, onProgress}
75
- * @returns {Promise<object>} manifest
72
+ * Place a whole local dir into the pool using two-pass batching for speed.
76
73
  */
77
74
  export async function planAndPlace(localDir, { kind, id, accounts, remoteRoot, chunkSize, onProgress }) {
78
75
  if (!accounts || accounts.length === 0) {
@@ -105,57 +102,84 @@ export async function planAndPlace(localDir, { kind, id, accounts, remoteRoot, c
105
102
  if (typeof onProgress === 'function') onProgress({ done, total: files.length, text });
106
103
  };
107
104
 
108
- let placed = 0;
105
+ // PASS 1: Planning
106
+ report(0, 'planning allocations...');
107
+ const batches = new Map(); // account.remote -> array of relative paths
108
+ const splits = []; // files that need splitting
109
+
110
+ for (const acc of pool) {
111
+ batches.set(acc.remote, { acc, files: [] });
112
+ }
113
+
109
114
  for (const entry of entries) {
110
115
  if (entry.isDir) {
111
116
  const acc = chooseAccount(0, pool);
112
117
  if (!acc) throw outOfSpace();
113
- const rp = `${basePath}/${entry.rel}`;
114
- const res = await mkdirRemote(`${acc.remote}:${rp}`);
115
- if (!res.ok) throw new Error(`mkdir failed on ${acc.remote}: ${res.error}`);
116
- manifest.entries.push({ rel: entry.rel, type: 'dir', size: 0, loc: [{ remote: acc.remote, path: rp }] });
118
+ manifest.entries.push({ rel: entry.rel, type: 'dir', size: 0, loc: [{ remote: acc.remote, path: `${basePath}/${entry.rel}` }] });
117
119
  continue;
118
120
  }
119
121
 
120
- const rel = entry.rel;
121
- const destPath = `${basePath}/${rel}`;
122
122
  const freeMax = Math.max(0, ...pool.map((a) => a.free));
123
123
  if (entry.size <= freeMax) {
124
124
  const acc = chooseAccount(entry.size, pool);
125
- if (!acc) { report(placed, `no space for ${rel}`); throw outOfSpace(); }
126
- const res = await copyToFile(entry.abs, `${acc.remote}:${destPath}`);
127
- if (!res.ok) throw new Error(`upload failed for ${rel} on ${acc.remote}: ${res.error}`);
125
+ if (!acc) throw outOfSpace();
128
126
  consume(acc, entry.size);
127
+ batches.get(acc.remote).files.push(entry.rel);
129
128
  manifest.entries.push({
130
- rel,
129
+ rel: entry.rel,
131
130
  type: 'file',
132
131
  size: entry.size,
133
- loc: [{ remote: acc.remote, path: destPath, start: 0, end: entry.size, size: entry.size }],
132
+ loc: [{ remote: acc.remote, path: `${basePath}/${entry.rel}`, start: 0, end: entry.size, size: entry.size }],
134
133
  });
135
134
  } else {
136
- // Split across accounts by byte ranges.
137
- const locs = [];
138
- let start = 0;
139
- let partIndex = 0;
140
- while (start < entry.size) {
141
- const len = Math.min(chunkSize, entry.size - start);
142
- const acc = chooseAccount(len, pool);
143
- if (process.env.PBB_DEBUG_ALLOC === '1') {
144
- console.error(`[alloc] file=${entry.rel} size=${entry.size} start=${start} len=${len} pool=${pool.map((a) => `${a.remote}:free=${a.free}`).join(',')} acc=${acc ? acc.remote : 'NONE'}`);
145
- }
146
- if (!acc) { report(placed, `no space for part of ${rel}`); throw outOfSpace(); }
147
- const partAbs = await makePartFile(entry, start, len);
148
- const partPath = `${destPath}.part-${String(partIndex).padStart(4, '0')}`;
149
- const res = await copyToFile(partAbs, `${acc.remote}:${partPath}`);
150
- if (!res.ok) throw new Error(`upload failed for ${rel} part on ${acc.remote}: ${res.error}`);
151
- consume(acc, len);
152
- locs.push({ remote: acc.remote, path: partPath, start, end: start + len, size: len });
153
- start += len;
154
- partIndex += 1;
155
- }
156
- manifest.entries.push({ rel, type: 'file', size: entry.size, split: true, loc: locs });
157
- report(placed, `split ${rel} across ${locs.length} account(s)`);
135
+ splits.push(entry);
136
+ }
137
+ }
138
+
139
+ // PASS 2: Batch Uploading (Sequential accounts, but parallel files inside rclone)
140
+ const batchDir = process.env.PBB_STATE_DIR || '/tmp';
141
+ let placed = 0;
142
+
143
+ for (const [remote, batch] of batches.entries()) {
144
+ if (batch.files.length === 0) continue;
145
+ const batchPath = path.join(batchDir, `batch-${remote.replace(/[^a-z0-9]/gi, '_')}-${Date.now()}.txt`);
146
+ fs.writeFileSync(batchPath, batch.files.join('\n') + '\n');
147
+
148
+ report(placed, `uploading batch of ${batch.files.length} files to ${remote}...`);
149
+ const res = await copyBatch(localDir, `${remote}:${basePath}`, batchPath);
150
+ if (!res.ok) throw new Error(`batch upload failed to ${remote}: ${res.error}`);
151
+
152
+ fs.unlinkSync(batchPath);
153
+ placed += batch.files.length;
154
+ report(placed, `placed ${placed}/${files.length}`);
155
+ }
156
+
157
+ // PASS 3: Split large files
158
+ for (const entry of splits) {
159
+ const rel = entry.rel;
160
+ const destPath = `${basePath}/${rel}`;
161
+ const locs = [];
162
+ let start = 0;
163
+ let partIndex = 0;
164
+
165
+ report(placed, `splitting huge file ${rel}...`);
166
+ while (start < entry.size) {
167
+ const len = Math.min(chunkSize, entry.size - start);
168
+ const acc = chooseAccount(len, pool);
169
+ if (!acc) throw outOfSpace();
170
+
171
+ const partAbs = await makePartFile(entry, start, len);
172
+ const partPath = `${destPath}.part-${String(partIndex).padStart(4, '0')}`;
173
+
174
+ const res = await copyToFile(partAbs, `${acc.remote}:${partPath}`);
175
+ if (!res.ok) throw new Error(`upload failed for ${rel} part on ${acc.remote}: ${res.error}`);
176
+
177
+ consume(acc, len);
178
+ locs.push({ remote: acc.remote, path: partPath, start, end: start + len, size: len });
179
+ start += len;
180
+ partIndex += 1;
158
181
  }
182
+ manifest.entries.push({ rel, type: 'file', size: entry.size, split: true, loc: locs });
159
183
  placed += 1;
160
184
  report(placed, `placed ${placed}/${files.length}`);
161
185
  }
@@ -71,6 +71,19 @@ export async function copyToFile(localFile, remotePath, { ignoreExisting = false
71
71
  return { ok: res.exitCode === 0, exitCode: res.exitCode, error: res.stderr?.trim() };
72
72
  }
73
73
 
74
+ /** Copy a batch of files using --files-from. */
75
+ export async function copyBatch(localDir, remotePath, filesFromPath) {
76
+ const args = [
77
+ 'copy', localDir, remotePath,
78
+ '--files-from', filesFromPath,
79
+ '--transfers=16',
80
+ '--checkers=16',
81
+ '--fast-list'
82
+ ];
83
+ const res = await rejectFalse(args);
84
+ return { ok: res.exitCode === 0, exitCode: res.exitCode, error: res.stderr?.trim() };
85
+ }
86
+
74
87
  /** Create a remote directory. */
75
88
  export async function mkdirRemote(remotePath) {
76
89
  const res = await rejectFalse(['mkdir', remotePath]);