parrot-blackbox 1.0.16 → 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/snapshot.js +26 -16
- package/src/commands/wizard.js +2 -0
- package/src/util/sudo.js +15 -15
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/snapshot.js
CHANGED
|
@@ -11,7 +11,7 @@ import fs from 'node:fs';
|
|
|
11
11
|
import path from 'node:path';
|
|
12
12
|
import { execa, execaSync } from 'execa';
|
|
13
13
|
import { loadConfig, loadState, saveState, journal, hasCommandSync } from '../core/store.js';
|
|
14
|
-
import { timeshiftDir } from '../core/paths.js';
|
|
14
|
+
import { timeshiftDir, stateDir, configFile, manifestsDir } from '../core/paths.js';
|
|
15
15
|
import { iso, clock } from '../core/time.js';
|
|
16
16
|
import { refreshAccounts } from '../storage/accounts.js';
|
|
17
17
|
import { planAndPlace } from '../storage/allocator.js';
|
|
@@ -259,26 +259,31 @@ export function cleanupSnapshotMount(snapshot) {
|
|
|
259
259
|
export async function runSnapshotBackup(cfg, state, { due, privileged = 'noninteractive', onProgress } = {}) {
|
|
260
260
|
journal('snapshots', `start due=${due} privileged=${privileged}`);
|
|
261
261
|
const accounts = await refreshAccounts(cfg);
|
|
262
|
+
|
|
263
|
+
if (accounts.length === 0) {
|
|
264
|
+
throw new Error('no storage accounts configured — add one with `parrot-blackbox account add`');
|
|
265
|
+
}
|
|
262
266
|
|
|
263
|
-
|
|
264
|
-
// it means its previous upload failed or was interrupted. We should resume it
|
|
265
|
-
// to avoid re-uploading everything to a new snapshot ID.
|
|
267
|
+
let created = null;
|
|
266
268
|
const localSnaps = await listLocalSnapshots({ privileged });
|
|
267
|
-
const manifestLocalDir = process.env.PBB_MANIFESTS_DIR || path.join(process.env.PBB_STATE_DIR || '.', 'manifests');
|
|
268
269
|
|
|
269
|
-
|
|
270
|
-
//
|
|
270
|
+
// Look at snapshots from newest to oldest; find the most recent one that
|
|
271
|
+
// belongs to parrot-blackbox and has not been fully uploaded yet.
|
|
271
272
|
for (let i = localSnaps.length - 1; i >= 0; i--) {
|
|
272
273
|
const s = localSnaps[i];
|
|
273
274
|
if (s.tags.includes('W') || (s.line && s.line.includes('parrot-blackbox'))) {
|
|
274
|
-
|
|
275
|
-
|
|
275
|
+
// Check for the manifest FILE on disk — this is the authoritative source.
|
|
276
|
+
// state.manifests (the in-memory JSON blob) may be empty on the first run
|
|
277
|
+
// or after a reinstall, so we cannot rely on it alone.
|
|
278
|
+
const manifestFile = path.join(manifestsDir(), `snapshots-${s.name}.json`);
|
|
279
|
+
const isUploaded = fs.existsSync(manifestFile);
|
|
280
|
+
if (!isUploaded) {
|
|
276
281
|
created = s;
|
|
277
282
|
journal('snapshots', `resuming upload for incomplete snapshot ${s.name}`);
|
|
283
|
+
console.log(`\n⏳ Resuming incomplete upload for snapshot ${s.name} (from tracker)...`);
|
|
278
284
|
break;
|
|
279
285
|
} else {
|
|
280
|
-
// The most recent parrot-blackbox snapshot is fully uploaded
|
|
281
|
-
// and create a new one.
|
|
286
|
+
// The most recent parrot-blackbox snapshot is fully uploaded — create a new one.
|
|
282
287
|
break;
|
|
283
288
|
}
|
|
284
289
|
}
|
|
@@ -293,16 +298,21 @@ export async function runSnapshotBackup(cfg, state, { due, privileged = 'noninte
|
|
|
293
298
|
let manifest;
|
|
294
299
|
try {
|
|
295
300
|
const bin = process.argv[1] || 'parrot-blackbox';
|
|
296
|
-
const outPath = path.join(
|
|
297
|
-
const
|
|
298
|
-
|
|
301
|
+
const outPath = path.join(stateDir(), `manifest-${created.name}.json`);
|
|
302
|
+
const envVars = {
|
|
303
|
+
HOME: process.env.HOME,
|
|
304
|
+
PBB_STATE_DIR: stateDir(),
|
|
305
|
+
PBB_CONFIG_FILE: configFile()
|
|
306
|
+
};
|
|
307
|
+
const cmdArgs = [process.execPath, bin, '_internal_upload', dir, 'snapshots', created.name, cfg.storage.remoteRoot, String(cfg.storage.chunkSize), outPath];
|
|
308
|
+
const args = process.env.PBB_SUDO_DIRECT === '1' ? cmdArgs : ['-E', ...cmdArgs];
|
|
299
309
|
|
|
300
310
|
let res;
|
|
301
311
|
if (privileged === 'interactive') {
|
|
302
312
|
await ensureSudo();
|
|
303
|
-
res = await sudoInteractive(args);
|
|
313
|
+
res = await sudoInteractive(args, { env: envVars });
|
|
304
314
|
} else {
|
|
305
|
-
res = await sudoNonInteractive(args);
|
|
315
|
+
res = await sudoNonInteractive(args, { env: envVars });
|
|
306
316
|
}
|
|
307
317
|
|
|
308
318
|
if (res.exitCode !== 0) {
|
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/util/sudo.js
CHANGED
|
@@ -78,16 +78,16 @@ export function isSudoArmed() {
|
|
|
78
78
|
* For daemon/non-interactive use, pass { interactive: false } — it will use
|
|
79
79
|
* `sudo -n` and never hang on a prompt.
|
|
80
80
|
*/
|
|
81
|
-
export async function sudoExec(args, { interactive = true, capture = false, timeout = 0 } = {}) {
|
|
81
|
+
export async function sudoExec(args, { interactive = true, capture = false, timeout = 0, env = {} } = {}) {
|
|
82
82
|
if (interactive && process.stdin.isTTY) {
|
|
83
83
|
// Ensure the sudo timestamp is armed before running the command.
|
|
84
84
|
await ensureSudo();
|
|
85
85
|
if (capture) {
|
|
86
|
-
return sudoInteractiveCapture(args, { timeout });
|
|
86
|
+
return sudoInteractiveCapture(args, { timeout, env });
|
|
87
87
|
}
|
|
88
|
-
return sudoInteractive(args, { timeout });
|
|
88
|
+
return sudoInteractive(args, { timeout, env });
|
|
89
89
|
}
|
|
90
|
-
return sudoNonInteractive(args);
|
|
90
|
+
return sudoNonInteractive(args, { env });
|
|
91
91
|
}
|
|
92
92
|
|
|
93
93
|
/**
|
|
@@ -95,26 +95,26 @@ export async function sudoExec(args, { interactive = true, capture = false, time
|
|
|
95
95
|
* Requires that ensureSudo() was called earlier in the session.
|
|
96
96
|
* Falls back to non-interactive sudo; if that fails, falls back to direct execution.
|
|
97
97
|
*/
|
|
98
|
-
export function sudoExecSync(args, { reject = false } = {}) {
|
|
98
|
+
export function sudoExecSync(args, { reject = false, env = {} } = {}) {
|
|
99
99
|
if (process.env.PBB_SUDO_DIRECT === '1') {
|
|
100
|
-
return execaSync(args[0], args.slice(1), { reject });
|
|
100
|
+
return execaSync(args[0], args.slice(1), { reject, env: { ...process.env, ...env } });
|
|
101
101
|
}
|
|
102
102
|
// Try with sudo (timestamp should be armed from earlier ensureSudo call)
|
|
103
103
|
const full = ['sudo', '-n', ...args];
|
|
104
|
-
const res = execaSync(full[0], full.slice(1), { reject: false });
|
|
104
|
+
const res = execaSync(full[0], full.slice(1), { reject: false, env: { ...process.env, ...env } });
|
|
105
105
|
if (res.exitCode === 0) return res;
|
|
106
106
|
// If sudo -n fails, try sudo with inherited stdio (will prompt if TTY available)
|
|
107
107
|
if (process.stdin?.isTTY) {
|
|
108
|
-
return execaSync('sudo', args, { reject, stdio: 'inherit' });
|
|
108
|
+
return execaSync('sudo', args, { reject, stdio: 'inherit', env: { ...process.env, ...env } });
|
|
109
109
|
}
|
|
110
110
|
// Last resort: try without sudo (some operations work unprivileged)
|
|
111
|
-
return execaSync(args[0], args.slice(1), { reject: false });
|
|
111
|
+
return execaSync(args[0], args.slice(1), { reject: false, env: { ...process.env, ...env } });
|
|
112
112
|
}
|
|
113
113
|
|
|
114
114
|
/** Interactive sudo baseline for injecting PBB_SUDO_DIRECT consistent with the rest. */
|
|
115
|
-
export async function sudoInteractive(args, { timeout = 0 } = {}) {
|
|
115
|
+
export async function sudoInteractive(args, { timeout = 0, env = {} } = {}) {
|
|
116
116
|
const full = [...sudoPrefix(), ...args];
|
|
117
|
-
const res = await execa(full[0], full.slice(1), { stdio: 'inherit', reject: false, timeout });
|
|
117
|
+
const res = await execa(full[0], full.slice(1), { stdio: 'inherit', reject: false, timeout, env: { ...process.env, ...env } });
|
|
118
118
|
if (res.exitCode !== 0) {
|
|
119
119
|
throw new Error(`elevated command failed: ${args.join(' ')} (exit ${res.exitCode})`);
|
|
120
120
|
}
|
|
@@ -126,9 +126,9 @@ export async function sudoInteractive(args, { timeout = 0 } = {}) {
|
|
|
126
126
|
* the parser) while keeping stdin inherited so the password prompt stays
|
|
127
127
|
* usable — like the real sudo, which reads passwords from /dev/tty.
|
|
128
128
|
*/
|
|
129
|
-
export async function sudoInteractiveCapture(args, { timeout = 0 } = {}) {
|
|
129
|
+
export async function sudoInteractiveCapture(args, { timeout = 0, env = {} } = {}) {
|
|
130
130
|
const full = [...sudoPrefix(), ...args];
|
|
131
|
-
const res = await execa(full[0], full.slice(1), { stdin: 'inherit', reject: false, timeout });
|
|
131
|
+
const res = await execa(full[0], full.slice(1), { stdin: 'inherit', reject: false, timeout, env: { ...process.env, ...env } });
|
|
132
132
|
if (res.exitCode !== 0) {
|
|
133
133
|
throw new Error(`elevated command failed: ${args.join(' ')} (exit ${res.exitCode})`);
|
|
134
134
|
}
|
|
@@ -136,9 +136,9 @@ export async function sudoInteractiveCapture(args, { timeout = 0 } = {}) {
|
|
|
136
136
|
}
|
|
137
137
|
|
|
138
138
|
/** Non-interactive sudo (daemon-safe). Returns execa result, never hangs. */
|
|
139
|
-
export async function sudoNonInteractive(args) {
|
|
139
|
+
export async function sudoNonInteractive(args, { env = {} } = {}) {
|
|
140
140
|
const full = process.env.PBB_SUDO_DIRECT === '1' ? args : ['sudo', '-n', ...args];
|
|
141
|
-
const res = await execa(full[0], full.slice(1), { reject: false });
|
|
141
|
+
const res = await execa(full[0], full.slice(1), { reject: false, env: { ...process.env, ...env } });
|
|
142
142
|
return res;
|
|
143
143
|
}
|
|
144
144
|
|