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 +1 -1
- package/src/backup/restore.js +3 -1
- package/src/backup/snapshot.js +37 -29
- package/src/cli.js +2 -2
- package/src/commands/setup.js +3 -1
- package/src/commands/tools.js +3 -1
- package/src/commands/wizard.js +2 -0
- package/src/lib/self.js +33 -5
- package/src/util/sudo.js +105 -12
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "parrot-blackbox",
|
|
3
|
-
"version": "1.0.
|
|
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",
|
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
|
@@ -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
|
-
|
|
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 */
|
|
@@ -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
|
-
|
|
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
|
-
|
|
273
|
-
//
|
|
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
|
-
|
|
278
|
-
|
|
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
|
|
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(
|
|
300
|
-
const
|
|
301
|
-
|
|
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
|
-
|
|
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':
|
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/commands/wizard.js
CHANGED
|
@@ -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
|
-
|
|
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,27 +1,120 @@
|
|
|
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, 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
|
|