parrot-blackbox 1.0.14 → 1.0.16

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,7 +1,7 @@
1
1
  {
2
2
  "name": "parrot-blackbox",
3
- "version": "1.0.14",
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.",
3
+ "version": "1.0.16",
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",
7
7
  "bin": {
@@ -19,7 +19,7 @@ import { stateDir, timeshiftDir } from '../core/paths.js';
19
19
  import { refreshAccounts } from '../storage/accounts.js';
20
20
  import { discoverManifest, restoreArtifact } from '../storage/archive.js';
21
21
  import { listLocalSnapshots } from './snapshot.js';
22
- import { sudoInteractive } from '../util/sudo.js';
22
+ import { ensureSudo, sudoInteractive } from '../util/sudo.js';
23
23
  import { bytesHuman } from '../util/misc.js';
24
24
 
25
25
  /** Restore a file backup generation into a writable local directory. */
@@ -67,6 +67,7 @@ export async function restoreSnapshot({ id, accounts, cfg, toDir, confirm = fals
67
67
 
68
68
  // Run the actual restore (interactive sudo → the password prompt is visible).
69
69
  console.log(`\nRestoring snapshot ${id} over the current system…\n`);
70
+ await ensureSudo();
70
71
  const res = await sudoInteractive(['timeshift', '--restore', '--snapshot', id, '--yes']);
71
72
  if (res.exitCode !== 0) throw new Error(`timeshift --restore failed (exit ${res.exitCode})`);
72
73
  journal('restore', `snapshot id=${id} RESTORED`);
@@ -79,6 +80,7 @@ async function placeIntoTimeshift(id, tmpRoot, cfg, privileged) {
79
80
  const base = determineSnapshotBase();
80
81
  const target = path.join(base, id);
81
82
  const cmd = `mkdir -p ${shq(base)} && rm -rf ${shq(target)} && cp -a ${shq(tmpRoot)}/. ${shq(target)}/ && chown -R root:root ${shq(target)}`;
83
+ await ensureSudo();
82
84
  const res = await sudoInteractive(['bash', '-c', cmd]);
83
85
  if (res.exitCode !== 0) throw new Error(`could not move snapshot into ${base} (exit ${res.exitCode})`);
84
86
  return target;
@@ -17,7 +17,7 @@ import { refreshAccounts } from '../storage/accounts.js';
17
17
  import { planAndPlace } from '../storage/allocator.js';
18
18
  import { listArtifacts, removeArtifact } from '../storage/archive.js';
19
19
  import { planPrune } from './retention.js';
20
- import { sudoInteractive, sudoNonInteractive, sudoInteractiveCapture } from '../util/sudo.js';
20
+ import { sudoInteractive, sudoNonInteractive, sudoInteractiveCapture, sudoExecSync, ensureSudo } from '../util/sudo.js';
21
21
 
22
22
  export class SudoDeferredError extends Error {
23
23
  constructor() {
@@ -96,6 +96,7 @@ function findPathInLine(line) {
96
96
  async function runTimeshiftList({ privileged = 'noninteractive' } = {}) {
97
97
  if (privileged === 'interactive') {
98
98
  try {
99
+ await ensureSudo();
99
100
  const res = await sudoInteractiveCapture(['timeshift', '--list']);
100
101
  return (res.exitCode === 0 ? res.stdout : res.stdout || res.stderr) || '';
101
102
  } catch {
@@ -124,6 +125,7 @@ export async function createSnapshot({ comment, privileged = 'noninteractive' }
124
125
 
125
126
  let createOut = '';
126
127
  if (privileged === 'interactive') {
128
+ await ensureSudo();
127
129
  const res = await sudoInteractiveCapture(args);
128
130
  if (res.exitCode !== 0) throw new Error(`timeshift --create failed (exit ${res.exitCode})`);
129
131
  createOut = `${res.stdout || ''} ${res.stderr || ''}`;
@@ -154,6 +156,7 @@ export async function createSnapshot({ comment, privileged = 'noninteractive' }
154
156
  export async function deleteSnapshot(name, { privileged = 'noninteractive' } = {}) {
155
157
  const args = ['timeshift', '--delete', '--snapshot', name];
156
158
  if (privileged === 'interactive') {
159
+ await ensureSudo();
157
160
  const res = await sudoInteractive(args);
158
161
  if (res.exitCode !== 0) throw new Error(`timeshift --delete ${name} failed (exit ${res.exitCode})`);
159
162
  return true;
@@ -201,15 +204,10 @@ export function snapshotDirFor(snapshot, { privileged = 'noninteractive' } = {})
201
204
 
202
205
  if (device && deviceResult.exitCode === 0) {
203
206
  // Create temporary mount point
204
- const mkdirCmd = privileged === 'interactive' ? ['sudo', 'mkdir', '-p', mountPoint] : ['sudo', '-n', 'mkdir', '-p', mountPoint];
205
- execaSync(mkdirCmd[0], mkdirCmd.slice(1), { reject: false });
207
+ sudoExecSync(['mkdir', '-p', mountPoint]);
206
208
 
207
209
  // Mount BTRFS root subvolume (subvolid=5 contains all subvolumes including snapshots)
208
- const mountCmd = privileged === 'interactive'
209
- ? ['sudo', 'mount', '-o', 'subvolid=5', device, mountPoint]
210
- : ['sudo', '-n', 'mount', '-o', 'subvolid=5', device, mountPoint];
211
-
212
- const mountResult = execaSync(mountCmd[0], mountCmd.slice(1), { reject: false, timeout: 5000 });
210
+ const mountResult = sudoExecSync(['mount', '-o', 'subvolid=5', device, mountPoint]);
213
211
 
214
212
  if (mountResult.exitCode === 0) {
215
213
  // Search for the snapshot in the mounted root subvolume
@@ -228,9 +226,8 @@ export function snapshotDirFor(snapshot, { privileged = 'noninteractive' } = {})
228
226
  }
229
227
 
230
228
  // Unmount if we didn't find anything
231
- const umountCmd = privileged === 'interactive' ? ['sudo', 'umount', mountPoint] : ['sudo', '-n', 'umount', mountPoint];
232
- execaSync(umountCmd[0], umountCmd.slice(1), { reject: false });
233
- execaSync('sudo', ['-n', 'rmdir', mountPoint], { reject: false });
229
+ sudoExecSync(['umount', mountPoint]);
230
+ sudoExecSync(['rmdir', mountPoint]);
234
231
  }
235
232
  }
236
233
  } catch {
@@ -245,8 +242,8 @@ export function snapshotDirFor(snapshot, { privileged = 'noninteractive' } = {})
245
242
  export function cleanupSnapshotMount(snapshot) {
246
243
  if (snapshot._tempMount) {
247
244
  try {
248
- execaSync('sudo', ['-n', 'umount', snapshot._tempMount], { reject: false });
249
- execaSync('sudo', ['-n', 'rmdir', snapshot._tempMount], { reject: false });
245
+ sudoExecSync(['umount', snapshot._tempMount]);
246
+ sudoExecSync(['rmdir', snapshot._tempMount]);
250
247
  delete snapshot._tempMount;
251
248
  } catch {
252
249
  /* best effort */
@@ -302,6 +299,7 @@ export async function runSnapshotBackup(cfg, state, { due, privileged = 'noninte
302
299
 
303
300
  let res;
304
301
  if (privileged === 'interactive') {
302
+ await ensureSudo();
305
303
  res = await sudoInteractive(args);
306
304
  } else {
307
305
  res = await sudoNonInteractive(args);
package/src/cli.js CHANGED
@@ -97,7 +97,7 @@ async function snapshotList() {
97
97
  const accs = listAccounts();
98
98
  console.log(`${pc.bold('\nLocal snapshots (Timeshift):')}`);
99
99
  try {
100
- const local = await listLocalSnapshots({ privileged: 'noninteractive' });
100
+ const local = await listLocalSnapshots({ privileged: process.stdin.isTTY ? 'interactive' : 'noninteractive' });
101
101
  if (local.length === 0) console.log(` ${pc.dim('none')}`);
102
102
  for (const s of local) console.log(` - ${pc.cyan(s.name)} ${pc.dim(s.tags)}`);
103
103
  } catch (e) {
@@ -379,7 +379,7 @@ const main = defineCommand({
379
379
  return runSelfUpdate({ force: process.argv.includes('--force') });
380
380
 
381
381
  case 'run':
382
- process.exitCode = await invokeRun('noninteractive');
382
+ process.exitCode = await invokeRun(process.stdin.isTTY ? 'interactive' : 'noninteractive');
383
383
  return;
384
384
 
385
385
  case 'force':
@@ -538,6 +538,21 @@ const main = defineCommand({
538
538
  s.stop('✖ Upload failed');
539
539
  console.error(`_internal_upload failed: ${e.message}`);
540
540
  process.exitCode = 1;
541
+ } finally {
542
+ // If we are running as root via sudo, rclone might have refreshed OAuth tokens
543
+ // and rewritten rclone.conf as root:root. Restore ownership to the real user.
544
+ if (process.getuid && process.getuid() === 0 && process.env.SUDO_UID && process.env.SUDO_GID) {
545
+ import('node:fs').then(fs => {
546
+ import('node:path').then(path => {
547
+ const confPath = path.join(process.env.HOME, '.config', 'rclone', 'rclone.conf');
548
+ if (fs.existsSync(confPath)) {
549
+ try {
550
+ fs.chownSync(confPath, parseInt(process.env.SUDO_UID, 10), parseInt(process.env.SUDO_GID, 10));
551
+ } catch { /* best effort */ }
552
+ }
553
+ });
554
+ });
555
+ }
541
556
  }
542
557
  return;
543
558
  }
@@ -9,6 +9,7 @@ import * as p from '@clack/prompts';
9
9
  import pc from 'picocolors';
10
10
  import { execa } from 'execa';
11
11
  import { loadConfig, journal, hasCommandSync } from '../core/store.js';
12
+ import { ensureSudo, sudoInteractive } from '../util/sudo.js';
12
13
  import { listAccounts, refreshAccounts, poolSummary } from '../storage/accounts.js';
13
14
  import { installService } from './service.js';
14
15
  import { runDueJobs } from '../daemon/scheduler.js';
@@ -58,7 +59,8 @@ async function ensureSystemTools() {
58
59
  s.stop('');
59
60
  try {
60
61
  const args = pm === 'pacman' ? ['-S', '--noconfirm', tool.pkg] : [pm, 'install', '-y', tool.pkg];
61
- const res = await execa('sudo', args, { stdio: 'inherit', reject: false });
62
+ await ensureSudo();
63
+ const res = await sudoInteractive([pm, ...args.slice(1)]);
62
64
  if (res.exitCode === 0 && hasCommandSync(tool.bin)) {
63
65
  p.log.success(`${pc.cyan(tool.bin)} installed.`);
64
66
  installed.push(tool.bin);
@@ -8,6 +8,7 @@ import * as p from '@clack/prompts';
8
8
  import pc from 'picocolors';
9
9
  import { execa } from 'execa';
10
10
  import { hasCommandSync } from '../core/store.js';
11
+ import { ensureSudo, sudoInteractive } from '../util/sudo.js';
11
12
 
12
13
  export const REQUIRED = [
13
14
  { bin: 'rclone', pkg: 'rclone', why: 'talks to MEGA / Google Drive (cloud storage)' },
@@ -59,7 +60,8 @@ export async function ensureSystemTools() {
59
60
  s.stop(''); // release the terminal first so the sudo password prompt is usable
60
61
  try {
61
62
  const args = pm === 'pacman' ? ['-S', '--noconfirm', tool.pkg] : [pm, 'install', '-y', tool.pkg];
62
- const res = await execa('sudo', args, { stdio: 'inherit', reject: false });
63
+ await ensureSudo();
64
+ const res = await sudoInteractive([pm, ...args.slice(1)]);
63
65
  if (res.exitCode === 0 && hasCommandSync(tool.bin)) {
64
66
  p.log.success(`${pc.cyan(tool.bin)} installed.`);
65
67
  installed.push(tool.bin);
package/src/lib/self.js CHANGED
@@ -8,6 +8,7 @@
8
8
  import { execa } from 'execa';
9
9
  import pc from 'picocolors';
10
10
  import { createRequire } from 'node:module';
11
+ import { ensureSudo, sudoInteractive } from '../util/sudo.js';
11
12
 
12
13
  const require = createRequire(import.meta.url);
13
14
  const pkg = require('../../package.json');
@@ -66,9 +67,18 @@ export async function promptSelfUpdate() {
66
67
 
67
68
  // Release the terminal so npm's progress & any prompts are visible/interruptible.
68
69
  console.log();
69
- const res = await execa('npm', ['install', '-g', `${NPM_NAME}@latest`], { stdio: 'inherit', reject: false });
70
+ let res = await execa('npm', ['install', '-g', `${NPM_NAME}@latest`], { stdio: 'inherit', reject: false });
70
71
  if (res.exitCode !== 0) {
71
- p.log.warn('Update failed. You can retry with: npm install -g ' + NPM_NAME + '@latest');
72
+ p.log.warn('Update failed. Attempting again with sudo...');
73
+ try {
74
+ await ensureSudo();
75
+ res = await sudoInteractive(['npm', 'install', '-g', `${NPM_NAME}@latest`]);
76
+ } catch {
77
+ // Fall through to error
78
+ }
79
+ }
80
+ if (res.exitCode !== 0) {
81
+ p.log.warn('Update failed. You can retry with: sudo npm install -g ' + NPM_NAME + '@latest');
72
82
  return false;
73
83
  }
74
84
  p.log.success(`Updated to v${latest}. ` + 'Run `parrot-blackbox` again to use the new version.');
@@ -99,9 +109,18 @@ export async function runSelfUpdate({ force = false } = {}) {
99
109
  const want = await p.confirm({ message: `Update parrot-blackbox to v${latest} now?`, initialValue: true });
100
110
  if (p.isCancel(want) || !want) { p.log.message(pc.dim('Update skipped.')); return false; }
101
111
  console.log();
102
- const res = await execa('npm', ['install', '-g', `${NPM_NAME}@latest`], { stdio: 'inherit', reject: false });
112
+ let res = await execa('npm', ['install', '-g', `${NPM_NAME}@latest`], { stdio: 'inherit', reject: false });
113
+ if (res.exitCode !== 0) {
114
+ p.log.warn('Update failed. Attempting again with sudo...');
115
+ try {
116
+ await ensureSudo();
117
+ res = await sudoInteractive(['npm', 'install', '-g', `${NPM_NAME}@latest`]);
118
+ } catch {
119
+ // Fall through to error
120
+ }
121
+ }
103
122
  if (res.exitCode !== 0) {
104
- p.log.warn('Update failed. You can retry with: npm install -g ' + NPM_NAME + '@latest');
123
+ p.log.warn('Update failed. You can retry with: sudo npm install -g ' + NPM_NAME + '@latest');
105
124
  return false;
106
125
  }
107
126
  p.log.success(`Updated to v${latest}. Restart parrot-blackbox to use the new version.`);
@@ -112,7 +131,16 @@ export async function runSelfUpdate({ force = false } = {}) {
112
131
  export async function selfUninstall() {
113
132
  const p = await import('@clack/prompts');
114
133
  console.log();
115
- const res = await execa('npm', ['uninstall', '-g', NPM_NAME], { stdio: 'inherit', reject: false });
134
+ let res = await execa('npm', ['uninstall', '-g', NPM_NAME], { stdio: 'inherit', reject: false });
135
+ if (res.exitCode !== 0) {
136
+ p.log.warn('Uninstall failed. Attempting again with sudo...');
137
+ try {
138
+ await ensureSudo();
139
+ res = await sudoInteractive(['npm', 'uninstall', '-g', NPM_NAME]);
140
+ } catch {
141
+ // Fall through to error
142
+ }
143
+ }
116
144
  if (res.exitCode === 0) {
117
145
  p.log.success(`${NPM_NAME} removed. The parrot-blackbox command is no longer available.`);
118
146
  return true;
package/src/util/sudo.js CHANGED
@@ -1,23 +1,116 @@
1
1
  /**
2
2
  * Privileged execution.
3
3
  *
4
- * - `sudoInteractive` — runs `sudo <args>` with the terminal inherited so the
4
+ * - `ensureSudo` — prompts the user for their sudo password once,
5
+ * arming the sudo timestamp so all subsequent calls in the same session
6
+ * work without re-prompting (unless the timestamp expires after ~15 min).
7
+ * Call this EARLY (e.g. at wizard launch, before any privileged menu item).
8
+ * - `sudoExec` — the ONE wrapper every caller should use. It picks
9
+ * interactive vs non-interactive automatically based on TTY, and pre-prompts
10
+ * for the password if the timestamp has lapsed.
11
+ * - `sudoInteractive` — runs `sudo <args>` with the terminal inherited so the
5
12
  * password prompt is visible and Ctrl+C works (the gitswitch/theamify/
6
13
  * warp-wizard pattern: callers stop any spinner FIRST).
7
- * - `sudoNonInteractive` — runs `sudo -n <args>`; used by the background daemon
8
- * so it can *never* hang waiting on a password. When the sudo timestamp has
9
- * lapsed the job is deferred and retried later; a single interactive
10
- * `parrot-blackbox snapshot now` (or any sudo use on the box) re-arms it.
14
+ * - `sudoInteractiveCapture` — same but captures stdout (for `timeshift --list`).
15
+ * - `sudoNonInteractive` — runs `sudo -n <args>`; used by the background daemon
16
+ * so it can *never* hang waiting on a password.
11
17
  *
12
18
  * PBB_SUDO_DIRECT=1 (used by unit tests) bypasses sudo entirely.
13
19
  */
14
20
 
15
- import { execa } from 'execa';
21
+ import { execa, execaSync } from 'execa';
16
22
 
17
23
  function sudoPrefix() {
18
24
  return process.env.PBB_SUDO_DIRECT === '1' ? [] : ['sudo'];
19
25
  }
20
26
 
27
+ /**
28
+ * Prompt the user for their sudo password (if needed) to arm the sudo
29
+ * timestamp for this session. This is a no-op when:
30
+ * - already root (UID 0)
31
+ * - PBB_SUDO_DIRECT=1 (test mode)
32
+ * - sudo timestamp is already valid
33
+ *
34
+ * Safe to call multiple times — it checks `sudo -n true` first, and only
35
+ * prompts when the timestamp has actually lapsed.
36
+ */
37
+ export async function ensureSudo() {
38
+ if (process.env.PBB_SUDO_DIRECT === '1') return true;
39
+ if (process.getuid && process.getuid() === 0) return true;
40
+
41
+ // Check if sudo timestamp is already valid (no prompt needed).
42
+ const check = await execa('sudo', ['-n', 'true'], { reject: false });
43
+ if (check.exitCode === 0) return true;
44
+
45
+ // Timestamp lapsed — prompt the user for their password.
46
+ // stdio: 'inherit' keeps the terminal so the user can type the password.
47
+ console.log('\n🔐 Root privileges required — please enter your sudo password:\n');
48
+ const res = await execa('sudo', ['-v'], { stdio: 'inherit', reject: false });
49
+ if (res.exitCode !== 0) {
50
+ throw new Error('sudo authentication failed — cannot continue without root privileges');
51
+ }
52
+ return true;
53
+ }
54
+
55
+ /**
56
+ * Synchronous version of ensureSudo for use in sync code paths.
57
+ * Returns true if sudo is available (timestamp valid or we're root).
58
+ * Does NOT prompt — use ensureSudo() for that.
59
+ */
60
+ export function isSudoArmed() {
61
+ if (process.env.PBB_SUDO_DIRECT === '1') return true;
62
+ if (process.getuid && process.getuid() === 0) return true;
63
+ try {
64
+ const res = execaSync('sudo', ['-n', 'true'], { reject: false });
65
+ return res.exitCode === 0;
66
+ } catch {
67
+ return false;
68
+ }
69
+ }
70
+
71
+ /**
72
+ * Smart sudo wrapper — the preferred entry point for ALL privileged calls
73
+ * from interactive (TTY) code paths. It:
74
+ * 1. Checks if sudo is armed
75
+ * 2. If not, prompts the user for password (if TTY)
76
+ * 3. Then runs the command with sudo
77
+ *
78
+ * For daemon/non-interactive use, pass { interactive: false } — it will use
79
+ * `sudo -n` and never hang on a prompt.
80
+ */
81
+ export async function sudoExec(args, { interactive = true, capture = false, timeout = 0 } = {}) {
82
+ if (interactive && process.stdin.isTTY) {
83
+ // Ensure the sudo timestamp is armed before running the command.
84
+ await ensureSudo();
85
+ if (capture) {
86
+ return sudoInteractiveCapture(args, { timeout });
87
+ }
88
+ return sudoInteractive(args, { timeout });
89
+ }
90
+ return sudoNonInteractive(args);
91
+ }
92
+
93
+ /**
94
+ * Synchronous sudo execution for code paths that MUST be sync (e.g. snapshotDirFor).
95
+ * Requires that ensureSudo() was called earlier in the session.
96
+ * Falls back to non-interactive sudo; if that fails, falls back to direct execution.
97
+ */
98
+ export function sudoExecSync(args, { reject = false } = {}) {
99
+ if (process.env.PBB_SUDO_DIRECT === '1') {
100
+ return execaSync(args[0], args.slice(1), { reject });
101
+ }
102
+ // Try with sudo (timestamp should be armed from earlier ensureSudo call)
103
+ const full = ['sudo', '-n', ...args];
104
+ const res = execaSync(full[0], full.slice(1), { reject: false });
105
+ if (res.exitCode === 0) return res;
106
+ // If sudo -n fails, try sudo with inherited stdio (will prompt if TTY available)
107
+ if (process.stdin?.isTTY) {
108
+ return execaSync('sudo', args, { reject, stdio: 'inherit' });
109
+ }
110
+ // Last resort: try without sudo (some operations work unprivileged)
111
+ return execaSync(args[0], args.slice(1), { reject: false });
112
+ }
113
+
21
114
  /** Interactive sudo baseline for injecting PBB_SUDO_DIRECT consistent with the rest. */
22
115
  export async function sudoInteractive(args, { timeout = 0 } = {}) {
23
116
  const full = [...sudoPrefix(), ...args];