parrot-blackbox 1.0.16 → 1.1.0

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": "1.0.16",
3
+ "version": "1.1.0",
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",
@@ -11,7 +11,7 @@ import fs from 'node:fs';
11
11
  import path from 'node:path';
12
12
  import { execa, execaSync } from 'execa';
13
13
  import { loadConfig, loadState, saveState, journal, hasCommandSync } from '../core/store.js';
14
- import { timeshiftDir } from '../core/paths.js';
14
+ import { timeshiftDir, stateDir, configFile, manifestsDir } from '../core/paths.js';
15
15
  import { iso, clock } from '../core/time.js';
16
16
  import { refreshAccounts } from '../storage/accounts.js';
17
17
  import { planAndPlace } from '../storage/allocator.js';
@@ -259,26 +259,31 @@ export function cleanupSnapshotMount(snapshot) {
259
259
  export async function runSnapshotBackup(cfg, state, { due, privileged = 'noninteractive', onProgress } = {}) {
260
260
  journal('snapshots', `start due=${due} privileged=${privileged}`);
261
261
  const accounts = await refreshAccounts(cfg);
262
+
263
+ if (accounts.length === 0) {
264
+ throw new Error('no storage accounts configured — add one with `parrot-blackbox account add`');
265
+ }
262
266
 
263
- // Resume logic: if the most recent local snapshot has no local manifest,
264
- // it means its previous upload failed or was interrupted. We should resume it
265
- // to avoid re-uploading everything to a new snapshot ID.
267
+ let created = null;
266
268
  const localSnaps = await listLocalSnapshots({ privileged });
267
- const manifestLocalDir = process.env.PBB_MANIFESTS_DIR || path.join(process.env.PBB_STATE_DIR || '.', 'manifests');
268
269
 
269
- let created = null;
270
- // Look at snapshots from newest to oldest
270
+ // Look at snapshots from newest to oldest; find the most recent one that
271
+ // belongs to parrot-blackbox and has not been fully uploaded yet.
271
272
  for (let i = localSnaps.length - 1; i >= 0; i--) {
272
273
  const s = localSnaps[i];
273
274
  if (s.tags.includes('W') || (s.line && s.line.includes('parrot-blackbox'))) {
274
- const manPath = path.join(manifestLocalDir, `snapshots-${s.name}.json`);
275
- if (!fs.existsSync(manPath)) {
275
+ // Check for the manifest FILE on disk — this is the authoritative source.
276
+ // state.manifests (the in-memory JSON blob) may be empty on the first run
277
+ // or after a reinstall, so we cannot rely on it alone.
278
+ const manifestFile = path.join(manifestsDir(), `snapshots-${s.name}.json`);
279
+ const isUploaded = fs.existsSync(manifestFile);
280
+ if (!isUploaded) {
276
281
  created = s;
277
282
  journal('snapshots', `resuming upload for incomplete snapshot ${s.name}`);
283
+ console.log(`\n⏳ Resuming incomplete upload for snapshot ${s.name} (from tracker)...`);
278
284
  break;
279
285
  } else {
280
- // The most recent parrot-blackbox snapshot is fully uploaded. We can break
281
- // and create a new one.
286
+ // The most recent parrot-blackbox snapshot is fully uploaded create a new one.
282
287
  break;
283
288
  }
284
289
  }
@@ -293,16 +298,21 @@ export async function runSnapshotBackup(cfg, state, { due, privileged = 'noninte
293
298
  let manifest;
294
299
  try {
295
300
  const bin = process.argv[1] || 'parrot-blackbox';
296
- const outPath = path.join(process.env.PBB_STATE_DIR || '/tmp', `manifest-${created.name}.json`);
297
- const baseArgs = [process.execPath, bin, '_internal_upload', dir, 'snapshots', created.name, cfg.storage.remoteRoot, String(cfg.storage.chunkSize), outPath];
298
- const args = process.env.PBB_SUDO_DIRECT === '1' ? baseArgs : ['-E', ...baseArgs];
301
+ const outPath = path.join(stateDir(), `manifest-${created.name}.json`);
302
+ const envVars = {
303
+ HOME: process.env.HOME,
304
+ PBB_STATE_DIR: stateDir(),
305
+ PBB_CONFIG_FILE: configFile()
306
+ };
307
+ const cmdArgs = [process.execPath, bin, '_internal_upload', dir, 'snapshots', created.name, cfg.storage.remoteRoot, String(cfg.storage.chunkSize), outPath];
308
+ const args = process.env.PBB_SUDO_DIRECT === '1' ? cmdArgs : ['-E', ...cmdArgs];
299
309
 
300
310
  let res;
301
311
  if (privileged === 'interactive') {
302
312
  await ensureSudo();
303
- res = await sudoInteractive(args);
313
+ res = await sudoInteractive(args, { env: envVars });
304
314
  } else {
305
- res = await sudoNonInteractive(args);
315
+ res = await sudoNonInteractive(args, { env: envVars });
306
316
  }
307
317
 
308
318
  if (res.exitCode !== 0) {
@@ -319,6 +319,7 @@ export async function runWizard() {
319
319
  message: 'What would you like to do?',
320
320
  options: [
321
321
  { value: 'snapshot', label: '📸 Create snapshot', hint: 'backup your system now' },
322
+ { value: 'resume', label: '⏳ Resume upload', hint: 'resume incomplete backup uploads' },
322
323
  { value: 'backup', label: '💾 Run all backups', hint: 'snapshots + file backups' },
323
324
  { value: 'restore', label: '♻️ Restore backup', hint: 'files or system snapshot' },
324
325
  { value: 'list', label: '📋 List backups', hint: 'see what\'s saved' },
@@ -348,6 +349,7 @@ export async function runWizard() {
348
349
  case 'accounts': await accountsMenu(); break;
349
350
  case 'tools': await runToolsCheck(); break;
350
351
  case 'snapshot': await snapshotNowAction(); break;
352
+ case 'resume': await snapshotNowAction(); break;
351
353
  case 'backup': await backupNowAction(); break;
352
354
  case 'list': await listBackupsAction(); break;
353
355
  case 'restore': await restoreMenu(); break;
@@ -1,8 +1,10 @@
1
1
  /**
2
2
  * Storage accounts — the gitswitch-style account manager for the multi-cloud
3
3
  * pool. Each account maps to ONE rclone remote (one MEGA or Google Drive
4
- * login). 5 MEGA + 5 Drive accounts with ~20GiB / ~15GiB each 175GiB that the
5
- * allocator can spread backups across so we are never out of space.
4
+ * login). You can add any number of MEGA and/or Google Drive accounts e.g.
5
+ * 2, 5, 11, or even 30 of each. MEGA accounts are always filled first; the
6
+ * allocator only spills over to Google Drive once every MEGA account is at
7
+ * capacity.
6
8
  */
7
9
 
8
10
  import crypto from 'node:crypto';
@@ -3,13 +3,16 @@
3
3
  * (many MEGA + Google Drive logins) from ever running out of space.
4
4
  *
5
5
  * Strategy:
6
- * 1. Every file of an artifact is placed WHOLE on a single account when it
7
- * fits. The account is chosen to minimise the resulting used-percentage
8
- * (water-filling), then most free, then oldest spreading wear.
6
+ * 0. MEGA accounts are always filled first. Google Drive accounts are only
7
+ * used once every MEGA account is at capacity for the file being placed.
8
+ * 1. Within each provider tier, every file of an artifact is placed WHOLE on
9
+ * a single account when it fits. The account is chosen to minimise the
10
+ * resulting used-percentage (water-filling), then most free, then oldest —
11
+ * spreading wear evenly across all accounts of the same type.
9
12
  * 2. If a single file is bigger than any one account's free space, it is
10
13
  * split into byte-range chunks distributed across several accounts
11
- * (first-fit over free space). A manifest records the exact ranges so
12
- * restore reassembles the file byte-perfectly.
14
+ * (first-fit over free space, MEGA-first). A manifest records the exact
15
+ * ranges so restore reassembles the file byte-perfectly.
13
16
  * The manifest is written to the cloud (`<root>/<kind>/<id>/__MANIFEST__.json`)
14
17
  * AND mirrored locally, so restore works even from a wiped machine.
15
18
  */
@@ -52,15 +55,35 @@ export function walkFiles(localDir) {
52
55
  return out;
53
56
  }
54
57
 
55
- /** Choose the account that minimises its resulting used-percentage. */
58
+ /**
59
+ * Choose the best account to store `needed` bytes.
60
+ *
61
+ * Priority:
62
+ * 1. MEGA accounts are always preferred over Google Drive.
63
+ * Only when ALL MEGA accounts are full (or have insufficient free space)
64
+ * will Google Drive accounts be used.
65
+ * 2. Within each provider tier the classic water-filling strategy applies:
66
+ * pick the account that results in the lowest used-percentage after
67
+ * placing the file (most headroom wins on ties).
68
+ */
56
69
  export function chooseAccount(needed, accounts) {
70
+ // Tier 0 = mega, Tier 1 = gdrive / everything else.
71
+ const providerTier = (acc) => (acc.provider === 'mega' ? 0 : 1);
72
+
73
+ // Find the lowest tier that has at least one account with enough free space.
74
+ const eligible = accounts.filter((a) => a.free >= needed);
75
+ if (eligible.length === 0) return null;
76
+
77
+ const bestTier = Math.min(...eligible.map(providerTier));
78
+ const candidates = eligible.filter((a) => providerTier(a) === bestTier);
79
+
80
+ // Water-fill within the chosen tier: minimise resulting used-percentage.
57
81
  let best = null;
58
82
  let bestScore = Infinity;
59
- for (const acc of accounts) {
60
- if (acc.free < needed) continue;
83
+ for (const acc of candidates) {
61
84
  const quota = acc.total || 0;
62
85
  const score = quota ? (acc.used + needed) / quota : needed;
63
- if (score < bestScore || (score === bestScore && (acc.free > (best?.free ?? -1)))) {
86
+ if (score < bestScore || (score === bestScore && acc.free > (best?.free ?? -1))) {
64
87
  bestScore = score;
65
88
  best = acc;
66
89
  }
package/src/util/sudo.js CHANGED
@@ -78,16 +78,16 @@ export function isSudoArmed() {
78
78
  * For daemon/non-interactive use, pass { interactive: false } — it will use
79
79
  * `sudo -n` and never hang on a prompt.
80
80
  */
81
- export async function sudoExec(args, { interactive = true, capture = false, timeout = 0 } = {}) {
81
+ export async function sudoExec(args, { interactive = true, capture = false, timeout = 0, env = {} } = {}) {
82
82
  if (interactive && process.stdin.isTTY) {
83
83
  // Ensure the sudo timestamp is armed before running the command.
84
84
  await ensureSudo();
85
85
  if (capture) {
86
- return sudoInteractiveCapture(args, { timeout });
86
+ return sudoInteractiveCapture(args, { timeout, env });
87
87
  }
88
- return sudoInteractive(args, { timeout });
88
+ return sudoInteractive(args, { timeout, env });
89
89
  }
90
- return sudoNonInteractive(args);
90
+ return sudoNonInteractive(args, { env });
91
91
  }
92
92
 
93
93
  /**
@@ -95,26 +95,26 @@ export async function sudoExec(args, { interactive = true, capture = false, time
95
95
  * Requires that ensureSudo() was called earlier in the session.
96
96
  * Falls back to non-interactive sudo; if that fails, falls back to direct execution.
97
97
  */
98
- export function sudoExecSync(args, { reject = false } = {}) {
98
+ export function sudoExecSync(args, { reject = false, env = {} } = {}) {
99
99
  if (process.env.PBB_SUDO_DIRECT === '1') {
100
- return execaSync(args[0], args.slice(1), { reject });
100
+ return execaSync(args[0], args.slice(1), { reject, env: { ...process.env, ...env } });
101
101
  }
102
102
  // Try with sudo (timestamp should be armed from earlier ensureSudo call)
103
103
  const full = ['sudo', '-n', ...args];
104
- const res = execaSync(full[0], full.slice(1), { reject: false });
104
+ const res = execaSync(full[0], full.slice(1), { reject: false, env: { ...process.env, ...env } });
105
105
  if (res.exitCode === 0) return res;
106
106
  // If sudo -n fails, try sudo with inherited stdio (will prompt if TTY available)
107
107
  if (process.stdin?.isTTY) {
108
- return execaSync('sudo', args, { reject, stdio: 'inherit' });
108
+ return execaSync('sudo', args, { reject, stdio: 'inherit', env: { ...process.env, ...env } });
109
109
  }
110
110
  // Last resort: try without sudo (some operations work unprivileged)
111
- return execaSync(args[0], args.slice(1), { reject: false });
111
+ return execaSync(args[0], args.slice(1), { reject: false, env: { ...process.env, ...env } });
112
112
  }
113
113
 
114
114
  /** Interactive sudo baseline for injecting PBB_SUDO_DIRECT consistent with the rest. */
115
- export async function sudoInteractive(args, { timeout = 0 } = {}) {
115
+ export async function sudoInteractive(args, { timeout = 0, env = {} } = {}) {
116
116
  const full = [...sudoPrefix(), ...args];
117
- const res = await execa(full[0], full.slice(1), { stdio: 'inherit', reject: false, timeout });
117
+ const res = await execa(full[0], full.slice(1), { stdio: 'inherit', reject: false, timeout, env: { ...process.env, ...env } });
118
118
  if (res.exitCode !== 0) {
119
119
  throw new Error(`elevated command failed: ${args.join(' ')} (exit ${res.exitCode})`);
120
120
  }
@@ -126,9 +126,9 @@ export async function sudoInteractive(args, { timeout = 0 } = {}) {
126
126
  * the parser) while keeping stdin inherited so the password prompt stays
127
127
  * usable — like the real sudo, which reads passwords from /dev/tty.
128
128
  */
129
- export async function sudoInteractiveCapture(args, { timeout = 0 } = {}) {
129
+ export async function sudoInteractiveCapture(args, { timeout = 0, env = {} } = {}) {
130
130
  const full = [...sudoPrefix(), ...args];
131
- const res = await execa(full[0], full.slice(1), { stdin: 'inherit', reject: false, timeout });
131
+ const res = await execa(full[0], full.slice(1), { stdin: 'inherit', reject: false, timeout, env: { ...process.env, ...env } });
132
132
  if (res.exitCode !== 0) {
133
133
  throw new Error(`elevated command failed: ${args.join(' ')} (exit ${res.exitCode})`);
134
134
  }
@@ -136,9 +136,9 @@ export async function sudoInteractiveCapture(args, { timeout = 0 } = {}) {
136
136
  }
137
137
 
138
138
  /** Non-interactive sudo (daemon-safe). Returns execa result, never hangs. */
139
- export async function sudoNonInteractive(args) {
139
+ export async function sudoNonInteractive(args, { env = {} } = {}) {
140
140
  const full = process.env.PBB_SUDO_DIRECT === '1' ? args : ['sudo', '-n', ...args];
141
- const res = await execa(full[0], full.slice(1), { reject: false });
141
+ const res = await execa(full[0], full.slice(1), { reject: false, env: { ...process.env, ...env } });
142
142
  return res;
143
143
  }
144
144