parrot-blackbox 2.0.9 → 2.1.1

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.0.9",
3
+ "version": "2.1.1",
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",
@@ -49,7 +49,21 @@ export function collectFiles(sources, { exclude = [], home = process.env.HOME }
49
49
 
50
50
  for (const src of sources) {
51
51
  const abs = expandPath(src, home);
52
- if (!fs.existsSync(abs)) {
52
+ let st;
53
+ try {
54
+ st = fs.statSync(abs); // throws → source is missing
55
+ } catch {
56
+ missing.push(src);
57
+ continue;
58
+ }
59
+
60
+ // A bare FILE source (e.g. `~/.gitconfig`) is backed up verbatim.
61
+ if (st.isFile()) {
62
+ files.push({ abs, rel: path.basename(abs) || 'file' });
63
+ continue;
64
+ }
65
+ // Sockets / FIFOs / devices aren't copyable as sources — skip cleanly.
66
+ if (!st.isDirectory()) {
53
67
  missing.push(src);
54
68
  continue;
55
69
  }
@@ -91,9 +105,10 @@ export function collectFiles(sources, { exclude = [], home = process.env.HOME }
91
105
  continue;
92
106
  }
93
107
  walk(eAbs, rel, sourceRoot);
94
- } else {
108
+ } else if (st.isFile()) {
95
109
  files.push({ abs: eAbs, rel });
96
110
  }
111
+ // sockets / FIFOs / devices can't be copied — silently skipped.
97
112
  }
98
113
  }
99
114
 
@@ -22,16 +22,15 @@ import { listLocalSnapshots } from './snapshot.js';
22
22
  import { ensureSudo, sudoInteractive } from '../util/sudo.js';
23
23
  import { bytesHuman } from '../util/misc.js';
24
24
 
25
- /** Restore a file backup generation into a writable local directory. */
26
- export async function restoreFiles({ id, toDir, accounts, cfg, onProgress }) {
27
- const found = await discoverManifest('files', id, accounts, cfg.storage.remoteRoot);
25
+ /** Restore a file-backup generation (or an urgent backup) into a writable local directory. */
26
+ export async function restoreFiles({ id, toDir, accounts, cfg, kind = 'files', onProgress }) {
27
+ const found = await discoverManifest(kind, id, accounts, cfg.storage.remoteRoot);
28
28
  if (!found) {
29
- // Fall back to scanning every account for the artifact id.
30
- throw new Error(`no file backup found for id "${id}" — check with \`parrot-blackbox list\``);
29
+ throw new Error(`no ${kind} backup found for id "${id}" — check with \`parrot-blackbox list\``);
31
30
  }
32
31
  fs.mkdirSync(toDir, { recursive: true });
33
32
  const res = await restoreArtifact(found.manifest, toDir, { onProgress });
34
- journal('restore', `files id=${id} -> ${toDir} files=${res.files} bytes=${res.bytes}`);
33
+ journal('restore', `${kind} id=${id} -> ${toDir} files=${res.files} bytes=${res.bytes}`);
35
34
  return { id, toDir, ...res, manifest: found.manifest };
36
35
  }
37
36
 
@@ -0,0 +1,147 @@
1
+ /**
2
+ * The URGENT backup — a fast, one-off rescue artifact for a fresh install.
3
+ *
4
+ * It bundles the user's real working files (Desktop / Downloads / Documents /
5
+ * Music / Pictures / Programming / Videos / Learning) PLUS the tooling needed
6
+ * to be productive on day one:
7
+ * - VS Codium data profile + extensions (+ shared storage / user config)
8
+ * - gitswitch bookkeeping + the SSH keys it manages + git config
9
+ *
10
+ * Design notes (why this is intentionally NOT the daily files job):
11
+ * - It uses its own fixed source list, so it never depends on the user's
12
+ * config having the right sources enabled.
13
+ * - It still honours the golden rule: git-tracked trees are skipped (GitHub
14
+ * already owns them) and the lean exclude list drops bloat (node_modules,
15
+ * caches, session noise).
16
+ * - It uploads through the same smart storage pool under a DISTINCT kind
17
+ * ('urgent') so it is never confused with — or pruned by — scheduled
18
+ * backups, and is cleanly restorable via the existing restore flow.
19
+ */
20
+
21
+ import fs from 'node:fs';
22
+ import path from 'node:path';
23
+ import { loadConfig, loadState, saveState, journal } from '../core/store.js';
24
+ import { stagingDir } from '../core/paths.js';
25
+ import { iso, clock } from '../core/time.js';
26
+ import { refreshAccounts } from '../storage/accounts.js';
27
+ import { planAndPlace } from '../storage/allocator.js';
28
+ import { collectFiles, stageFiles, sumFiles } from './git-exclude.js';
29
+
30
+ /** Cloud artifact kind (a distinct bucket from 'files' / 'snapshots'). */
31
+ export const KIND = 'urgent';
32
+
33
+ /** Everything the urgent backup takes from the home directory. */
34
+ export const URGENT_SOURCES = [
35
+ // Working files
36
+ '~/Desktop',
37
+ '~/Downloads',
38
+ '~/Documents',
39
+ '~/Learning',
40
+ '~/Music',
41
+ '~/Pictures',
42
+ '~/Programming',
43
+ '~/Videos',
44
+ // VS Codium — data profile + extensions + shared storage + user config
45
+ '~/.vscode-oss',
46
+ '~/.vscode-oss-shared',
47
+ '~/.config/VSCodium/User',
48
+ // gitswitch — accounts/SSH bookkeeping, the SSH keys it manages, git config
49
+ '~/.gitswitch',
50
+ '~/.ssh',
51
+ '~/.gitconfig',
52
+ ];
53
+
54
+ /** Lean exclude list — git repos + bloat are out, real files are in. */
55
+ export const URGENT_EXCLUDE = [
56
+ '**/.cache/**',
57
+ '**/.git/**',
58
+ '**/node_modules/**',
59
+ '**/__pycache__/**',
60
+ '**/*.tmp',
61
+ '**/*.swp',
62
+ '**/*.log',
63
+ '**/lost+found/**',
64
+ // VS Codium cache / session noise — the profile is what matters, not caches.
65
+ '**/Cache/**',
66
+ '**/CachedData/**',
67
+ '**/CachedExtensionVSIXs/**',
68
+ '**/GPUCache/**',
69
+ '**/blob_storage/**',
70
+ '**/Code Cache/**',
71
+ '**/Crashpad/**',
72
+ '**/Service Worker/**',
73
+ '**/WebStorage/**',
74
+ '**/Local Storage/**',
75
+ '**/Session Storage/**',
76
+ ];
77
+
78
+ /**
79
+ * Collect + stage the urgent sources into a fresh bundle dir.
80
+ * @returns {{dir:string, files:Array, sizeBytes:number, skippedRepos:string[], missing:string[], skipped:number}}
81
+ */
82
+ export function buildUrgentBundle({ home = process.env.HOME } = {}) {
83
+ const col = collectFiles(URGENT_SOURCES, { exclude: URGENT_EXCLUDE, home });
84
+ const dir = path.join(stagingDir(), `urgent-${Date.now()}-${process.pid}`);
85
+ fs.rmSync(dir, { recursive: true, force: true });
86
+ fs.mkdirSync(dir, { recursive: true });
87
+ stageFiles(col.files, dir);
88
+ return {
89
+ dir,
90
+ files: col.files,
91
+ sizeBytes: sumFiles(col.files),
92
+ skippedRepos: col.skippedRepos,
93
+ missing: col.missing,
94
+ skipped: col.skipped,
95
+ };
96
+ }
97
+
98
+ /**
99
+ * Run one URGENT backup generation — bundle → stage → upload to the pool.
100
+ * Unlike scheduled backups this does NOT run retention, so the rescue artifact
101
+ * is never auto-deleted.
102
+ * @returns {Promise<{id:string, manifest:object, sizeBytes:number, skippedRepos:string[], missing:string[]}>}
103
+ */
104
+ export async function runUrgentBackup(cfg = loadConfig(), state = loadState(), { onProgress } = {}) {
105
+ const now = clock();
106
+ const id = iso(now);
107
+ journal('urgent', `start id=${id}`);
108
+ const bundle = buildUrgentBundle();
109
+
110
+ const accounts = await refreshAccounts(cfg);
111
+ if (!accounts.length) {
112
+ fs.rmSync(bundle.dir, { recursive: true, force: true });
113
+ throw new Error('No cloud accounts configured — run `parrot-blackbox account add` (or Guided Setup) first.');
114
+ }
115
+
116
+ let manifest;
117
+ try {
118
+ manifest = await planAndPlace(bundle.dir, {
119
+ kind: KIND,
120
+ id,
121
+ accounts,
122
+ remoteRoot: cfg.storage.remoteRoot,
123
+ chunkSize: cfg.storage.chunkSize,
124
+ onProgress,
125
+ });
126
+ } finally {
127
+ fs.rmSync(bundle.dir, { recursive: true, force: true });
128
+ }
129
+
130
+ state.manifests[`${KIND}-${manifest.id}`] = {
131
+ kind: KIND,
132
+ id: manifest.id,
133
+ createdAt: manifest.createdAt,
134
+ totalSize: manifest.totalSize,
135
+ entryCount: manifest.entries?.length || 0,
136
+ };
137
+ saveState(state);
138
+ journal('urgent', `done id=${id} bytes=${manifest.totalSize}`);
139
+
140
+ return {
141
+ id,
142
+ manifest,
143
+ sizeBytes: manifest.totalSize,
144
+ skippedRepos: bundle.skippedRepos,
145
+ missing: bundle.missing,
146
+ };
147
+ }
package/src/cli.js CHANGED
@@ -14,6 +14,7 @@ import { installService, removeService } from './commands/service.js';
14
14
  import { runDueJobs } from './daemon/scheduler.js';
15
15
  import { startDaemon, stopDaemon, daemonRunning } from './daemon/daemon.js';
16
16
  import { runSnapshotNow, listLocalSnapshots, pruneSnapshots, deleteSnapshot, deleteAllSnapshots } from './backup/snapshot.js';
17
+ import { runUrgentBackup } from './backup/urgent.js';
17
18
  import { restoreSnapshot, restoreFiles } from './backup/restore.js';
18
19
  import { listAccounts, addAccount, removeAccount, refreshAccounts, poolSummary } from './storage/accounts.js';
19
20
  import { listArtifacts } from './storage/archive.js';
@@ -52,6 +53,7 @@ ${pc.bold('Usage:')}
52
53
  parrot-blackbox snapshot list List local & cloud snapshots
53
54
  parrot-blackbox snapshot delete [<name>|--all] Delete one or all local snapshots ${pc.dim('[sudo]')}
54
55
  parrot-blackbox snapshot prune Delete snapshots beyond the keep limit ${pc.dim('[sudo]')}
56
+ parrot-blackbox urgent ⚡ One-off rescue backup: working files + VS Codium + gitswitch/SSH data
55
57
  parrot-blackbox list [files] List cloud file backups
56
58
  parrot-blackbox restore Restore a snapshot or file backup ${pc.dim('[sudo]')}
57
59
  parrot-blackbox account add Add a MEGA / Google Drive account (remote must already exist)
@@ -132,6 +134,12 @@ async function listFiles() {
132
134
  for (const a of artifacts) {
133
135
  console.log(` - ${pc.cyan(a.id)} ${bytesHuman(a.totalSize)} ${pc.dim(a.account)}`);
134
136
  }
137
+ const urgent = await listArtifacts('urgent', accs, cfg.storage.remoteRoot);
138
+ console.log(`\n${pc.bold('Cloud urgent backups:')}`);
139
+ if (urgent.length === 0) console.log(` ${pc.dim('none yet — run `parrot-blackbox urgent`')}`);
140
+ for (const a of urgent) {
141
+ console.log(` - ${pc.cyan(a.id)} ${bytesHuman(a.totalSize)} ${pc.dim(a.account)}`);
142
+ }
135
143
  console.log();
136
144
  }
137
145
 
@@ -162,13 +170,14 @@ async function restoreFlow(rest) {
162
170
  options: [
163
171
  { value: 'snapshot', label: 'System snapshot (Timeshift) — overwrites the whole system', hint: '[sudo]' },
164
172
  { value: 'files', label: 'File backup — recover fonts/images/docs into a folder' },
173
+ { value: 'urgent', label: 'Urgent backup — user files + tool profiles (fresh install)' },
165
174
  ],
166
175
  }));
167
176
  if (p.isCancel(kind)) return;
168
177
 
169
- if (kind === 'files') {
170
- const artifacts = await listArtifacts('files', accs, cfg.storage.remoteRoot);
171
- if (artifacts.length === 0) { p.log.warn('No file backups found.'); return; }
178
+ if (kind === 'files' || kind === 'urgent') {
179
+ const artifacts = await listArtifacts(kind, accs, cfg.storage.remoteRoot);
180
+ if (artifacts.length === 0) { p.log.warn(`No ${kind === 'urgent' ? 'urgent' : 'file'} backups found.`); return; }
172
181
  const id = rest[1] || (await p.select({
173
182
  message: 'Pick a backup generation:',
174
183
  options: artifacts.map((a) => ({ value: a.id, label: `${a.id} (${bytesHuman(a.totalSize)})` })),
@@ -181,7 +190,7 @@ async function restoreFlow(rest) {
181
190
  const s = p.spinner();
182
191
  s.start('Restoring…');
183
192
  try {
184
- const res = await restoreFiles({ id, toDir, accounts: accs, cfg });
193
+ const res = await restoreFiles({ id, toDir, accounts: accs, cfg, kind });
185
194
  s.stop(`✔ Restored ${res.files} file(s), ${bytesHuman(res.bytes)} into ${toDir}`);
186
195
  } catch (e) {
187
196
  s.stop('✖ Restore failed.');
@@ -408,6 +417,23 @@ const main = defineCommand({
408
417
  return;
409
418
  }
410
419
 
420
+ case 'urgent': {
421
+ // One-off rescue backup — working files + VS Codium + gitswitch/SSH data.
422
+ const progress = makeProgressRenderer();
423
+ try {
424
+ const r = await runUrgentBackup(undefined, undefined, { onProgress: progress });
425
+ progress.stop();
426
+ console.log(`${pc.green('✔')} Urgent backup stored (${bytesHuman(r.sizeBytes)}). Restore with: \`parrot-blackbox restore urgent\`.`);
427
+ if (r.skippedRepos?.length) console.log(pc.dim(`Skipped ${r.skippedRepos.length} git-tracked folder(s).`));
428
+ if (r.missing?.length) console.log(pc.dim(`Source(s) not present, skipped: ${r.missing.join(', ')}.`));
429
+ } catch (e) {
430
+ progress.stop();
431
+ console.error(pc.red(`✖ ${e.message}`));
432
+ process.exitCode = 1;
433
+ }
434
+ return;
435
+ }
436
+
411
437
  case 'snapshot': {
412
438
  const [sub, ...args] = rest;
413
439
  if (sub === 'now' || sub === 'create' || sub === 'force') {
@@ -18,6 +18,7 @@ import { listAccounts, refreshAccounts, poolSummary, addAccount, removeAccount }
18
18
  import { loadConfig, saveConfig } from '../core/store.js';
19
19
  import { runDueJobs } from '../daemon/scheduler.js';
20
20
  import { runSnapshotNow, listLocalSnapshots, deleteSnapshot, deleteAllSnapshots, nextSnapshotUploadMode } from '../backup/snapshot.js';
21
+ import { runUrgentBackup } from '../backup/urgent.js';
21
22
  import { listArtifacts } from '../storage/archive.js';
22
23
  import { restoreFiles, restoreSnapshot } from '../backup/restore.js';
23
24
  import { installService, removeService } from './service.js';
@@ -177,6 +178,33 @@ async function backupNowAction() {
177
178
  }
178
179
  }
179
180
 
181
+ /** One-off rescue backup: working files + VS Codium + gitswitch/SSH data (fast, for a fresh install). */
182
+ async function urgentBackupAction() {
183
+ const cfg = loadConfig();
184
+ if (!listAccounts().length) { p.log.warn('No cloud accounts configured — add one from the menu first.'); return; }
185
+ const { URGENT_SOURCES } = await import('../backup/urgent.js');
186
+ const names = URGENT_SOURCES.map((s) => s.replace(/^~\//, ''));
187
+ const ok = await p.confirm({
188
+ message: pc.bold(`⚡ Urgent backup: ${names.length} sources`) +
189
+ pc.dim(` — ${names.join(', ')}.`) +
190
+ pc.dim('\nGit-tracked folders are skipped (already on GitHub); only real files + tool profiles are stored. Continue?'),
191
+ initialValue: true,
192
+ });
193
+ if (p.isCancel(ok) || !ok) { p.log.message(pc.dim('Cancelled — nothing was backed up.')); return; }
194
+
195
+ const progress = makeClackProgressRenderer(p);
196
+ try {
197
+ const r = await runUrgentBackup(cfg, undefined, { onProgress: progress });
198
+ p.log.success(`✔ Urgent backup stored (${bytesHuman(r.sizeBytes)}). Restore later via: Restore backup → Urgent backup.`);
199
+ if (r.skippedRepos?.length) p.log.message(pc.dim(`Skipped ${r.skippedRepos.length} git-tracked folder(s).`));
200
+ if (r.missing?.length) p.log.message(pc.dim(`Source(s) not present, skipped: ${r.missing.join(', ')}.`));
201
+ } catch (e) {
202
+ p.log.warn(`✖ ${e.message}`);
203
+ } finally {
204
+ progress.stop();
205
+ }
206
+ }
207
+
180
208
  /** Create + upload a snapshot immediately. */
181
209
  async function snapshotNowAction() {
182
210
  try {
@@ -224,6 +252,10 @@ async function listBackupsAction() {
224
252
  const files = await listArtifacts('files', accs, cfg.storage.remoteRoot);
225
253
  if (!files.length) p.log.message(pc.dim(' none'));
226
254
  for (const f of files) p.log.message(` - ${pc.cyan(f.id)} ${bytesHuman(f.totalSize)}`);
255
+ p.log.message(pc.bold('Cloud urgent backups:'));
256
+ const urgent = await listArtifacts('urgent', accs, cfg.storage.remoteRoot);
257
+ if (!urgent.length) p.log.message(pc.dim(' none'));
258
+ for (const u of urgent) p.log.message(` - ${pc.cyan(u.id)} ${bytesHuman(u.totalSize)}`);
227
259
  } else {
228
260
  p.log.message(pc.dim('No accounts configured — add one from the menu.'));
229
261
  }
@@ -314,7 +346,27 @@ async function deleteSnapshotsMenu() {
314
346
  }
315
347
  }
316
348
 
317
- /** Restore files or a system snapshot. */
349
+ /** Restore a file-like artifact ('files' or 'urgent') into a fresh directory. */
350
+ async function restoreFileLike(kind, accs, cfg) {
351
+ const artifacts = await listArtifacts(kind, accs, cfg.storage.remoteRoot);
352
+ if (!artifacts.length) { p.log.warn(`No ${kind === 'urgent' ? 'urgent' : 'file'} backups found.`); return; }
353
+ const id = await p.select({
354
+ message: 'Pick a backup to restore:',
355
+ options: artifacts.map((a) => ({ value: a.id, label: `${a.id} (${bytesHuman(a.totalSize)})` })).concat([{ value: '__back', label: '← Back' }]),
356
+ });
357
+ if (p.isCancel(id) || id === '__back') return;
358
+ const toDir = await p.text({ message: 'Restore into which directory?', initialValue: `./restored-${id}` });
359
+ if (p.isCancel(toDir) || !toDir) return;
360
+ fs.mkdirSync(toDir, { recursive: true });
361
+ try {
362
+ const res = await restoreFiles({ id, toDir, accounts: accs, cfg, kind });
363
+ p.log.success(`✔ Restored ${res.files} file(s), ${bytesHuman(res.bytes)} into ${toDir}`);
364
+ } catch (e) {
365
+ p.log.warn(`✖ ${e.message}`);
366
+ }
367
+ }
368
+
369
+ /** Restore files / urgent backup / system snapshot. */
318
370
  async function restoreMenu() {
319
371
  const accs = listAccounts();
320
372
  if (!accs.length) { p.log.warn('No cloud accounts configured yet.'); return; }
@@ -323,29 +375,15 @@ async function restoreMenu() {
323
375
  message: '♻️ Restore backup',
324
376
  options: [
325
377
  { value: 'files', label: '📄 Files', hint: 'recover documents, images, etc.' },
378
+ { value: 'urgent', label: '⚡ Urgent backup', hint: 'user files + tool profiles (fresh install)' },
326
379
  { value: 'snapshot', label: '💽 System snapshot', hint: 'full system restore [sudo]' },
327
380
  { value: 'back', label: '← Back' },
328
381
  ],
329
382
  });
330
383
  if (p.isCancel(kind) || kind === 'back') return;
331
384
 
332
- if (kind === 'files') {
333
- const artifacts = await listArtifacts('files', accs, cfg.storage.remoteRoot);
334
- if (!artifacts.length) { p.log.warn('No file backups found.'); return; }
335
- const id = await p.select({
336
- message: 'Pick a backup generation:',
337
- options: artifacts.map((a) => ({ value: a.id, label: `${a.id} (${bytesHuman(a.totalSize)})` })).concat([{ value: '__back', label: '← Back' }]),
338
- });
339
- if (p.isCancel(id) || id === '__back') return;
340
- const toDir = await p.text({ message: 'Restore into which directory?', initialValue: `./restored-${id}` });
341
- if (p.isCancel(toDir) || !toDir) return;
342
- fs.mkdirSync(toDir, { recursive: true });
343
- try {
344
- const res = await restoreFiles({ id, toDir, accounts: accs, cfg });
345
- p.log.success(`✔ Restored ${res.files} file(s), ${bytesHuman(res.bytes)} into ${toDir}`);
346
- } catch (e) {
347
- p.log.warn(`✖ ${e.message}`);
348
- }
385
+ if (kind === 'files' || kind === 'urgent') {
386
+ await restoreFileLike(kind, accs, cfg);
349
387
  return;
350
388
  }
351
389
 
@@ -445,6 +483,7 @@ export async function runWizard() {
445
483
  message: 'What would you like to do?',
446
484
  options: [
447
485
  { value: 'snapshot', label: '📸 Create snapshot', hint: 'backup your system now' },
486
+ { value: 'urgent', label: '⚡ Urgent backup', hint: 'files + tool profiles, fast — rescue for a fresh install' },
448
487
  { value: 'resume', label: '⏳ Resume upload', hint: 'resume incomplete backup uploads' },
449
488
  { value: 'backup', label: '💾 Run all backups', hint: 'snapshots + file backups' },
450
489
  { value: 'restore', label: '♻️ Restore backup', hint: 'files or system snapshot' },
@@ -476,6 +515,7 @@ export async function runWizard() {
476
515
  case 'accounts': await accountsMenu(); break;
477
516
  case 'tools': await runToolsCheck(); break;
478
517
  case 'snapshot': await snapshotNowAction(); break;
518
+ case 'urgent': await urgentBackupAction(); break;
479
519
  case 'resume': await snapshotNowAction(); break;
480
520
  case 'backup': await backupNowAction(); break;
481
521
  case 'list': await listBackupsAction(); break;
@@ -122,12 +122,12 @@ export async function planAndPlace(localDir, { kind, id, accounts, remoteRoot, c
122
122
  entries: [],
123
123
  };
124
124
 
125
- const report = (done, text) => {
126
- if (typeof onProgress === 'function') onProgress({ done, total: files.length, text });
125
+ const emit = (done, text, remote) => {
126
+ if (typeof onProgress === 'function') onProgress({ done, total: totalSize, text, remote });
127
127
  };
128
128
 
129
129
  // PASS 1: Planning
130
- report(0, 'planning allocations...');
130
+ emit(0, 'planning allocations...');
131
131
  const batches = new Map(); // account.remote -> array of relative paths
132
132
  const splits = []; // files that need splitting
133
133
 
@@ -162,24 +162,30 @@ export async function planAndPlace(localDir, { kind, id, accounts, remoteRoot, c
162
162
 
163
163
  // PASS 2: Batch Uploading (Sequential accounts, but parallel files inside rclone)
164
164
  const batchDir = process.env.PBB_STATE_DIR || '/tmp';
165
- let placed = 0;
166
-
165
+ const fileSizes = new Map(files.map((f) => [f.rel, f.size]));
166
+ const batchBytes = (rels) => rels.reduce((s, r) => s + (fileSizes.get(r) ?? 0), 0);
167
+ let placedBytes = 0;
168
+
167
169
  for (const [remote, batch] of batches.entries()) {
168
170
  if (batch.files.length === 0) continue;
169
171
  const batchPath = path.join(batchDir, `batch-${remote.replace(/[^a-z0-9]/gi, '_')}-${Date.now()}.txt`);
170
172
  fs.writeFileSync(batchPath, batch.files.join('\n') + '\n');
171
-
173
+
172
174
  // Ensure the remote directory exists to prevent "directory not found" errors
173
175
  // when rclone tries to list the destination for --files-from checking.
174
176
  await mkdirRemote(`${remote}:${basePath}`);
175
-
176
- report(placed, `uploading batch of ${batch.files.length} files to ${remote}...`);
177
- const res = await copyBatch(localDir, `${remote}:${basePath}`, batchPath);
177
+
178
+ const thisBatchBytes = batchBytes(batch.files);
179
+ const label = `uploading ${batch.files.length} file(s) → ${remote}`;
180
+ emit(placedBytes, label, remote);
181
+ const res = await copyBatch(localDir, `${remote}:${basePath}`, batchPath, {
182
+ onProgress: (p) => emit(placedBytes + p.done, label, remote),
183
+ });
178
184
  if (!res.ok) throw new Error(`batch upload failed to ${remote}: ${res.error}`);
179
-
185
+
180
186
  fs.unlinkSync(batchPath);
181
- placed += batch.files.length;
182
- report(placed, `placed ${placed}/${files.length}`);
187
+ placedBytes += thisBatchBytes;
188
+ emit(placedBytes, `uploaded ${bytesHuman(placedBytes)}`, remote);
183
189
  }
184
190
 
185
191
  // PASS 3: Split large files
@@ -189,27 +195,27 @@ export async function planAndPlace(localDir, { kind, id, accounts, remoteRoot, c
189
195
  const locs = [];
190
196
  let start = 0;
191
197
  let partIndex = 0;
192
-
193
- report(placed, `splitting huge file ${rel}...`);
198
+
199
+ emit(placedBytes, `splitting huge file ${rel}...`);
194
200
  while (start < entry.size) {
195
201
  const len = Math.min(chunkSize, entry.size - start);
196
202
  const acc = chooseAccount(len, pool);
197
203
  if (!acc) throw outOfSpace();
198
-
204
+
199
205
  const partAbs = await makePartFile(entry, start, len);
200
206
  const partPath = `${destPath}.part-${String(partIndex).padStart(4, '0')}`;
201
-
207
+
202
208
  const res = await copyToFile(partAbs, `${acc.remote}:${partPath}`);
203
209
  if (!res.ok) throw new Error(`upload failed for ${rel} part on ${acc.remote}: ${res.error}`);
204
-
210
+
205
211
  consume(acc, len);
206
212
  locs.push({ remote: acc.remote, path: partPath, start, end: start + len, size: len });
207
213
  start += len;
208
214
  partIndex += 1;
215
+ placedBytes += len;
216
+ emit(placedBytes, `uploaded ${bytesHuman(placedBytes)}`, acc.remote);
209
217
  }
210
218
  manifest.entries.push({ rel, type: 'file', size: entry.size, split: true, loc: locs });
211
- placed += 1;
212
- report(placed, `placed ${placed}/${files.length}`);
213
219
  }
214
220
 
215
221
  // Manifest: cloud + local mirror.
@@ -71,19 +71,59 @@ 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) {
74
+ /** Copy a batch of files using --files-from. When `onProgress` is supplied we
75
+ * ask rclone for live transfer stats and parse its `Transferred: X / Y` frames
76
+ * so the caller receives byte-level progress as `{ done, total }` (bytes). */
77
+ export async function copyBatch(localDir, remotePath, filesFromPath, { onProgress } = {}) {
76
78
  const args = [
77
79
  'copy', localDir, remotePath,
78
80
  '--files-from', filesFromPath,
79
81
  '--transfers=16',
80
82
  '--checkers=16',
81
- '--fast-list'
83
+ '--fast-list',
82
84
  ];
83
- const res = await rejectFalse(args);
85
+ if (typeof onProgress === 'function') {
86
+ // rclone emits periodic frames even when stdout is not a TTY (pipelines).
87
+ args.push('--progress', '--stats=1s');
88
+ }
89
+ const child = rejectFalse(args);
90
+ if (typeof onProgress === 'function') {
91
+ await consumeProgress(child, onProgress);
92
+ }
93
+ const res = await child;
84
94
  return { ok: res.exitCode === 0, exitCode: res.exitCode, error: res.stderr?.trim() };
85
95
  }
86
96
 
97
+ /** Consume rclone's --progress stdout, emitting parsed byte counts per frame. */
98
+ async function consumeProgress(child, onProgress) {
99
+ let buf = '';
100
+ for await (const chunk of child.stdout) {
101
+ buf += chunk;
102
+ const frames = buf.split(/[\r\n]+/);
103
+ buf = frames.pop(); // keep a partial trailing frame for the next chunk
104
+ for (const frame of frames) {
105
+ const p = parseTransferFrame(frame);
106
+ if (p) onProgress(p);
107
+ }
108
+ }
109
+ const tail = parseTransferFrame(buf);
110
+ if (tail) onProgress(tail);
111
+ }
112
+
113
+ /**
114
+ * Extract the BYTE `Transferred: <done> / <total>` from an rclone progress
115
+ * frame. This deliberately requires a size unit (KiB/MiB/GiB/…) so the separate
116
+ * FILE-count `Transferred: 3 / 5` line is never mistaken for bytes.
117
+ * @returns {{done:number,total:number}|null}
118
+ */
119
+ export function parseTransferFrame(frame) {
120
+ const m = /\bTransferred:\s+([\d.]+\s*(?:[kmgt]i?)?b)\s*\/\s*([\d.]+\s*(?:[kmgt]i?)?b)/i.exec(frame);
121
+ if (!m) return null;
122
+ const done = parseBytes(m[1]);
123
+ const total = parseBytes(m[2]);
124
+ return done !== null && total !== null ? { done, total } : null;
125
+ }
126
+
87
127
  /** Download a batch of files using --files-from (reverse of copyBatch). */
88
128
  export async function downloadBatch(remotePath, localDir, filesFromPath) {
89
129
  const args = [