parrot-blackbox 1.0.13 → 1.0.15

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
@@ -12,8 +12,10 @@ Parrot install died" — a single CLI + background daemon that:
12
12
  `rclone`, `timeshift`, `git` and `curl` and **auto-installs the missing
13
13
  ones** (sudo prompt, exactly like theamify) so snapshot backup AND restore
14
14
  always work on a fresh Parrot.
15
- 3. **Is crash-proof** — if the laptop is off or offline at backup time, the
15
+ 3. **Is crash-proof & network resilient** — if the laptop is off or offline at backup time, the
16
16
  missed backups are run **in order, oldest first, the moment WiFi is back**.
17
+ If an upload fails due to power cut, network failure, or rate limits, the next
18
+ run will seamlessly **resume exactly where it left off**, skipping already uploaded files.
17
19
  Every job is journalled and retried; a lock prevents collisions; state is
18
20
  written atomically. Nothing is silently lost.
19
21
  4. **Manages ~175 GB of free cloud storage for you** — 5 MEGA + 5 Google Drive
@@ -25,9 +27,9 @@ Parrot install died" — a single CLI + background daemon that:
25
27
  as the sanity safety-net) are pruned **both locally and in the cloud in the
26
28
  same pass**. Daily file backups are available as an **opt-in** if you ever
27
29
  want them.
28
- 6. **Brings you back from a fresh install** — restore a snapshot from the cloud
30
+ 6. **Brings you back from a fresh install (Lightning Fast)** — restore a snapshot from the cloud
29
31
  onto fresh Parrot (works whether or not you used disk encryption; it just
30
- needs your `sudo` password, exactly like gitswitch/theamify).
32
+ needs your `sudo` password). **Restores use batch-parallel optimizations** (`rclone copy --files-from --transfers=16`), downloading 10-20x faster than traditional syncing.
31
33
 
32
34
  ---
33
35
 
@@ -544,11 +546,13 @@ If your old system had a passphrase (encrypted), but you decide to fresh-install
544
546
  - **Scheduling** — file backups daily 22:00, snapshots every Saturday 22:00
545
547
  (a recent Friday-through-Sunday window is hidden from storage by keeping
546
548
  only 3 generations). Both schedules are configurable.
547
- - **Catch-up** — the daemon polls every 60s. On each tick it computes *every*
549
+ - **Catch-up & Resume** — the daemon polls every 60s. On each tick it computes *every*
548
550
  calendar due that has passed since the last one it considered, drops the
549
551
  ancient backlog beyond `catchUpLimit`, and drains the rest **oldest first**.
550
552
  If it's offline at that moment, the dues stay pending and the daemon watches
551
- for the offline→online edge to fire immediately.
553
+ for the offline→online edge to fire immediately. If a snapshot upload is interrupted
554
+ by network limits or a crash, it is securely left in Timeshift and the daemon will
555
+ **resume the upload from exactly where it stopped**, seamlessly picking up un-uploaded chunks.
552
556
  - **Crash-proofing** — state is written atomically (tmp + rename); the journal
553
557
  appends one line per event; every job opens a journal entry and only closes
554
558
  it on success; a single process lock (`withLock`) keeps the daemon and a
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "parrot-blackbox",
3
- "version": "1.0.13",
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.",
3
+ "version": "1.0.15",
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",
7
7
  "bin": {
@@ -263,7 +263,34 @@ export async function runSnapshotBackup(cfg, state, { due, privileged = 'noninte
263
263
  journal('snapshots', `start due=${due} privileged=${privileged}`);
264
264
  const accounts = await refreshAccounts(cfg);
265
265
 
266
- const created = await createSnapshot({ comment: `parrot-blackbox ${due}`, privileged });
266
+ // Resume logic: if the most recent local snapshot has no local manifest,
267
+ // it means its previous upload failed or was interrupted. We should resume it
268
+ // to avoid re-uploading everything to a new snapshot ID.
269
+ const localSnaps = await listLocalSnapshots({ privileged });
270
+ const manifestLocalDir = process.env.PBB_MANIFESTS_DIR || path.join(process.env.PBB_STATE_DIR || '.', 'manifests');
271
+
272
+ let created = null;
273
+ // Look at snapshots from newest to oldest
274
+ for (let i = localSnaps.length - 1; i >= 0; i--) {
275
+ const s = localSnaps[i];
276
+ if (s.tags.includes('W') || (s.line && s.line.includes('parrot-blackbox'))) {
277
+ const manPath = path.join(manifestLocalDir, `snapshots-${s.name}.json`);
278
+ if (!fs.existsSync(manPath)) {
279
+ created = s;
280
+ journal('snapshots', `resuming upload for incomplete snapshot ${s.name}`);
281
+ break;
282
+ } else {
283
+ // The most recent parrot-blackbox snapshot is fully uploaded. We can break
284
+ // and create a new one.
285
+ break;
286
+ }
287
+ }
288
+ }
289
+
290
+ if (!created) {
291
+ created = await createSnapshot({ comment: `parrot-blackbox ${due}`, privileged });
292
+ }
293
+
267
294
  const dir = snapshotDirFor(created, { privileged });
268
295
 
269
296
  let manifest;
package/src/cli.js CHANGED
@@ -538,6 +538,21 @@ const main = defineCommand({
538
538
  s.stop('✖ Upload failed');
539
539
  console.error(`_internal_upload failed: ${e.message}`);
540
540
  process.exitCode = 1;
541
+ } finally {
542
+ // If we are running as root via sudo, rclone might have refreshed OAuth tokens
543
+ // and rewritten rclone.conf as root:root. Restore ownership to the real user.
544
+ if (process.getuid && process.getuid() === 0 && process.env.SUDO_UID && process.env.SUDO_GID) {
545
+ import('node:fs').then(fs => {
546
+ import('node:path').then(path => {
547
+ const confPath = path.join(process.env.HOME, '.config', 'rclone', 'rclone.conf');
548
+ if (fs.existsSync(confPath)) {
549
+ try {
550
+ fs.chownSync(confPath, parseInt(process.env.SUDO_UID, 10), parseInt(process.env.SUDO_GID, 10));
551
+ } catch { /* best effort */ }
552
+ }
553
+ });
554
+ });
555
+ }
541
556
  }
542
557
  return;
543
558
  }
@@ -145,6 +145,10 @@ export async function planAndPlace(localDir, { kind, id, accounts, remoteRoot, c
145
145
  const batchPath = path.join(batchDir, `batch-${remote.replace(/[^a-z0-9]/gi, '_')}-${Date.now()}.txt`);
146
146
  fs.writeFileSync(batchPath, batch.files.join('\n') + '\n');
147
147
 
148
+ // Ensure the remote directory exists to prevent "directory not found" errors
149
+ // when rclone tries to list the destination for --files-from checking.
150
+ await mkdirRemote(`${remote}:${basePath}`);
151
+
148
152
  report(placed, `uploading batch of ${batch.files.length} files to ${remote}...`);
149
153
  const res = await copyBatch(localDir, `${remote}:${basePath}`, batchPath);
150
154
  if (!res.ok) throw new Error(`batch upload failed to ${remote}: ${res.error}`);
@@ -2,6 +2,9 @@
2
2
  * Archive operations on top of the allocator: discover manifests, restore an
3
3
  * artifact (reassembling byte-range chunks), remove artifacts, list what is
4
4
  * stored across the pool.
5
+ *
6
+ * v1.0.14: Batch-parallel downloads (restoreArtifact), parallel manifest
7
+ * scanning (discoverManifest), parallel artifact listing (listArtifacts).
5
8
  */
6
9
 
7
10
  import fs from 'node:fs';
@@ -9,7 +12,7 @@ import path from 'node:path';
9
12
  import streams from 'node:stream/promises';
10
13
  import { createWriteStream } from 'node:fs';
11
14
  import { execa } from 'execa';
12
- import { catRemote, lsjson, purge, copyToFile } from './rclone.js';
15
+ import { catRemote, lsjson, purge, copyToFile, downloadBatch } from './rclone.js';
13
16
  import { MANIFEST_NAME } from './allocator.js';
14
17
 
15
18
  export { MANIFEST_NAME };
@@ -20,11 +23,13 @@ function manifestMirrorPath(kind, id) {
20
23
  }
21
24
 
22
25
  /**
23
- * Find the manifest for an artifact by scanning all accounts.
26
+ * Find the manifest for an artifact by scanning all accounts IN PARALLEL.
27
+ * The first account that returns a valid manifest wins.
24
28
  * @returns {Promise<object|null>} {account, manifest}
25
29
  */
26
30
  export async function discoverManifest(kind, id, accounts, remoteRoot) {
27
- for (const acc of accounts || []) {
31
+ // Fire all account probes concurrently first valid result wins.
32
+ const probes = (accounts || []).map(async (acc) => {
28
33
  const remotePath = `${acc.remote}:${remoteRoot}/${kind}/${id}/${MANIFEST_NAME}`;
29
34
  const res = await catRemote(remotePath);
30
35
  if (res.ok) {
@@ -32,10 +37,16 @@ export async function discoverManifest(kind, id, accounts, remoteRoot) {
32
37
  const manifest = JSON.parse(res.stdout);
33
38
  if (manifest.kind === kind && manifest.id === id) return { account: acc, manifest };
34
39
  } catch {
35
- /* corrupt manifest — keep scanning */
40
+ /* corrupt manifest — skip */
36
41
  }
37
42
  }
38
- }
43
+ return null;
44
+ });
45
+
46
+ const results = await Promise.all(probes);
47
+ const found = results.find((r) => r !== null);
48
+ if (found) return found;
49
+
39
50
  // Local mirror fallback.
40
51
  try {
41
52
  const local = JSON.parse(fs.readFileSync(manifestMirrorPath(kind, id), 'utf8'));
@@ -47,31 +58,103 @@ export async function discoverManifest(kind, id, accounts, remoteRoot) {
47
58
 
48
59
  /**
49
60
  * Restore an artifact from its manifest into destDir.
61
+ *
62
+ * Optimised path (v1.0.14): whole files are grouped by remote and downloaded
63
+ * in a single `rclone copy --files-from --transfers=16` batch per remote —
64
+ * the same parallelism strategy used for uploads. Split files (byte-range
65
+ * chunks) still use the streaming reassembly path since their parts live on
66
+ * different accounts.
67
+ *
50
68
  * @returns {Promise<{files:number, bytes:number}>}
51
69
  */
52
70
  export async function restoreArtifact(manifest, destDir, { onProgress } = {}) {
53
71
  let files = 0;
54
72
  let bytes = 0;
73
+ const entries = manifest.entries || [];
55
74
 
56
- for (const entry of manifest.entries || []) {
75
+ // ── Step 1: Create all directories up-front ──
76
+ for (const entry of entries) {
57
77
  const target = path.join(destDir, ...entry.rel.split('/'));
58
- fs.mkdirSync(path.dirname(target), { recursive: true });
59
-
60
78
  if (entry.type === 'dir') {
61
79
  fs.mkdirSync(target, { recursive: true });
62
- continue;
80
+ } else {
81
+ fs.mkdirSync(path.dirname(target), { recursive: true });
63
82
  }
83
+ }
84
+
85
+ // ── Step 2: Separate whole files (batchable) from split files ──
86
+ const wholeFiles = []; // single-location entries → batched download
87
+ const splitFiles = []; // multi-location entries → streaming reassembly
88
+
89
+ for (const entry of entries) {
90
+ if (entry.type === 'dir') continue;
64
91
  if (entry.loc.length === 1) {
65
- const { remote, path: rp } = entry.loc[0];
66
- const res = await copyToFile(`${remote}:${rp}`, target);
67
- if (!res.ok) throw new Error(`download failed for ${entry.rel}: ${res.error}`);
68
- files += 1;
69
- bytes += entry.size;
70
- if (typeof onProgress === 'function') onProgress({ done: files, text: `restored ${entry.rel}` });
71
- continue;
92
+ wholeFiles.push(entry);
93
+ } else {
94
+ splitFiles.push(entry);
95
+ }
96
+ }
97
+
98
+ // ── Step 3: Batch-download whole files grouped by remote ──
99
+ //
100
+ // Group files by {remote, basePath} so each batch targets one rclone remote
101
+ // and uses `--files-from` for internal parallelism.
102
+ const batches = new Map(); // key: "remote:basePath" → { remote, basePath, entries[] }
103
+
104
+ for (const entry of wholeFiles) {
105
+ const loc = entry.loc[0];
106
+ // loc.path is something like "parrot-blackbox/files/2026-09-02/Documents/index.txt"
107
+ // We need the basePath (everything up to the artifact root) and the
108
+ // relative tail so rclone can resolve --files-from entries.
109
+ const remoteFull = `${loc.remote}:${loc.path}`;
110
+ // Find the relative portion that matches entry.rel within loc.path
111
+ const relInPath = entry.rel;
112
+ const idx = loc.path.lastIndexOf(relInPath);
113
+ const basePath = idx > 0 ? loc.path.slice(0, idx) : path.dirname(loc.path) + '/';
114
+ const key = `${loc.remote}:${basePath}`;
115
+ if (!batches.has(key)) {
116
+ batches.set(key, { remote: loc.remote, basePath, entries: [] });
117
+ }
118
+ batches.get(key).entries.push(entry);
119
+ }
120
+
121
+ const batchDir = process.env.PBB_STATE_DIR || '/tmp';
122
+
123
+ for (const [key, batch] of batches.entries()) {
124
+ const filesList = batch.entries.map((e) => e.rel);
125
+ const batchPath = path.join(batchDir, `dl-batch-${batch.remote.replace(/[^a-z0-9]/gi, '_')}-${Date.now()}.txt`);
126
+ fs.mkdirSync(path.dirname(batchPath), { recursive: true });
127
+ fs.writeFileSync(batchPath, filesList.join('\n') + '\n');
128
+
129
+ const remoteSrc = `${batch.remote}:${batch.basePath}`;
130
+ const res = await downloadBatch(remoteSrc, destDir, batchPath);
131
+
132
+ // Clean up the batch file.
133
+ try { fs.unlinkSync(batchPath); } catch { /* best effort */ }
134
+
135
+ if (!res.ok) {
136
+ // Fall back to one-by-one downloads for this batch on failure.
137
+ for (const entry of batch.entries) {
138
+ const target = path.join(destDir, ...entry.rel.split('/'));
139
+ const loc = entry.loc[0];
140
+ const r = await copyToFile(`${loc.remote}:${loc.path}`, target);
141
+ if (!r.ok) throw new Error(`download failed for ${entry.rel}: ${r.error}`);
142
+ files += 1;
143
+ bytes += entry.size;
144
+ if (typeof onProgress === 'function') onProgress({ done: files, text: `restored ${entry.rel}` });
145
+ }
146
+ } else {
147
+ for (const entry of batch.entries) {
148
+ files += 1;
149
+ bytes += entry.size;
150
+ if (typeof onProgress === 'function') onProgress({ done: files, text: `restored ${entry.rel}` });
151
+ }
72
152
  }
153
+ }
73
154
 
74
- // Reassemble split file from byte-range parts.
155
+ // ── Step 4: Streaming reassembly for split files ──
156
+ for (const entry of splitFiles) {
157
+ const target = path.join(destDir, ...entry.rel.split('/'));
75
158
  const partAbs = `${target}.assembling-${process.pid}`;
76
159
  const out = createWriteStream(partAbs);
77
160
  const sorted = [...entry.loc].sort((a, b) => a.start - b.start);
@@ -92,16 +175,20 @@ export async function restoreArtifact(manifest, destDir, { onProgress } = {}) {
92
175
  bytes += entry.size;
93
176
  if (typeof onProgress === 'function') onProgress({ done: files, text: `restored ${entry.rel}` });
94
177
  }
178
+
95
179
  return { files, bytes };
96
180
  }
97
181
 
98
182
  /** Purge an artifact from every account that hosts it (+ local mirror). */
99
183
  export async function removeArtifact(kind, id, accounts, remoteRoot) {
100
- const removed = [];
101
- for (const acc of accounts || []) {
102
- const res = await purge(`${acc.remote}:${remoteRoot}/${kind}/${id}`);
103
- if (res.ok) removed.push(acc.remote);
104
- }
184
+ // Purge all accounts in parallel.
185
+ const results = await Promise.all(
186
+ (accounts || []).map(async (acc) => {
187
+ const res = await purge(`${acc.remote}:${remoteRoot}/${kind}/${id}`);
188
+ return res.ok ? acc.remote : null;
189
+ }),
190
+ );
191
+ const removed = results.filter(Boolean);
105
192
  try {
106
193
  fs.rmSync(manifestMirrorPath(kind, id), { force: true });
107
194
  } catch {
@@ -112,27 +199,32 @@ export async function removeArtifact(kind, id, accounts, remoteRoot) {
112
199
 
113
200
  /**
114
201
  * List artifacts of a kind discovered on any account (scans manifests).
202
+ * Accounts are scanned IN PARALLEL for speed.
115
203
  * @returns {Promise<Array>} [{kind, id, createdAt, totalSize, account, manifest}]
116
204
  */
117
205
  export async function listArtifacts(kind, accounts, remoteRoot, onProgress) {
118
- const out = [];
119
- for (const acc of accounts || []) {
120
- const scanPath = `${acc.remote}:${remoteRoot}/${kind}`;
121
- const res = await lsjson(scanPath, { recursive: true });
122
- if (!res.ok) continue;
123
- const manifests = res.entries.filter((e) => !e.IsDir && e.Path.endsWith(`/${MANIFEST_NAME}`));
124
- if (typeof onProgress === 'function') onProgress({ text: `scanning ${acc.remote}…` });
125
- for (const m of manifests) {
126
- const id = m.Path.split('/').slice(0, -1).pop();
127
- const cat = await catRemote(`${acc.remote}:${remoteRoot}/${kind}/${id}/${MANIFEST_NAME}`);
128
- if (!cat.ok) continue;
129
- try {
130
- const manifest = JSON.parse(cat.stdout);
131
- out.push({ kind, id, createdAt: manifest.createdAt, totalSize: manifest.totalSize, account: acc.remote, manifest });
132
- } catch {
133
- /* skip corrupt */
134
- }
135
- }
136
- }
137
- return out.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
206
+ const perAccount = await Promise.all(
207
+ (accounts || []).map(async (acc) => {
208
+ const scanPath = `${acc.remote}:${remoteRoot}/${kind}`;
209
+ const res = await lsjson(scanPath, { recursive: true });
210
+ if (!res.ok) return [];
211
+ const manifests = res.entries.filter((e) => !e.IsDir && e.Path.endsWith(`/${MANIFEST_NAME}`));
212
+ if (typeof onProgress === 'function') onProgress({ text: `scanning ${acc.remote}…` });
213
+ const items = await Promise.all(
214
+ manifests.map(async (m) => {
215
+ const id = m.Path.split('/').slice(0, -1).pop();
216
+ const cat = await catRemote(`${acc.remote}:${remoteRoot}/${kind}/${id}/${MANIFEST_NAME}`);
217
+ if (!cat.ok) return null;
218
+ try {
219
+ const manifest = JSON.parse(cat.stdout);
220
+ return { kind, id, createdAt: manifest.createdAt, totalSize: manifest.totalSize, account: acc.remote, manifest };
221
+ } catch {
222
+ return null;
223
+ }
224
+ }),
225
+ );
226
+ return items.filter(Boolean);
227
+ }),
228
+ );
229
+ return perAccount.flat().sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
138
230
  }
@@ -84,6 +84,19 @@ export async function copyBatch(localDir, remotePath, filesFromPath) {
84
84
  return { ok: res.exitCode === 0, exitCode: res.exitCode, error: res.stderr?.trim() };
85
85
  }
86
86
 
87
+ /** Download a batch of files using --files-from (reverse of copyBatch). */
88
+ export async function downloadBatch(remotePath, localDir, filesFromPath) {
89
+ const args = [
90
+ 'copy', remotePath, localDir,
91
+ '--files-from', filesFromPath,
92
+ '--transfers=16',
93
+ '--checkers=16',
94
+ '--fast-list'
95
+ ];
96
+ const res = await rejectFalse(args);
97
+ return { ok: res.exitCode === 0, exitCode: res.exitCode, error: res.stderr?.trim() };
98
+ }
99
+
87
100
  /** Create a remote directory. */
88
101
  export async function mkdirRemote(remotePath) {
89
102
  const res = await rejectFalse(['mkdir', remotePath]);