parrot-blackbox 1.0.15 → 1.0.18

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.15",
3
+ "version": "1.0.18",
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",
@@ -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;
@@ -11,13 +11,13 @@ 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';
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 */
@@ -262,26 +259,31 @@ export function cleanupSnapshotMount(snapshot) {
262
259
  export async function runSnapshotBackup(cfg, state, { due, privileged = 'noninteractive', onProgress } = {}) {
263
260
  journal('snapshots', `start due=${due} privileged=${privileged}`);
264
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
+ }
265
266
 
266
- // Resume logic: if the most recent local snapshot has no local manifest,
267
- // it means its previous upload failed or was interrupted. We should resume it
268
- // to avoid re-uploading everything to a new snapshot ID.
267
+ let created = null;
269
268
  const localSnaps = await listLocalSnapshots({ privileged });
270
- const manifestLocalDir = process.env.PBB_MANIFESTS_DIR || path.join(process.env.PBB_STATE_DIR || '.', 'manifests');
271
269
 
272
- let created = null;
273
- // 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.
274
272
  for (let i = localSnaps.length - 1; i >= 0; i--) {
275
273
  const s = localSnaps[i];
276
274
  if (s.tags.includes('W') || (s.line && s.line.includes('parrot-blackbox'))) {
277
- const manPath = path.join(manifestLocalDir, `snapshots-${s.name}.json`);
278
- 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) {
279
281
  created = s;
280
282
  journal('snapshots', `resuming upload for incomplete snapshot ${s.name}`);
283
+ console.log(`\n⏳ Resuming incomplete upload for snapshot ${s.name} (from tracker)...`);
281
284
  break;
282
285
  } else {
283
- // The most recent parrot-blackbox snapshot is fully uploaded. We can break
284
- // and create a new one.
286
+ // The most recent parrot-blackbox snapshot is fully uploaded — create a new one.
285
287
  break;
286
288
  }
287
289
  }
@@ -296,15 +298,21 @@ export async function runSnapshotBackup(cfg, state, { due, privileged = 'noninte
296
298
  let manifest;
297
299
  try {
298
300
  const bin = process.argv[1] || 'parrot-blackbox';
299
- const outPath = path.join(process.env.PBB_STATE_DIR || '/tmp', `manifest-${created.name}.json`);
300
- const baseArgs = [process.execPath, bin, '_internal_upload', dir, 'snapshots', created.name, cfg.storage.remoteRoot, String(cfg.storage.chunkSize), outPath];
301
- 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];
302
309
 
303
310
  let res;
304
311
  if (privileged === 'interactive') {
305
- res = await sudoInteractive(args);
312
+ await ensureSudo();
313
+ res = await sudoInteractive(args, { env: envVars });
306
314
  } else {
307
- res = await sudoNonInteractive(args);
315
+ res = await sudoNonInteractive(args, { env: envVars });
308
316
  }
309
317
 
310
318
  if (res.exitCode !== 0) {
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':
@@ -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);
@@ -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;
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,27 +1,120 @@
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, env = {} } = {}) {
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, env });
87
+ }
88
+ return sudoInteractive(args, { timeout, env });
89
+ }
90
+ return sudoNonInteractive(args, { env });
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, env = {} } = {}) {
99
+ if (process.env.PBB_SUDO_DIRECT === '1') {
100
+ return execaSync(args[0], args.slice(1), { reject, env: { ...process.env, ...env } });
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, env: { ...process.env, ...env } });
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', env: { ...process.env, ...env } });
109
+ }
110
+ // Last resort: try without sudo (some operations work unprivileged)
111
+ return execaSync(args[0], args.slice(1), { reject: false, env: { ...process.env, ...env } });
112
+ }
113
+
21
114
  /** Interactive sudo baseline for injecting PBB_SUDO_DIRECT consistent with the rest. */
22
- export async function sudoInteractive(args, { timeout = 0 } = {}) {
115
+ export async function sudoInteractive(args, { timeout = 0, env = {} } = {}) {
23
116
  const full = [...sudoPrefix(), ...args];
24
- 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 } });
25
118
  if (res.exitCode !== 0) {
26
119
  throw new Error(`elevated command failed: ${args.join(' ')} (exit ${res.exitCode})`);
27
120
  }
@@ -33,9 +126,9 @@ export async function sudoInteractive(args, { timeout = 0 } = {}) {
33
126
  * the parser) while keeping stdin inherited so the password prompt stays
34
127
  * usable — like the real sudo, which reads passwords from /dev/tty.
35
128
  */
36
- export async function sudoInteractiveCapture(args, { timeout = 0 } = {}) {
129
+ export async function sudoInteractiveCapture(args, { timeout = 0, env = {} } = {}) {
37
130
  const full = [...sudoPrefix(), ...args];
38
- 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 } });
39
132
  if (res.exitCode !== 0) {
40
133
  throw new Error(`elevated command failed: ${args.join(' ')} (exit ${res.exitCode})`);
41
134
  }
@@ -43,9 +136,9 @@ export async function sudoInteractiveCapture(args, { timeout = 0 } = {}) {
43
136
  }
44
137
 
45
138
  /** Non-interactive sudo (daemon-safe). Returns execa result, never hangs. */
46
- export async function sudoNonInteractive(args) {
139
+ export async function sudoNonInteractive(args, { env = {} } = {}) {
47
140
  const full = process.env.PBB_SUDO_DIRECT === '1' ? args : ['sudo', '-n', ...args];
48
- 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 } });
49
142
  return res;
50
143
  }
51
144