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 +2 -2
- package/src/backup/restore.js +3 -1
- package/src/backup/snapshot.js +11 -13
- package/src/cli.js +17 -2
- package/src/commands/setup.js +3 -1
- package/src/commands/tools.js +3 -1
- package/src/lib/self.js +33 -5
- package/src/util/sudo.js +99 -6
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "parrot-blackbox",
|
|
3
|
-
"version": "1.0.
|
|
4
|
-
"description": "
|
|
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": {
|
package/src/backup/restore.js
CHANGED
|
@@ -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;
|
package/src/backup/snapshot.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
232
|
-
|
|
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
|
-
|
|
249
|
-
|
|
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
|
}
|
package/src/commands/setup.js
CHANGED
|
@@ -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
|
-
|
|
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);
|
package/src/commands/tools.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
* - `
|
|
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
|
-
* - `
|
|
8
|
-
*
|
|
9
|
-
*
|
|
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];
|