parrot-blackbox 2.0.7 → 2.0.9

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.7",
3
+ "version": "2.0.9",
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",
@@ -10,6 +10,7 @@
10
10
  import fs from 'node:fs';
11
11
  import path from 'node:path';
12
12
  import { execa, execaSync } from 'execa';
13
+ import pc from 'picocolors';
13
14
  import { loadConfig, loadState, saveState, journal, hasCommandSync } from '../core/store.js';
14
15
  import { timeshiftDir, stateDir, configFile, manifestsDir } from '../core/paths.js';
15
16
  import { iso, clock } from '../core/time.js';
@@ -589,6 +590,15 @@ async function uploadViaBtrfsSend({ snapshot, subvolPath, parentSnapshot, parent
589
590
  const estimatedSize = await estimateSendSize(subvolPath, { parent: parentSubvolPath });
590
591
  const btrfsCfg = cfg.jobs.snapshots.btrfs || {};
591
592
 
593
+ // A missing parent means this is the unavoidable FULL baseline: `btrfs send -p`
594
+ // needs the parent subvolume to exist on disk, so there is no smaller option.
595
+ // Say so loudly BEFORE the upload starts — the wizard also confirmation-gates it.
596
+ if (!parentSubvolPath) {
597
+ console.log(pc.yellow('⚠ No parent snapshot available — this is a FULL baseline upload of the entire system subvolume.'));
598
+ console.log(pc.yellow(` Estimated ${(estimatedSize / (1024 ** 3)).toFixed(1)} GiB raw — on a typical uplink this can take hours.`));
599
+ console.log(pc.yellow(' Once a snapshot is fully uploaded and kept, subsequent backups become small increments.'));
600
+ }
601
+
592
602
  console.log(`\n📤 Uploading ${parentSubvolPath ? 'incremental' : 'full'} BTRFS stream...`);
593
603
  console.log(` Estimated size: ${(estimatedSize / (1024 ** 3)).toFixed(2)} GiB`);
594
604
  if (btrfsCfg.compression !== false) console.log(` Compression: zstd enabled`);
@@ -834,6 +844,38 @@ export async function runSnapshotNow(cfg = loadConfig(), state = loadState(), op
834
844
  return runSnapshotBackup(cfg, state, { due, privileged: 'interactive', ...opts });
835
845
  }
836
846
 
847
+ /**
848
+ * Decide whether the NEXT snapshot upload would be a small incremental (a
849
+ * fully-uploaded parent snapshot still exists on disk) or a FULL baseline send.
850
+ *
851
+ * Lightweight probe — it only lists local snapshots and reads local manifests;
852
+ * it never creates anything. The wizard uses this to warn and confirm BEFORE
853
+ * a multi-hour full baseline upload is kicked off.
854
+ *
855
+ * @param {object} opts
856
+ * @param {object} [opts.cfg] config object (defaults to on-disk config)
857
+ * @param {'interactive'|'noninteractive'} [opts.privileged]
858
+ * @param {Array|null} [opts.localSnaps] injectable snapshot list (tests)
859
+ * @param {string|null} [opts.manifestsDirOverride] injectable manifests dir (tests)
860
+ * @returns {Promise<{full: boolean, parent: string|null, reason: string|null}>}
861
+ */
862
+ export async function nextSnapshotUploadMode({ cfg = loadConfig(), privileged = 'noninteractive', localSnaps = null, manifestsDirOverride = null } = {}) {
863
+ const btrfsCfg = cfg?.jobs?.snapshots?.btrfs || {};
864
+ if (btrfsCfg.enabled === false || process.env.PBB_DISABLE_BTRFS) {
865
+ return { full: false, parent: null, reason: 'BTRFS streaming disabled — file-copy mode in use' };
866
+ }
867
+ if (btrfsCfg.incremental === false) {
868
+ return { full: false, parent: null, reason: 'incremental uploads disabled in config (full sends are intentional)' };
869
+ }
870
+
871
+ const snaps = localSnaps ?? (await listLocalSnapshots({ privileged }).catch(() => []));
872
+ const { findLastUploadedSnapshot } = await import('./btrfs-send.js');
873
+ const parent = findLastUploadedSnapshot(manifestsDirOverride || manifestsDir(), snaps);
874
+ return parent
875
+ ? { full: false, parent, reason: null }
876
+ : { full: true, parent: null, reason: 'no fully-uploaded parent snapshot on disk' };
877
+ }
878
+
837
879
  export function timeshiftAvailable() {
838
880
  return hasCommandSync('timeshift');
839
881
  }
@@ -231,6 +231,7 @@ export async function runRepair({ auto = false } = {}) {
231
231
  }
232
232
 
233
233
  // 5. Optional npm reinstall (repair a broken CLI install)
234
+ let updated = false;
234
235
  if (!auto) {
235
236
  const want = await p.confirm({
236
237
  message: 'Reinstall parrot-blackbox from npm to repair the executable?',
@@ -239,10 +240,11 @@ export async function runRepair({ auto = false } = {}) {
239
240
  if (!p.isCancel(want) && want) {
240
241
  const { runSelfUpdate } = await import('../lib/self.js');
241
242
  p.log.step('Reinstalling from npm…');
242
- const did = await runSelfUpdate({ force: true });
243
- if (did) fixed.push('npm');
243
+ updated = await runSelfUpdate({ force: true });
244
+ if (updated) fixed.push('npm');
244
245
  }
245
246
  }
246
247
 
247
248
  p.outro(pc.green(fixed.length ? `Repair complete — fixed: ${fixed.join(', ')}.` : 'Nothing to repair — everything looks healthy.'));
249
+ return { updated };
248
250
  }
@@ -17,18 +17,22 @@ import { guidedRemoteAdd } from './remote.js';
17
17
  import { listAccounts, refreshAccounts, poolSummary, addAccount, removeAccount } from '../storage/accounts.js';
18
18
  import { loadConfig, saveConfig } from '../core/store.js';
19
19
  import { runDueJobs } from '../daemon/scheduler.js';
20
- import { runSnapshotNow, listLocalSnapshots, deleteSnapshot, deleteAllSnapshots } from '../backup/snapshot.js';
20
+ import { runSnapshotNow, listLocalSnapshots, deleteSnapshot, deleteAllSnapshots, nextSnapshotUploadMode } from '../backup/snapshot.js';
21
21
  import { listArtifacts } from '../storage/archive.js';
22
22
  import { restoreFiles, restoreSnapshot } from '../backup/restore.js';
23
23
  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, makeProgressRenderer } from '../util/misc.js';
27
+ import { bytesHuman, makeClackProgressRenderer } from '../util/misc.js';
28
28
 
29
29
  const require = createRequire(import.meta.url);
30
30
  const pkg = require('../../package.json');
31
31
 
32
+ /** Set once a self-update happened inside THIS process — the wizard then
33
+ * warns that it is still running the OLD code until restarted. */
34
+ let updatedInSession = false;
35
+
32
36
  async function importSelf() {
33
37
  return import('../lib/self.js');
34
38
  }
@@ -38,7 +42,7 @@ async function autoUpdateCheck() {
38
42
  const { checkForUpdate, promptSelfUpdate } = await importSelf();
39
43
  try {
40
44
  const { outdated } = await checkForUpdate();
41
- if (outdated) await promptSelfUpdate();
45
+ if (outdated && await promptSelfUpdate()) updatedInSession = true;
42
46
  } catch {
43
47
  /* offline / npm missing — never block the wizard on the update check */
44
48
  }
@@ -139,10 +143,23 @@ async function accountsMenu() {
139
143
 
140
144
  /** Run every enabled backup right now. */
141
145
  async function backupNowAction() {
142
- const s = p.spinner();
143
- s.start('Running backup…');
144
- s.stop('');
145
- const res = await runDueJobs({ force: true, privileged: 'interactive' });
146
+ p.log.message(pc.dim('Running backup…'));
147
+ const cfg = loadConfig();
148
+ // Warn BEFORE a FULL baseline snapshot upload (10s of GiB, hours long) is
149
+ // accidentally started from "Run all backups".
150
+ if (cfg.jobs?.snapshots?.enabled !== false) {
151
+ const mode = await nextSnapshotUploadMode({ cfg, privileged: 'interactive' });
152
+ if (mode.full) {
153
+ const ok = await p.confirm({
154
+ message: pc.red('⚠ No previous snapshot found — the snapshot backup will be a FULL baseline upload of the ENTIRE system (10s of GiB, can take hours). Continue?'),
155
+ initialValue: false,
156
+ });
157
+ if (p.isCancel(ok) || !ok) { p.log.message(pc.dim('Aborted — nothing was uploaded.')); return; }
158
+ }
159
+ }
160
+ const progress = makeClackProgressRenderer(p);
161
+ const res = await runDueJobs({ force: true, privileged: 'interactive', onProgress: progress });
162
+ progress.stop();
146
163
  const report = res.report || [];
147
164
  if (report.length === 0) { p.log.message(pc.dim('No enabled backup jobs.')); return; }
148
165
  for (const r of report) {
@@ -163,7 +180,20 @@ async function backupNowAction() {
163
180
  /** Create + upload a snapshot immediately. */
164
181
  async function snapshotNowAction() {
165
182
  try {
166
- const progress = makeProgressRenderer();
183
+ // A full baseline sends the ENTIRE system subvolume (10s of GiB, hours).
184
+ // Confirm BEFORE creating the snapshot so the user can back out cheaply.
185
+ const mode = await nextSnapshotUploadMode({ cfg: loadConfig(), privileged: 'interactive' });
186
+ if (mode.full) {
187
+ const ok = await p.confirm({
188
+ message: pc.red('⚠ No previous snapshot found — this will be a FULL baseline upload of the ENTIRE system (10s of GiB, can take hours). Continue?'),
189
+ initialValue: false,
190
+ });
191
+ if (p.isCancel(ok) || !ok) { p.log.message(pc.dim('Aborted — nothing was uploaded.')); return; }
192
+ } else if (mode.parent) {
193
+ p.log.message(pc.dim(`Incremental upload (parent: ${mode.parent}).`));
194
+ }
195
+
196
+ const progress = makeClackProgressRenderer(p);
167
197
  const r = await runSnapshotNow(undefined, undefined, { onProgress: progress });
168
198
  progress.stop();
169
199
  p.log.success(`✔ Snapshot ${r.snapshot} created & uploaded (${bytesHuman(r.manifest?.totalSize ?? 0)}).`);
@@ -235,7 +265,8 @@ async function deleteSnapshotsMenu() {
235
265
  // ── Delete ALL ─────────────────────────────────────────────────────────────
236
266
  if (pick === '__all') {
237
267
  const confirm = await p.confirm({
238
- message: pc.red(`Delete ALL ${snapshots.length} local snapshot(s)? This cannot be undone.`),
268
+ message: pc.red(`Delete ALL ${snapshots.length} local snapshot(s)? This cannot be undone.`) +
269
+ pc.yellow(' No parent snapshot will remain — the NEXT backup becomes a FULL baseline upload of the entire system (10s of GiB, can take hours).'),
239
270
  initialValue: false,
240
271
  });
241
272
  if (p.isCancel(confirm) || !confirm) {
@@ -265,7 +296,8 @@ async function deleteSnapshotsMenu() {
265
296
 
266
297
  // ── Delete ONE ─────────────────────────────────────────────────────────────
267
298
  const confirm = await p.confirm({
268
- message: pc.yellow(`Delete snapshot ${pc.bold(pick)}?`),
299
+ message: pc.yellow(`Delete snapshot ${pc.bold(pick)}?`) +
300
+ (snapshots.length === 1 ? pc.yellow(' This is the last snapshot — the next backup will be a FULL baseline upload.') : ''),
269
301
  initialValue: false,
270
302
  });
271
303
  if (p.isCancel(confirm) || !confirm) {
@@ -401,6 +433,14 @@ export async function runWizard() {
401
433
  await autoUpdateCheck();
402
434
 
403
435
  for (;;) {
436
+ // After an in-session self-update THIS process is still running the loaded
437
+ // (old) code — that's exactly the trap that makes uploads look silent in a
438
+ // stale session. Remind once per update so the user restarts.
439
+ if (updatedInSession) {
440
+ p.log.warn(`⚠ Updated earlier this session — this process still runs the OLD v${pkg.version} code. Exit and re-run \`parrot-blackbox\` to use the new version.`);
441
+ updatedInSession = false;
442
+ }
443
+
404
444
  const action = await p.select({
405
445
  message: 'What would you like to do?',
406
446
  options: [
@@ -446,8 +486,8 @@ export async function runWizard() {
446
486
  case 'setup': await runSetup(); break;
447
487
  case 'status': await runStatus(); break;
448
488
  case 'doctor': await runDoctor(); break;
449
- case 'repair': { const { runRepair } = await import('./manage.js'); await runRepair(); break; }
450
- case 'update': { const { runSelfUpdate } = await import('../lib/self.js'); await runSelfUpdate(); break; }
489
+ case 'repair': { const { runRepair } = await import('./manage.js'); const res = await runRepair(); if (res?.updated) updatedInSession = true; break; }
490
+ case 'update': { const { runSelfUpdate } = await import('../lib/self.js'); if (await runSelfUpdate()) updatedInSession = true; break; }
451
491
  case 'uninstall': await runUninstallWizard(); p.outro('parrot-blackbox removed — cloud backups are safe.'); return;
452
492
  default: break;
453
493
  }
package/src/util/misc.js CHANGED
@@ -129,22 +129,23 @@ export function makeProgressRenderer() {
129
129
  const BAR_WIDTH = 25;
130
130
  let lastLine = '';
131
131
 
132
- function render({ done = 0, total = 0, speedMBs = 0, remote = '' } = {}) {
133
- const doneMB = done / (1024 * 1024);
134
- const totalMB = total / (1024 * 1024);
132
+ function buildLine({ done = 0, total = 0, speedMBs = 0, remote = '' } = {}) {
133
+ const doneMB = done / (1024 * 1024);
134
+ const totalMB = total / (1024 * 1024);
135
135
  const speedStr = speedMBs > 0 ? ` ${speedMBs.toFixed(1)} MB/s` : '';
136
136
  const destStr = remote ? ` → ${remote}` : '';
137
137
 
138
- let line;
139
138
  if (total > 0) {
140
139
  const pct = Math.min(100, Math.round((done / total) * 100));
141
140
  const filled = Math.round((pct / 100) * BAR_WIDTH);
142
141
  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}`;
142
+ return ` [${bar}] ${String(pct).padStart(3)}% ${doneMB.toFixed(1)} MB / ${totalMB.toFixed(1)} MB${speedStr}${destStr}`;
146
143
  }
144
+ return ` ⬆ ${doneMB.toFixed(1)} MB streamed${speedStr}${destStr}`;
145
+ }
147
146
 
147
+ function render(evt = {}) {
148
+ const line = buildLine(evt);
148
149
  if (isTTY) {
149
150
  process.stdout.write(`\r${line}\x1b[K`);
150
151
  } else if (line !== lastLine) {
@@ -160,5 +161,61 @@ export function makeProgressRenderer() {
160
161
  lastLine = '';
161
162
  };
162
163
 
164
+ return render;
165
+ }
166
+
167
+ /**
168
+ * A @clack/prompts-aware progress renderer.
169
+ *
170
+ * Inside the clack wizard the terminal is managed by clack's ANSI cursor
171
+ * tracking — raw `\r` writes get clobbered. This variant throttles output
172
+ * to at most one `p.log.message()` call per second so clack can handle
173
+ * rendering, and the bar stays readable.
174
+ *
175
+ * Usage:
176
+ * import * as p from '@clack/prompts';
177
+ * const progress = makeClackProgressRenderer(p);
178
+ * await runSnapshotNow(undefined, undefined, { onProgress: progress });
179
+ * progress.stop();
180
+ *
181
+ * Expected event shape: same as makeProgressRenderer().
182
+ */
183
+ export function makeClackProgressRenderer(p) {
184
+ const BAR_WIDTH = 20;
185
+ const THROTTLE_MS = 800; // max one clack log line per 800 ms
186
+ let lastEmitAt = 0;
187
+ let lastLine = '';
188
+
189
+ function buildLine({ done = 0, total = 0, speedMBs = 0, remote = '' } = {}) {
190
+ const doneMB = done / (1024 * 1024);
191
+ const totalMB = total / (1024 * 1024);
192
+ const speedStr = speedMBs > 0 ? ` ${speedMBs.toFixed(1)} MB/s` : '';
193
+ const destStr = remote ? ` → ${remote}` : '';
194
+
195
+ if (total > 0) {
196
+ const pct = Math.min(100, Math.round((done / total) * 100));
197
+ const filled = Math.round((pct / 100) * BAR_WIDTH);
198
+ const bar = '█'.repeat(filled) + '░'.repeat(BAR_WIDTH - filled);
199
+ return `[${bar}] ${String(pct).padStart(3)}% ${doneMB.toFixed(1)} / ${totalMB.toFixed(1)} MB${speedStr}${destStr}`;
200
+ }
201
+ return `⬆ ${doneMB.toFixed(1)} MB streamed${speedStr}${destStr}`;
202
+ }
203
+
204
+ function render(evt = {}) {
205
+ const now = Date.now();
206
+ const line = buildLine(evt);
207
+ if (line === lastLine) return; // nothing changed
208
+ if (now - lastEmitAt < THROTTLE_MS) return; // too soon
209
+ lastEmitAt = now;
210
+ lastLine = line;
211
+ p.log.message(line);
212
+ }
213
+
214
+ render.stop = function stop() {
215
+ // emit the final state unconditionally so the user sees 100% or final MB
216
+ if (lastLine) p.log.message(lastLine);
217
+ lastLine = '';
218
+ };
219
+
163
220
  return render;
164
221
  }