parrot-blackbox 1.0.5 → 1.0.7
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 +83 -12
- package/src/commands/wizard.js +39 -38
- package/src/util/network.js +38 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "parrot-blackbox",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.7",
|
|
4
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.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/cli.js",
|
package/src/backup/snapshot.js
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
|
|
10
10
|
import fs from 'node:fs';
|
|
11
11
|
import path from 'node:path';
|
|
12
|
-
import { execa } from 'execa';
|
|
12
|
+
import { execa, execaSync } from 'execa';
|
|
13
13
|
import { loadConfig, loadState, saveState, journal, hasCommandSync } from '../core/store.js';
|
|
14
14
|
import { timeshiftDir } from '../core/paths.js';
|
|
15
15
|
import { iso, clock } from '../core/time.js';
|
|
@@ -26,22 +26,59 @@ export class SudoDeferredError extends Error {
|
|
|
26
26
|
}
|
|
27
27
|
|
|
28
28
|
/**
|
|
29
|
-
* Parse `timeshift --list` output into snapshots.
|
|
29
|
+
* Parse `timeshift --list` output into snapshots. Tolerant of multiple formats:
|
|
30
|
+
*
|
|
31
|
+
* Old format (Timeshift 22.x/23.x):
|
|
32
|
+
* 2026-08-29 22:00:01 W 2026-08-29_22-00-01 /timeshift/snapshots/...
|
|
33
|
+
*
|
|
34
|
+
* Table format with header (Timeshift 24.x):
|
|
35
|
+
* Num Name Tags Description
|
|
36
|
+
* 0 2026-08-29 22:00:01 W 2026-08-29_22-00-01 parrot-blackbox
|
|
37
|
+
*
|
|
38
|
+
* Current format (Timeshift 24.06+):
|
|
39
|
+
* Num Name Tags Description
|
|
40
|
+
* 0 > 2026-09-01_21-22-39 W parrot-blackbox 2026-09-01T21:22:05
|
|
41
|
+
*
|
|
30
42
|
* @returns {Array<{name:string, date:string, time:string, tags:string, dir:?string}>}
|
|
31
43
|
*/
|
|
32
44
|
export function parseTimeshiftList(stdout) {
|
|
33
45
|
const out = [];
|
|
34
46
|
for (const line of String(stdout).split('\n')) {
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
47
|
+
// Try current format first: Num > NAME Tags Description
|
|
48
|
+
// The NAME field is in YYYY-MM-DD_HH-MM-SS format
|
|
49
|
+
let m = /^\s*\d+\s+>?\s+(\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2})\s+([A-Za-z]{1,6})\s+(.*)$/.exec(line);
|
|
50
|
+
if (m) {
|
|
51
|
+
const [, name, tags, description] = m;
|
|
52
|
+
// Extract date and time from the name (YYYY-MM-DD_HH-MM-SS)
|
|
53
|
+
const date = name.slice(0, 10); // YYYY-MM-DD
|
|
54
|
+
const time = name.slice(11).replace(/-/g, ':'); // HH:MM:SS
|
|
55
|
+
const dir = findPathInLine(line);
|
|
56
|
+
out.push({ name, date, time, tags, dir, line: `${name} ${tags} ${description}` });
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Try older format: [Num] DATE TIME TAGS NAME [description]
|
|
61
|
+
m = /^\s*(?:\d+\s+)?(\d{4}-\d{2}-\d{2})\s+(\d{2}:\d{2}:\d{2})\s+([A-Za-z]{1,6})\s+(\S+)(?:\s+(.*))?$/.exec(line);
|
|
62
|
+
if (m) {
|
|
63
|
+
const [, date, time, tags, dirOrName, detail] = m;
|
|
64
|
+
// Prefer the explicit dir-ish token (name contains _HH-MM-SS) over a rebuilt name.
|
|
65
|
+
const name = /^[\w.-]+\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2}$/.test(dirOrName)
|
|
66
|
+
? dirOrName
|
|
67
|
+
: `${date}_${time.replace(/:/g, '-')}`;
|
|
68
|
+
const dir = findPathInLine(line);
|
|
69
|
+
out.push({ name, date, time, tags, dir, line: `${date} ${time} ${tags} ${dirOrName}${detail ? ` ${detail}` : ''}` });
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
41
72
|
}
|
|
42
73
|
return out.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
43
74
|
}
|
|
44
75
|
|
|
76
|
+
/** Extract a created snapshot name from `timeshift --create` output. */
|
|
77
|
+
export function extractCreatedName(output) {
|
|
78
|
+
const m = /Created new snapshot[:\s]+([\w.\-]+)/i.exec(String(output || ''));
|
|
79
|
+
return m ? m[1] : null;
|
|
80
|
+
}
|
|
81
|
+
|
|
45
82
|
/** Extract the snapshot directory path from a list line if present. */
|
|
46
83
|
function findPathInLine(line) {
|
|
47
84
|
const dirs = line.match(/\/[^\s]+\/[A-Za-z0-9._-]+/g) || [];
|
|
@@ -85,18 +122,28 @@ export async function createSnapshot({ comment, privileged = 'noninteractive' }
|
|
|
85
122
|
const before = new Set((await listLocalSnapshots({ privileged })).map((s) => s.name));
|
|
86
123
|
const args = ['timeshift', '--create', '--comments', comment || 'parrot-blackbox', '--tags', 'W'];
|
|
87
124
|
|
|
125
|
+
let createOut = '';
|
|
88
126
|
if (privileged === 'interactive') {
|
|
89
|
-
const res = await
|
|
127
|
+
const res = await sudoInteractiveCapture(args);
|
|
90
128
|
if (res.exitCode !== 0) throw new Error(`timeshift --create failed (exit ${res.exitCode})`);
|
|
129
|
+
createOut = `${res.stdout || ''} ${res.stderr || ''}`;
|
|
91
130
|
} else {
|
|
92
131
|
const res = await sudoNonInteractive(args);
|
|
93
132
|
if (res.exitCode !== 0) {
|
|
94
133
|
if (/password|authentication|sudo/i.test(res.stderr || '')) throw new SudoDeferredError();
|
|
95
134
|
throw new Error(`timeshift --create failed (exit ${res.exitCode}): ${res.stderr?.trim()}`);
|
|
96
135
|
}
|
|
136
|
+
createOut = `${res.stdout || ''} ${res.stderr || ''}`;
|
|
97
137
|
}
|
|
98
138
|
|
|
99
|
-
//
|
|
139
|
+
// Primary: read the exact name from timeshift's own output
|
|
140
|
+
// ("Created new snapshot: 2026-08-31_16-00-01") — the most reliable signal.
|
|
141
|
+
const createdName = extractCreatedName(createOut);
|
|
142
|
+
if (createdName) {
|
|
143
|
+
return { name: createdName, date: createdName.slice(0, 10), time: createdName.slice(11).replace(/-/g, ':'), tags: 'W', dir: null, line: createOut.trim() };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Fallback: diff the list before/after.
|
|
100
147
|
const after = await listLocalSnapshots({ privileged });
|
|
101
148
|
const created = after.find((s) => !before.has(s.name)) || after[after.length - 1];
|
|
102
149
|
if (!created) throw new Error('timeshift reported success but no snapshot was found');
|
|
@@ -118,15 +165,39 @@ export async function deleteSnapshot(name, { privileged = 'noninteractive' } = {
|
|
|
118
165
|
}
|
|
119
166
|
return true;
|
|
120
167
|
}
|
|
121
|
-
/**
|
|
168
|
+
/**
|
|
169
|
+
* Resolve the on-disk directory of a snapshot.
|
|
170
|
+
* BTRFS mode keeps snapshots in a hidden subvolume that Timeshift mounts at
|
|
171
|
+
* /run/timeshift/backup (e.g. .../timeshift-btrfs/snapshots/<name>) — check the
|
|
172
|
+
* classic locations first, then search the mounted backup tree.
|
|
173
|
+
*/
|
|
122
174
|
export function snapshotDirFor(snapshot, { privileged = 'noninteractive' } = {}) {
|
|
123
175
|
if (snapshot.dir && fs.existsSync(snapshot.dir)) return snapshot.dir;
|
|
124
176
|
const base = timeshiftDir();
|
|
125
177
|
const candidates = [
|
|
126
178
|
path.join(base, 'snapshots', snapshot.name),
|
|
127
179
|
path.join(base, snapshot.name),
|
|
180
|
+
`/run/timeshift/backup/${snapshot.name}`,
|
|
181
|
+
`/run/timeshift/backup/timeshift-btrfs/snapshots/${snapshot.name}`,
|
|
182
|
+
`/run/timeshift/backup/@/timeshift-btrfs/snapshots/${snapshot.name}`,
|
|
128
183
|
];
|
|
129
|
-
|
|
184
|
+
const direct = candidates.find((c) => fs.existsSync(c));
|
|
185
|
+
if (direct) return direct;
|
|
186
|
+
|
|
187
|
+
// Best effort: search the mounted Timeshift backup tree for the snapshot dir.
|
|
188
|
+
try {
|
|
189
|
+
if (fs.existsSync('/run/timeshift/backup')) {
|
|
190
|
+
const found = execaSync(
|
|
191
|
+
'bash',
|
|
192
|
+
['-c', `find /run/timeshift/backup -maxdepth 5 -type d -name '${snapshot.name}' 2>/dev/null | head -1`],
|
|
193
|
+
{ reject: false, timeout: 5000 },
|
|
194
|
+
).stdout.trim();
|
|
195
|
+
if (found) return found;
|
|
196
|
+
}
|
|
197
|
+
} catch {
|
|
198
|
+
/* fall through */
|
|
199
|
+
}
|
|
200
|
+
return candidates[0];
|
|
130
201
|
}
|
|
131
202
|
|
|
132
203
|
/**
|
package/src/commands/wizard.js
CHANGED
|
@@ -48,16 +48,16 @@ async function autoUpdateCheck() {
|
|
|
48
48
|
async function addAccountAction() {
|
|
49
49
|
for (;;) {
|
|
50
50
|
const provider = await p.select({
|
|
51
|
-
message: '
|
|
51
|
+
message: '☁️ Add cloud account',
|
|
52
52
|
options: [
|
|
53
|
-
{ value: 'mega', label: 'MEGA
|
|
54
|
-
{ value: 'gdrive', label: 'Google Drive
|
|
53
|
+
{ value: 'mega', label: 'MEGA', hint: '20 GB free' },
|
|
54
|
+
{ value: 'gdrive', label: 'Google Drive', hint: '15 GB free' },
|
|
55
55
|
{ value: 'back', label: '← Back' },
|
|
56
56
|
],
|
|
57
57
|
});
|
|
58
58
|
if (p.isCancel(provider) || provider === 'back') return;
|
|
59
59
|
const res = await guidedRemoteAdd({ provider });
|
|
60
|
-
if (res.ok) p.log.success(`✔ ${pc.bold(res.name)}
|
|
60
|
+
if (res.ok) p.log.success(`✔ ${pc.bold(res.name)} added to pool.`);
|
|
61
61
|
else if (res.error) p.log.warn(res.error);
|
|
62
62
|
else if (res.cancelled) { p.log.message(pc.dim('Cancelled.')); return; }
|
|
63
63
|
const again = await p.confirm({ message: 'Add another account?', initialValue: false });
|
|
@@ -68,12 +68,12 @@ async function addAccountAction() {
|
|
|
68
68
|
/** Storage pool sub-menu: list / add / remove / quota. */
|
|
69
69
|
async function accountsMenu() {
|
|
70
70
|
const sub = await p.select({
|
|
71
|
-
message: 'Storage
|
|
71
|
+
message: '🗂 Storage Pool',
|
|
72
72
|
options: [
|
|
73
|
-
{ value: 'list', label: '
|
|
74
|
-
{ value: 'add', label: '➕ Add account
|
|
75
|
-
{ value: 'remove', label: '➖ Remove account from
|
|
76
|
-
{ value: 'quota', label: '📐 Set
|
|
73
|
+
{ value: 'list', label: '📊 Show accounts', hint: 'quotas and usage' },
|
|
74
|
+
{ value: 'add', label: '➕ Add account', hint: 'existing rclone remote' },
|
|
75
|
+
{ value: 'remove', label: '➖ Remove account', hint: 'from pool only' },
|
|
76
|
+
{ value: 'quota', label: '📐 Set quota', hint: 'override account limit' },
|
|
77
77
|
{ value: 'back', label: '← Back' },
|
|
78
78
|
],
|
|
79
79
|
});
|
|
@@ -199,13 +199,13 @@ async function listBackupsAction() {
|
|
|
199
199
|
/** Restore files or a system snapshot. */
|
|
200
200
|
async function restoreMenu() {
|
|
201
201
|
const accs = listAccounts();
|
|
202
|
-
if (!accs.length) { p.log.warn('No accounts configured
|
|
202
|
+
if (!accs.length) { p.log.warn('No cloud accounts configured yet.'); return; }
|
|
203
203
|
const cfg = loadConfig();
|
|
204
204
|
const kind = await p.select({
|
|
205
|
-
message: 'Restore
|
|
205
|
+
message: '♻️ Restore backup',
|
|
206
206
|
options: [
|
|
207
|
-
{ value: 'files', label: '📄
|
|
208
|
-
{ value: 'snapshot', label: '💽 System snapshot
|
|
207
|
+
{ value: 'files', label: '📄 Files', hint: 'recover documents, images, etc.' },
|
|
208
|
+
{ value: 'snapshot', label: '💽 System snapshot', hint: 'full system restore [sudo]' },
|
|
209
209
|
{ value: 'back', label: '← Back' },
|
|
210
210
|
],
|
|
211
211
|
});
|
|
@@ -256,10 +256,10 @@ async function restoreMenu() {
|
|
|
256
256
|
/** Always-on service sub-menu. */
|
|
257
257
|
async function serviceMenu() {
|
|
258
258
|
const sub = await p.select({
|
|
259
|
-
message: '
|
|
259
|
+
message: '⏱ Schedule Service',
|
|
260
260
|
options: [
|
|
261
|
-
{ value: 'install', label: '✅
|
|
262
|
-
{ value: 'remove', label: '❌
|
|
261
|
+
{ value: 'install', label: '✅ Enable', hint: 'auto-backup on schedule' },
|
|
262
|
+
{ value: 'remove', label: '❌ Disable', hint: 'stop auto-backup' },
|
|
263
263
|
{ value: 'back', label: '← Back' },
|
|
264
264
|
],
|
|
265
265
|
});
|
|
@@ -275,12 +275,13 @@ async function serviceMenu() {
|
|
|
275
275
|
|
|
276
276
|
/** Daemon sub-menu. */
|
|
277
277
|
async function daemonMenu() {
|
|
278
|
+
const running = daemonRunning();
|
|
278
279
|
const sub = await p.select({
|
|
279
|
-
message:
|
|
280
|
+
message: `🤖 Daemon ${running ? pc.green('●') : pc.yellow('○')} ${running ? 'running' : 'stopped'}`,
|
|
280
281
|
options: [
|
|
281
|
-
{ value: 'start', label: '▶️ Start
|
|
282
|
-
{ value: 'stop', label: '
|
|
283
|
-
{ value: 'status', label: '📊
|
|
282
|
+
{ value: 'start', label: '▶️ Start' },
|
|
283
|
+
{ value: 'stop', label: '⏹️ Stop' },
|
|
284
|
+
{ value: 'status', label: '📊 Status' },
|
|
284
285
|
{ value: 'back', label: '← Back' },
|
|
285
286
|
],
|
|
286
287
|
});
|
|
@@ -302,7 +303,7 @@ async function daemonMenu() {
|
|
|
302
303
|
* prompt just returns you to this menu.
|
|
303
304
|
*/
|
|
304
305
|
export async function runWizard() {
|
|
305
|
-
p.intro(pc.
|
|
306
|
+
p.intro(`🦜 ${pc.bold('parrot-blackbox')} ${pc.dim(`v${pkg.version}`)}`);
|
|
306
307
|
|
|
307
308
|
if (!process.stdin.isTTY) {
|
|
308
309
|
p.log.warn('No interactive terminal detected — run subcommands directly: `parrot-blackbox help`');
|
|
@@ -317,27 +318,27 @@ export async function runWizard() {
|
|
|
317
318
|
const action = await p.select({
|
|
318
319
|
message: 'What would you like to do?',
|
|
319
320
|
options: [
|
|
320
|
-
{ value: '
|
|
321
|
-
{ value: '
|
|
322
|
-
{ value: '
|
|
323
|
-
{ value: '
|
|
324
|
-
{ value: '
|
|
325
|
-
{ value: '
|
|
326
|
-
{ value: '
|
|
327
|
-
{ value: '
|
|
328
|
-
{ value: '
|
|
329
|
-
{ value: '
|
|
330
|
-
{ value: 'status', label: '📊 Status', hint: 'quick
|
|
321
|
+
{ value: 'snapshot', label: '📸 Create snapshot', hint: 'backup your system now' },
|
|
322
|
+
{ value: 'backup', label: '💾 Run all backups', hint: 'snapshots + file backups' },
|
|
323
|
+
{ value: 'restore', label: '♻️ Restore backup', hint: 'files or system snapshot' },
|
|
324
|
+
{ value: 'list', label: '📋 List backups', hint: 'see what\'s saved' },
|
|
325
|
+
{ value: 'add', label: '☁️ Add cloud account', hint: 'MEGA or Google Drive' },
|
|
326
|
+
{ value: 'accounts', label: '🗂 Manage storage', hint: 'pool, quotas, accounts' },
|
|
327
|
+
{ value: 'setup', label: '🚀 Guided setup', hint: 'first-time configuration' },
|
|
328
|
+
{ value: 'tools', label: '🔧 Check tools', hint: 'install missing dependencies' },
|
|
329
|
+
{ value: 'service', label: '⏱ Schedule service', hint: 'auto-backup setup' },
|
|
330
|
+
{ value: 'daemon', label: '🤖 Daemon control', hint: 'start / stop / status' },
|
|
331
|
+
{ value: 'status', label: '📊 Status', hint: 'quick health check' },
|
|
331
332
|
{ value: 'doctor', label: '🩺 Doctor', hint: 'full diagnostics' },
|
|
332
|
-
{ value: 'repair', label: '
|
|
333
|
-
{ value: 'update', label: '
|
|
334
|
-
{ value: 'uninstall', label: '
|
|
335
|
-
{ value: 'exit', label: '
|
|
333
|
+
{ value: 'repair', label: '🛠️ Repair', hint: 'fix broken installation' },
|
|
334
|
+
{ value: 'update', label: '⬆️ Update', hint: 'check for new version' },
|
|
335
|
+
{ value: 'uninstall', label: '🗑️ Uninstall', hint: 'remove parrot-blackbox' },
|
|
336
|
+
{ value: 'exit', label: '👋 Exit' },
|
|
336
337
|
],
|
|
337
338
|
});
|
|
338
339
|
|
|
339
340
|
if (p.isCancel(action) || action === 'exit') {
|
|
340
|
-
p.outro('
|
|
341
|
+
p.outro('👋 See you later!');
|
|
341
342
|
return;
|
|
342
343
|
}
|
|
343
344
|
|
|
@@ -363,6 +364,6 @@ export async function runWizard() {
|
|
|
363
364
|
} catch (e) {
|
|
364
365
|
p.log.warn(`✖ ${e.message}`);
|
|
365
366
|
}
|
|
366
|
-
p.log.message(
|
|
367
|
+
p.log.message('');
|
|
367
368
|
}
|
|
368
369
|
}
|
package/src/util/network.js
CHANGED
|
@@ -3,24 +3,58 @@
|
|
|
3
3
|
* the daemon observes the laptop coming back online, pending backups run
|
|
4
4
|
* immediately in order.
|
|
5
5
|
*
|
|
6
|
+
* Probes MULTIPLE hosts — if any answers, we are online. On some networks a
|
|
7
|
+
* specific host (e.g. api.mega.nz) fails to resolve even though the internet
|
|
8
|
+
* works, so a single-host probe falsely reports "offline" and defers every
|
|
9
|
+
* backup forever.
|
|
10
|
+
*
|
|
6
11
|
* Overridable in tests via PBB_NETWORK=offline|online.
|
|
7
12
|
*/
|
|
8
13
|
|
|
9
14
|
import { execa } from 'execa';
|
|
10
15
|
import { loadConfig } from '../core/store.js';
|
|
11
16
|
|
|
17
|
+
const FALLBACK_HOSTS = [
|
|
18
|
+
'https://api.github.com',
|
|
19
|
+
'https://www.google.com',
|
|
20
|
+
'https://api.mega.nz',
|
|
21
|
+
];
|
|
22
|
+
|
|
12
23
|
export async function isOnline() {
|
|
13
24
|
if (process.env.PBB_NETWORK === 'offline') return false;
|
|
14
25
|
if (process.env.PBB_NETWORK === 'online') return true;
|
|
15
26
|
|
|
16
27
|
const cfg = loadConfig();
|
|
17
|
-
const
|
|
28
|
+
const hosts = [
|
|
29
|
+
cfg.network.pingHost,
|
|
30
|
+
...(Array.isArray(cfg.network.pingHosts) ? cfg.network.pingHosts : []),
|
|
31
|
+
...FALLBACK_HOSTS,
|
|
32
|
+
].filter(Boolean);
|
|
33
|
+
const unique = [...new Set(hosts)];
|
|
34
|
+
|
|
35
|
+
for (const host of unique) {
|
|
36
|
+
try {
|
|
37
|
+
// `-f`/fail-on-HTTP-error is WRONG here: reachable hosts legitimately
|
|
38
|
+
// return 403/404/5xx (e.g. api.github.com HEAD is frequently 403). Any
|
|
39
|
+
// HTTP response proves DNS+connect+internet — that is all we need.
|
|
40
|
+
const res = await execa('curl', ['-sS', '-o', '/dev/null', '--connect-timeout', '4', '--max-time', '6', host], {
|
|
41
|
+
reject: false,
|
|
42
|
+
});
|
|
43
|
+
if (res.exitCode === 0) return true;
|
|
44
|
+
// Also treat any HTTP response line as online even if curl -sS -o returned
|
|
45
|
+
// non-zero (some proxies set code but curl reports 8/18/52).
|
|
46
|
+
} catch {
|
|
47
|
+
/* try next host */
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
// Last resort: a bare TCP connect to a well-known IP proves L3 connectivity.
|
|
18
51
|
try {
|
|
19
|
-
const res = await execa('curl', ['-
|
|
52
|
+
const res = await execa('curl', ['-sS', '-o', '/dev/null', '--connect-timeout', '3', '--max-time', '5', 'http://1.1.1.1'], {
|
|
20
53
|
reject: false,
|
|
21
54
|
});
|
|
22
|
-
|
|
55
|
+
if (res.exitCode === 0) return true;
|
|
23
56
|
} catch {
|
|
24
|
-
|
|
57
|
+
/* give up */
|
|
25
58
|
}
|
|
59
|
+
return false;
|
|
26
60
|
}
|