parrot-blackbox 2.0.5 → 2.0.7

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.5",
3
+ "version": "2.0.7",
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",
@@ -259,6 +259,31 @@ export async function deleteAllSnapshots({ privileged = 'interactive', onProgres
259
259
 
260
260
  return { deleted, failed };
261
261
  }
262
+ /**
263
+ * A module-level registry of all temporary BTRFS mounts created during this
264
+ * process lifetime, so we can unmount them all at exit even if the snapshot
265
+ * object that originally held the reference was garbage-collected.
266
+ */
267
+ const _activeTempMounts = new Set();
268
+
269
+ /**
270
+ * Unmount and remove all temporary BTRFS mount points created by this process.
271
+ * Called on process exit and also by cleanupSnapshotMount.
272
+ */
273
+ function _cleanupAllTempMounts() {
274
+ for (const mp of _activeTempMounts) {
275
+ try { sudoExecSync(['umount', mp]); } catch { /* best effort */ }
276
+ try { sudoExecSync(['rmdir', mp]); } catch { /* best effort */ }
277
+ _activeTempMounts.delete(mp);
278
+ }
279
+ }
280
+
281
+ // Register a process exit handler so stale mounts are always cleaned up,
282
+ // even if the snapshot object holding _tempMount was discarded before cleanup.
283
+ process.on('exit', _cleanupAllTempMounts);
284
+ // Also handle abnormal exits so the mount isn't left behind after Ctrl+C
285
+ // (SIGINT/SIGTERM handlers in cli.js call process.exit(), which fires 'exit').
286
+
262
287
  /**
263
288
  * Resolve the on-disk directory of a snapshot.
264
289
  *
@@ -270,6 +295,11 @@ export async function deleteAllSnapshots({ privileged = 'interactive', onProgres
270
295
  * 2. Check static paths (rsync mode)
271
296
  * 3. Mount the BTRFS root subvolume ourselves to access snapshots persistently
272
297
  * 4. Fall back to triggering timeshift --list
298
+ *
299
+ * IMPORTANT: the mount point is registered in _activeTempMounts so that
300
+ * _cleanupAllTempMounts() can release it on process exit even if the snapshot
301
+ * object is later discarded (e.g. after a resume-upload path that builds a new
302
+ * snapshot object from just the name).
273
303
  */
274
304
  export function snapshotDirFor(snapshot, { privileged = 'noninteractive' } = {}) {
275
305
  if (snapshot.dir && fs.existsSync(snapshot.dir)) return snapshot.dir;
@@ -301,6 +331,9 @@ export function snapshotDirFor(snapshot, { privileged = 'noninteractive' } = {})
301
331
  const mountResult = sudoExecSync(['mount', '-o', 'subvolid=5', device, mountPoint]);
302
332
 
303
333
  if (mountResult.exitCode === 0) {
334
+ // Register in the global set so it is always cleaned up on exit.
335
+ _activeTempMounts.add(mountPoint);
336
+
304
337
  // Search for the snapshot in the mounted root subvolume
305
338
  const searchPaths = [
306
339
  `${mountPoint}/timeshift-btrfs/snapshots/${snapshot.name}`,
@@ -311,12 +344,13 @@ export function snapshotDirFor(snapshot, { privileged = 'noninteractive' } = {})
311
344
  const found = searchPaths.find((p) => fs.existsSync(p));
312
345
 
313
346
  if (found) {
314
- // Store the mount point for cleanup later
347
+ // Store the mount point on the snapshot object for explicit early cleanup.
315
348
  snapshot._tempMount = mountPoint;
316
349
  return found;
317
350
  }
318
351
 
319
- // Unmount if we didn't find anything
352
+ // Unmount immediately if we didn't find anything useful.
353
+ _activeTempMounts.delete(mountPoint);
320
354
  sudoExecSync(['umount', mountPoint]);
321
355
  sudoExecSync(['rmdir', mountPoint]);
322
356
  }
@@ -332,13 +366,15 @@ export function snapshotDirFor(snapshot, { privileged = 'noninteractive' } = {})
332
366
  /** Cleanup temporary BTRFS mount if one was created */
333
367
  export function cleanupSnapshotMount(snapshot) {
334
368
  if (snapshot && snapshot._tempMount) {
369
+ const mp = snapshot._tempMount;
335
370
  try {
336
- sudoExecSync(['umount', snapshot._tempMount]);
337
- sudoExecSync(['rmdir', snapshot._tempMount]);
338
- delete snapshot._tempMount;
371
+ sudoExecSync(['umount', mp]);
372
+ sudoExecSync(['rmdir', mp]);
339
373
  } catch {
340
374
  /* best effort */
341
375
  }
376
+ _activeTempMounts.delete(mp);
377
+ delete snapshot._tempMount;
342
378
  }
343
379
  }
344
380
 
@@ -523,11 +559,30 @@ export async function runSnapshotBackup(cfg, state, { due, privileged = 'noninte
523
559
  * `btrfs send` always receives a real subvolume, never a plain directory.
524
560
  */
525
561
  async function uploadViaBtrfsSend({ snapshot, subvolPath, parentSnapshot, parentSubvolPath, accounts, cfg, due, privileged, onProgress }) {
526
- const { createSendStream, estimateSendSize, isValidBtrfsStreamManifest } = await import('./btrfs-send.js');
562
+ const { createSendStream, estimateSendSize, isValidBtrfsStreamManifest, isSubvolumeReadOnly, setSubvolumeReadOnly } = await import('./btrfs-send.js');
527
563
  const { planAndPlaceStream } = await import('../storage/allocator.js');
528
564
 
529
565
  if (!subvolPath) throw new Error('no BTRFS subvolume path for snapshot upload');
530
566
 
567
+ // ── RO guard ──────────────────────────────────────────────────────────────
568
+ // btrfs send requires the subvolume to be read-only (ro=true). Timeshift
569
+ // always creates snapshots as RO, but when they are accessed via a
570
+ // subvolid=5 bind-mount the kernel may report the inner @ subvolume as
571
+ // rw=false (writable) depending on mount flags. Detect and fix this before
572
+ // handing the path to btrfs send, otherwise the send exits 1 with
573
+ // "subvolume … is not read-only".
574
+ const isRO = await isSubvolumeReadOnly(subvolPath, { privileged });
575
+ if (!isRO) {
576
+ journal('snapshots', `subvolume ${subvolPath} is not read-only — setting ro=true before send`);
577
+ try {
578
+ await setSubvolumeReadOnly(subvolPath, true, { privileged });
579
+ journal('snapshots', `set ${subvolPath} read-only OK`);
580
+ } catch (e) {
581
+ throw new Error(`btrfs send requires a read-only subvolume but ${subvolPath} is writable and could not be set read-only: ${e.message}`);
582
+ }
583
+ }
584
+ // ─────────────────────────────────────────────────────────────────────────
585
+
531
586
  journal('snapshots', `btrfs send subvolPath=${subvolPath} parent=${parentSubvolPath || 'null'}`);
532
587
 
533
588
  // Estimate size for progress reporting
package/src/cli.js CHANGED
@@ -2,6 +2,8 @@ import { defineCommand, runMain } from 'citty';
2
2
  import pc from 'picocolors';
3
3
  import * as p from '@clack/prompts';
4
4
  import { createRequire } from 'node:module';
5
+ import fs from 'node:fs';
6
+ import path from 'node:path';
5
7
  import { runSetup } from './commands/setup.js';
6
8
  import { runWizard } from './commands/wizard.js';
7
9
  import { runRepair } from './commands/manage.js';
@@ -17,7 +19,7 @@ import { listAccounts, addAccount, removeAccount, refreshAccounts, poolSummary }
17
19
  import { listArtifacts } from './storage/archive.js';
18
20
  import { loadConfig, loadState, saveConfig } from './core/store.js';
19
21
  import { configFile, stateDir } from './core/paths.js';
20
- import { bytesHuman } from './util/misc.js';
22
+ import { bytesHuman, makeProgressRenderer } from './util/misc.js';
21
23
  import { isOnline } from './util/network.js';
22
24
 
23
25
  const require = createRequire(import.meta.url);
@@ -386,7 +388,9 @@ const main = defineCommand({
386
388
  case 'force':
387
389
  case 'backup': {
388
390
  // Runs every ENABLED job right now (default = the weekly snapshot).
389
- const res = await runDueJobs({ force: true, privileged: 'interactive' });
391
+ const progress = makeProgressRenderer();
392
+ const res = await runDueJobs({ force: true, privileged: 'interactive', onProgress: progress });
393
+ progress.stop();
390
394
  const report = res.report || [];
391
395
  if (report.length === 0) console.log(pc.dim('No enabled backup jobs — run `parrot-blackbox` to set up the schedule.'));
392
396
  for (const r of report) {
@@ -408,7 +412,9 @@ const main = defineCommand({
408
412
  const [sub, ...args] = rest;
409
413
  if (sub === 'now' || sub === 'create' || sub === 'force') {
410
414
  try {
411
- const r = await runSnapshotNow();
415
+ const progress = makeProgressRenderer();
416
+ const r = await runSnapshotNow(undefined, undefined, { onProgress: progress });
417
+ progress.stop();
412
418
  console.log(`${pc.green('✔')} Snapshot ${r.snapshot} created & uploaded (${bytesHuman(r.manifest.totalSize)}).`);
413
419
  if (r.pruned?.length) console.log(pc.dim(`Pruned: ${r.pruned.join(', ')}`));
414
420
  } catch (e) {
@@ -667,7 +673,7 @@ const main = defineCommand({
667
673
  });
668
674
  }
669
675
  s.stop('✔ Upload complete');
670
- import('node:fs').then(fs => fs.writeFileSync(outPath, JSON.stringify(manifest)));
676
+ fs.writeFileSync(outPath, JSON.stringify(manifest));
671
677
  process.exitCode = 0;
672
678
  } catch (e) {
673
679
  s.stop('✖ Upload failed');
@@ -677,16 +683,12 @@ const main = defineCommand({
677
683
  // If we are running as root via sudo, rclone might have refreshed OAuth tokens
678
684
  // and rewritten rclone.conf as root:root. Restore ownership to the real user.
679
685
  if (process.getuid && process.getuid() === 0 && process.env.SUDO_UID && process.env.SUDO_GID) {
680
- import('node:fs').then(fs => {
681
- import('node:path').then(path => {
682
- const confPath = path.join(process.env.HOME, '.config', 'rclone', 'rclone.conf');
683
- if (fs.existsSync(confPath)) {
684
- try {
685
- fs.chownSync(confPath, parseInt(process.env.SUDO_UID, 10), parseInt(process.env.SUDO_GID, 10));
686
- } catch { /* best effort */ }
687
- }
688
- });
689
- });
686
+ const confPath = path.join(process.env.HOME, '.config', 'rclone', 'rclone.conf');
687
+ if (fs.existsSync(confPath)) {
688
+ try {
689
+ fs.chownSync(confPath, parseInt(process.env.SUDO_UID, 10), parseInt(process.env.SUDO_GID, 10));
690
+ } catch { /* best effort */ }
691
+ }
690
692
  }
691
693
  }
692
694
  return;
@@ -702,4 +704,11 @@ const main = defineCommand({
702
704
  process.exitCode = 1;
703
705
  }
704
706
  },
705
- });runMain(main);
707
+ });
708
+
709
+ runMain(main).then(() => {
710
+ // Force the process to exit cleanly regardless of any open file descriptors or
711
+ // kernel mounts left by subvolid=5 bind-mounts created in snapshotDirFor.
712
+ // process.on('exit') in snapshot.js will unmount them before we terminate.
713
+ process.exit(process.exitCode ?? 0);
714
+ });
@@ -24,7 +24,7 @@ import { installService, removeService } from './service.js';
24
24
  import { startDaemon, stopDaemon, daemonRunning } from '../daemon/daemon.js';
25
25
  import { runDoctor, runStatus, runUninstallWizard } from './manage.js';
26
26
  import { runSetup } from './setup.js';
27
- import { bytesHuman } from '../util/misc.js';
27
+ import { bytesHuman, makeProgressRenderer } from '../util/misc.js';
28
28
 
29
29
  const require = createRequire(import.meta.url);
30
30
  const pkg = require('../../package.json');
@@ -163,7 +163,9 @@ async function backupNowAction() {
163
163
  /** Create + upload a snapshot immediately. */
164
164
  async function snapshotNowAction() {
165
165
  try {
166
- const r = await runSnapshotNow();
166
+ const progress = makeProgressRenderer();
167
+ const r = await runSnapshotNow(undefined, undefined, { onProgress: progress });
168
+ progress.stop();
167
169
  p.log.success(`✔ Snapshot ${r.snapshot} created & uploaded (${bytesHuman(r.manifest?.totalSize ?? 0)}).`);
168
170
  if (r.pruned?.length) p.log.message(pc.dim(`Pruned: ${r.pruned.join(', ')}`));
169
171
  } catch (e) {
@@ -269,6 +269,12 @@ export async function planAndPlaceStream(btrfsStream, { kind, id, accounts, remo
269
269
  let locs = [];
270
270
  let currentStart = 0;
271
271
  let target = getNextAccountAndPath();
272
+
273
+ // Speed tracking
274
+ let speedBytesWindow = 0;
275
+ let speedWindowStart = Date.now();
276
+ let currentSpeedMBs = 0;
277
+ const SPEED_WINDOW_MS = 1500; // recalculate speed every 1.5 s
272
278
 
273
279
  currentChild = spawn(process.env.PBB_RCLONE || 'rclone', ['rcat', `${target.remote}:${target.path}`]);
274
280
  let childFailed = false;
@@ -287,6 +293,7 @@ export async function planAndPlaceStream(btrfsStream, { kind, id, accounts, remo
287
293
 
288
294
  currentBytesInChunk += toWrite;
289
295
  totalBytes += toWrite;
296
+ speedBytesWindow += toWrite;
290
297
  offset += toWrite;
291
298
 
292
299
  if (!canContinue) {
@@ -309,7 +316,24 @@ export async function planAndPlaceStream(btrfsStream, { kind, id, accounts, remo
309
316
  currentChild.on('exit', (code) => { if (code !== 0) childFailed = true; });
310
317
  }
311
318
  }
312
- if (onProgress) onProgress({ done: totalBytes, total: originalSize, text: `uploading stream: ${(totalBytes / (1024**2)).toFixed(1)} MB` });
319
+
320
+ // Recalculate speed on each chunk and emit progress
321
+ if (onProgress) {
322
+ const now = Date.now();
323
+ const elapsed = now - speedWindowStart;
324
+ if (elapsed >= SPEED_WINDOW_MS) {
325
+ currentSpeedMBs = (speedBytesWindow / (1024 * 1024)) / (elapsed / 1000);
326
+ speedBytesWindow = 0;
327
+ speedWindowStart = now;
328
+ }
329
+ onProgress({
330
+ done: totalBytes,
331
+ total: originalSize,
332
+ speedMBs: currentSpeedMBs,
333
+ remote: target.remote,
334
+ text: `uploading stream: ${(totalBytes / (1024 ** 2)).toFixed(1)} MB`,
335
+ });
336
+ }
313
337
  }
314
338
 
315
339
  if (currentChild) {
package/src/util/misc.js CHANGED
@@ -111,4 +111,54 @@ export function shellQuote(s) {
111
111
 
112
112
  export function pad2(n) {
113
113
  return String(n).padStart(2, '0');
114
+ }
115
+
116
+ /**
117
+ * Returns an onProgress handler that renders a live, in-place progress bar
118
+ * with percentage, MB transferred, and upload speed.
119
+ *
120
+ * Expected event shape:
121
+ * { done: number (bytes), total: number (bytes or 0), speedMBs?: number, remote?: string }
122
+ *
123
+ * Call renderer.stop() when done to advance to the next line.
124
+ *
125
+ * When `total` is 0 (stream size unknown) we display bytes + speed only.
126
+ */
127
+ export function makeProgressRenderer() {
128
+ const isTTY = process.stdout.isTTY;
129
+ const BAR_WIDTH = 25;
130
+ let lastLine = '';
131
+
132
+ function render({ done = 0, total = 0, speedMBs = 0, remote = '' } = {}) {
133
+ const doneMB = done / (1024 * 1024);
134
+ const totalMB = total / (1024 * 1024);
135
+ const speedStr = speedMBs > 0 ? ` ${speedMBs.toFixed(1)} MB/s` : '';
136
+ const destStr = remote ? ` → ${remote}` : '';
137
+
138
+ let line;
139
+ if (total > 0) {
140
+ const pct = Math.min(100, Math.round((done / total) * 100));
141
+ const filled = Math.round((pct / 100) * BAR_WIDTH);
142
+ const bar = '█'.repeat(filled) + '░'.repeat(BAR_WIDTH - filled);
143
+ line = ` [${bar}] ${String(pct).padStart(3)}% ${doneMB.toFixed(1)} MB / ${totalMB.toFixed(1)} MB${speedStr}${destStr}`;
144
+ } else {
145
+ line = ` ⬆ ${doneMB.toFixed(1)} MB streamed${speedStr}${destStr}`;
146
+ }
147
+
148
+ if (isTTY) {
149
+ process.stdout.write(`\r${line}\x1b[K`);
150
+ } else if (line !== lastLine) {
151
+ // Non-TTY (piped / daemon log): only emit when something changes to
152
+ // avoid flooding the journal with thousands of identical lines.
153
+ process.stdout.write(line + '\n');
154
+ }
155
+ lastLine = line;
156
+ }
157
+
158
+ render.stop = function stop() {
159
+ if (isTTY && lastLine) process.stdout.write('\n');
160
+ lastLine = '';
161
+ };
162
+
163
+ return render;
114
164
  }