parrot-blackbox 1.0.12 → 1.0.14

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
 
@@ -107,6 +109,25 @@ and pending backups.
107
109
 
108
110
  ---
109
111
 
112
+ ## 🤖 Background Automation (Set it and forget it)
113
+
114
+ To make `parrot-blackbox` take weekly snapshots and upload them to your cloud accounts completely automatically in the background, install the background service:
115
+
116
+ ```bash
117
+ parrot-blackbox schedule install
118
+ parrot-blackbox daemon start
119
+ ```
120
+
121
+ 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.
122
+ 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!
123
+
124
+ You can check on the daemon at any time using:
125
+ ```bash
126
+ parrot-blackbox status
127
+ ```
128
+
129
+ ---
130
+
110
131
  ## Managing cloud accounts (rclone remotes)
111
132
 
112
133
  Every MEGA / Google Drive login becomes one rclone remote = one pool account.
@@ -449,6 +470,7 @@ Every account is exactly one rclone remote (`mega-1:`, `gdrive-6:` …).
449
470
  percentage** (water-filling), then most free.
450
471
  - A file that doesn't fit any single account is split at `chunkSize` boundaries
451
472
  and the pieces are spread across accounts — restore reassembles byte-perfect.
473
+ - **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
474
  - The manifest is written to the cloud **and** mirrored locally, so a wiped
453
475
  machine can still find and restore everything.
454
476
 
@@ -495,18 +517,27 @@ parrot-blackbox restore files <backup-id> ~/recovered
495
517
  ```bash
496
518
  # on the fresh Parrot:
497
519
  npm install -g parrot-blackbox
498
- rclone config # re-add the same remotes
520
+ rclone config # re-add the same remotes
499
521
  parrot-blackbox account add mega mega-1 # …add every account
500
- parrot-blackbox snapshot list # see what's in the cloud
522
+ parrot-blackbox snapshot list # see what's in the cloud
501
523
  parrot-blackbox restore snapshot <snapshot-id> --yes
502
524
  ```
503
525
 
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.
526
+ #### What happens during a snapshot restore on a fresh install?
527
+ When you run the restore command, `parrot-blackbox` does the following:
528
+ 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.
529
+ 2. **Runs Timeshift Restore:** It then hands over control to Timeshift (`sudo timeshift --restore`).
530
+ 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.
531
+ 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.
532
+
533
+ #### What about Disk Encryption (Passphrases)?
534
+ **Disk encryption (LUKS) operates at the hardware/disk level, while snapshots operate at the file level.**
535
+ 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.**
536
+ - The snapshot only contains your files and programs, not the LUKS encryption container.
537
+ - When Timeshift restores the files onto your new unencrypted fresh install, it simply places the files into the unencrypted disk.
538
+ - 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!
539
+
540
+ *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
541
 
511
542
  ---
512
543
 
@@ -515,11 +546,13 @@ run from inside the OS.
515
546
  - **Scheduling** — file backups daily 22:00, snapshots every Saturday 22:00
516
547
  (a recent Friday-through-Sunday window is hidden from storage by keeping
517
548
  only 3 generations). Both schedules are configurable.
518
- - **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*
519
550
  calendar due that has passed since the last one it considered, drops the
520
551
  ancient backlog beyond `catchUpLimit`, and drains the rest **oldest first**.
521
552
  If it's offline at that moment, the dues stay pending and the daemon watches
522
- 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.
523
556
  - **Crash-proofing** — state is written atomically (tmp + rename); the journal
524
557
  appends one line per event; every job opens a journal entry and only closes
525
558
  it on success; a single process lock (`withLock`) keeps the daemon and a
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "parrot-blackbox",
3
- "version": "1.0.12",
3
+ "version": "1.0.14",
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",
@@ -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;
@@ -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,88 @@ 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
+ // 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
+
152
+ report(placed, `uploading batch of ${batch.files.length} files to ${remote}...`);
153
+ const res = await copyBatch(localDir, `${remote}:${basePath}`, batchPath);
154
+ if (!res.ok) throw new Error(`batch upload failed to ${remote}: ${res.error}`);
155
+
156
+ fs.unlinkSync(batchPath);
157
+ placed += batch.files.length;
158
+ report(placed, `placed ${placed}/${files.length}`);
159
+ }
160
+
161
+ // PASS 3: Split large files
162
+ for (const entry of splits) {
163
+ const rel = entry.rel;
164
+ const destPath = `${basePath}/${rel}`;
165
+ const locs = [];
166
+ let start = 0;
167
+ let partIndex = 0;
168
+
169
+ report(placed, `splitting huge file ${rel}...`);
170
+ while (start < entry.size) {
171
+ const len = Math.min(chunkSize, entry.size - start);
172
+ const acc = chooseAccount(len, pool);
173
+ if (!acc) throw outOfSpace();
174
+
175
+ const partAbs = await makePartFile(entry, start, len);
176
+ const partPath = `${destPath}.part-${String(partIndex).padStart(4, '0')}`;
177
+
178
+ const res = await copyToFile(partAbs, `${acc.remote}:${partPath}`);
179
+ if (!res.ok) throw new Error(`upload failed for ${rel} part on ${acc.remote}: ${res.error}`);
180
+
181
+ consume(acc, len);
182
+ locs.push({ remote: acc.remote, path: partPath, start, end: start + len, size: len });
183
+ start += len;
184
+ partIndex += 1;
158
185
  }
186
+ manifest.entries.push({ rel, type: 'file', size: entry.size, split: true, loc: locs });
159
187
  placed += 1;
160
188
  report(placed, `placed ${placed}/${files.length}`);
161
189
  }
@@ -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
  }
@@ -71,6 +71,32 @@ 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
+
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
+
74
100
  /** Create a remote directory. */
75
101
  export async function mkdirRemote(remotePath) {
76
102
  const res = await rejectFalse(['mkdir', remotePath]);