parrot-blackbox 1.0.4 → 1.0.6
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 +80 -14
- package/src/commands/manage.js +21 -6
- package/src/commands/service.js +3 -0
- 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.6",
|
|
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';
|
|
@@ -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 } from '../util/sudo.js';
|
|
20
|
+
import { sudoInteractive, sudoNonInteractive, sudoInteractiveCapture } from '../util/sudo.js';
|
|
21
21
|
|
|
22
22
|
export class SudoDeferredError extends Error {
|
|
23
23
|
constructor() {
|
|
@@ -26,22 +26,39 @@ 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 the table format
|
|
30
|
+
* used by Timeshift 22.x/23.x/24.x (leading "Num" column + header rows) AND the
|
|
31
|
+
* older plain format:
|
|
32
|
+
*
|
|
33
|
+
* Next run: daily at 22:00
|
|
34
|
+
* Num Name Tags Description
|
|
35
|
+
* 0 2026-08-31 16:00:01 W 2026-08-31_16-00-01 parrot-blackbox
|
|
36
|
+
*
|
|
30
37
|
* @returns {Array<{name:string, date:string, time:string, tags:string, dir:?string}>}
|
|
31
38
|
*/
|
|
32
39
|
export function parseTimeshiftList(stdout) {
|
|
33
40
|
const out = [];
|
|
34
41
|
for (const line of String(stdout).split('\n')) {
|
|
35
|
-
|
|
42
|
+
// Optional leading Num column; date TIME tags NAME [description…]
|
|
43
|
+
const 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);
|
|
36
44
|
if (!m) continue;
|
|
37
45
|
const [, date, time, tags, dirOrSize, detail] = m;
|
|
38
|
-
|
|
46
|
+
// Prefer the explicit dir-ish token (name contains _HH-MM-SS) over a rebuilt name.
|
|
47
|
+
const name = /^[\w.-]+\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2}$/.test(dirOrSize)
|
|
48
|
+
? dirOrSize
|
|
49
|
+
: `${date}_${time.replace(/:/g, '-')}`;
|
|
39
50
|
const dir = findPathInLine(line);
|
|
40
51
|
out.push({ name, date, time, tags, dir, line: `${date} ${time} ${tags} ${dirOrSize}${detail ? ` ${detail}` : ''}` });
|
|
41
52
|
}
|
|
42
53
|
return out.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
43
54
|
}
|
|
44
55
|
|
|
56
|
+
/** Extract a created snapshot name from `timeshift --create` output. */
|
|
57
|
+
export function extractCreatedName(output) {
|
|
58
|
+
const m = /Created new snapshot[:\s]+([\w.\-]+)/i.exec(String(output || ''));
|
|
59
|
+
return m ? m[1] : null;
|
|
60
|
+
}
|
|
61
|
+
|
|
45
62
|
/** Extract the snapshot directory path from a list line if present. */
|
|
46
63
|
function findPathInLine(line) {
|
|
47
64
|
const dirs = line.match(/\/[^\s]+\/[A-Za-z0-9._-]+/g) || [];
|
|
@@ -50,18 +67,33 @@ function findPathInLine(line) {
|
|
|
50
67
|
null;
|
|
51
68
|
}
|
|
52
69
|
|
|
53
|
-
/**
|
|
54
|
-
|
|
70
|
+
/**
|
|
71
|
+
* `timeshift --list` needs admin on most installs — run it with sudo.
|
|
72
|
+
* Interactive: capture stdout while keeping stdin (so the sudo password
|
|
73
|
+
* prompt works). Non-interactive: `sudo -n`, falling back to a direct call
|
|
74
|
+
* for the few builds that allow unprivileged listing.
|
|
75
|
+
*/
|
|
76
|
+
async function runTimeshiftList({ privileged = 'noninteractive' } = {}) {
|
|
77
|
+
if (privileged === 'interactive') {
|
|
78
|
+
try {
|
|
79
|
+
const res = await sudoInteractiveCapture(['timeshift', '--list']);
|
|
80
|
+
return (res.exitCode === 0 ? res.stdout : res.stdout || res.stderr) || '';
|
|
81
|
+
} catch {
|
|
82
|
+
return '';
|
|
83
|
+
}
|
|
84
|
+
}
|
|
55
85
|
try {
|
|
56
|
-
const res = await
|
|
57
|
-
|
|
86
|
+
const res = await sudoNonInteractive(['timeshift', '--list']);
|
|
87
|
+
if (res.exitCode === 0) return res.stdout || '';
|
|
88
|
+
const direct = await execa('timeshift', ['--list'], { reject: false });
|
|
89
|
+
return (direct.exitCode === 0 ? direct.stdout : direct.stdout || direct.stderr) || '';
|
|
58
90
|
} catch {
|
|
59
91
|
return '';
|
|
60
92
|
}
|
|
61
93
|
}
|
|
62
94
|
|
|
63
95
|
export async function listLocalSnapshots({ privileged = 'noninteractive' } = {}) {
|
|
64
|
-
const text = await runTimeshiftList();
|
|
96
|
+
const text = await runTimeshiftList({ privileged });
|
|
65
97
|
return parseTimeshiftList(text);
|
|
66
98
|
}
|
|
67
99
|
|
|
@@ -70,18 +102,28 @@ export async function createSnapshot({ comment, privileged = 'noninteractive' }
|
|
|
70
102
|
const before = new Set((await listLocalSnapshots({ privileged })).map((s) => s.name));
|
|
71
103
|
const args = ['timeshift', '--create', '--comments', comment || 'parrot-blackbox', '--tags', 'W'];
|
|
72
104
|
|
|
105
|
+
let createOut = '';
|
|
73
106
|
if (privileged === 'interactive') {
|
|
74
|
-
const res = await
|
|
107
|
+
const res = await sudoInteractiveCapture(args);
|
|
75
108
|
if (res.exitCode !== 0) throw new Error(`timeshift --create failed (exit ${res.exitCode})`);
|
|
109
|
+
createOut = `${res.stdout || ''} ${res.stderr || ''}`;
|
|
76
110
|
} else {
|
|
77
111
|
const res = await sudoNonInteractive(args);
|
|
78
112
|
if (res.exitCode !== 0) {
|
|
79
113
|
if (/password|authentication|sudo/i.test(res.stderr || '')) throw new SudoDeferredError();
|
|
80
114
|
throw new Error(`timeshift --create failed (exit ${res.exitCode}): ${res.stderr?.trim()}`);
|
|
81
115
|
}
|
|
116
|
+
createOut = `${res.stdout || ''} ${res.stderr || ''}`;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Primary: read the exact name from timeshift's own output
|
|
120
|
+
// ("Created new snapshot: 2026-08-31_16-00-01") — the most reliable signal.
|
|
121
|
+
const createdName = extractCreatedName(createOut);
|
|
122
|
+
if (createdName) {
|
|
123
|
+
return { name: createdName, date: createdName.slice(0, 10), time: createdName.slice(11).replace(/-/g, ':'), tags: 'W', dir: null, line: createOut.trim() };
|
|
82
124
|
}
|
|
83
125
|
|
|
84
|
-
//
|
|
126
|
+
// Fallback: diff the list before/after.
|
|
85
127
|
const after = await listLocalSnapshots({ privileged });
|
|
86
128
|
const created = after.find((s) => !before.has(s.name)) || after[after.length - 1];
|
|
87
129
|
if (!created) throw new Error('timeshift reported success but no snapshot was found');
|
|
@@ -103,15 +145,39 @@ export async function deleteSnapshot(name, { privileged = 'noninteractive' } = {
|
|
|
103
145
|
}
|
|
104
146
|
return true;
|
|
105
147
|
}
|
|
106
|
-
/**
|
|
148
|
+
/**
|
|
149
|
+
* Resolve the on-disk directory of a snapshot.
|
|
150
|
+
* BTRFS mode keeps snapshots in a hidden subvolume that Timeshift mounts at
|
|
151
|
+
* /run/timeshift/backup (e.g. .../timeshift-btrfs/snapshots/<name>) — check the
|
|
152
|
+
* classic locations first, then search the mounted backup tree.
|
|
153
|
+
*/
|
|
107
154
|
export function snapshotDirFor(snapshot, { privileged = 'noninteractive' } = {}) {
|
|
108
155
|
if (snapshot.dir && fs.existsSync(snapshot.dir)) return snapshot.dir;
|
|
109
156
|
const base = timeshiftDir();
|
|
110
157
|
const candidates = [
|
|
111
158
|
path.join(base, 'snapshots', snapshot.name),
|
|
112
159
|
path.join(base, snapshot.name),
|
|
160
|
+
`/run/timeshift/backup/${snapshot.name}`,
|
|
161
|
+
`/run/timeshift/backup/timeshift-btrfs/snapshots/${snapshot.name}`,
|
|
162
|
+
`/run/timeshift/backup/@/timeshift-btrfs/snapshots/${snapshot.name}`,
|
|
113
163
|
];
|
|
114
|
-
|
|
164
|
+
const direct = candidates.find((c) => fs.existsSync(c));
|
|
165
|
+
if (direct) return direct;
|
|
166
|
+
|
|
167
|
+
// Best effort: search the mounted Timeshift backup tree for the snapshot dir.
|
|
168
|
+
try {
|
|
169
|
+
if (fs.existsSync('/run/timeshift/backup')) {
|
|
170
|
+
const found = execaSync(
|
|
171
|
+
'bash',
|
|
172
|
+
['-c', `find /run/timeshift/backup -maxdepth 5 -type d -name '${snapshot.name}' 2>/dev/null | head -1`],
|
|
173
|
+
{ reject: false, timeout: 5000 },
|
|
174
|
+
).stdout.trim();
|
|
175
|
+
if (found) return found;
|
|
176
|
+
}
|
|
177
|
+
} catch {
|
|
178
|
+
/* fall through */
|
|
179
|
+
}
|
|
180
|
+
return candidates[0];
|
|
115
181
|
}
|
|
116
182
|
|
|
117
183
|
/**
|
package/src/commands/manage.js
CHANGED
|
@@ -185,14 +185,29 @@ export async function runRepair({ auto = false } = {}) {
|
|
|
185
185
|
// 3. Service
|
|
186
186
|
try {
|
|
187
187
|
const { serviceFile, daemonLogFile } = await import('../core/paths.js');
|
|
188
|
-
const { serviceBackend } = await import('./service.js');
|
|
188
|
+
const { serviceBackend, installService } = await import('./service.js');
|
|
189
189
|
const fsMod = await import('node:fs');
|
|
190
|
-
if (serviceBackend() === 'systemd'
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
190
|
+
if (serviceBackend() === 'systemd') {
|
|
191
|
+
// The ExecStart line must reference a REAL absolute path; the v1.0.4 unit
|
|
192
|
+
// wrote `node parrot-blackbox daemon foreground` (bare name) which crashed
|
|
193
|
+
// with "Cannot find module '/home/artkins/parrot-blackbox'".
|
|
194
|
+
let unitOk = false;
|
|
195
|
+
try {
|
|
196
|
+
const unit = fsMod.readFileSync(serviceFile(), 'utf8');
|
|
197
|
+
const execLine = unit.split('\n').find((l) => l.startsWith('ExecStart=')) || '';
|
|
198
|
+
unitOk = fsMod.existsSync(serviceFile()) && /ExecStart=\S+/.test(execLine) && /\//.test(execLine);
|
|
199
|
+
} catch {
|
|
200
|
+
unitOk = false;
|
|
201
|
+
}
|
|
202
|
+
if (!unitOk) {
|
|
203
|
+
const backend = await installService();
|
|
204
|
+
p.log.success(`Always-on service re-written via ${backend}.`);
|
|
205
|
+
fixed.push('service');
|
|
206
|
+
} else {
|
|
207
|
+
p.log.success('Service OK.');
|
|
208
|
+
}
|
|
194
209
|
} else {
|
|
195
|
-
p.log.success('Service OK.');
|
|
210
|
+
p.log.success('Service backend OK.');
|
|
196
211
|
}
|
|
197
212
|
} catch (e) {
|
|
198
213
|
p.log.warn(`Service check failed: ${e.message}`);
|
package/src/commands/service.js
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
import fs from 'node:fs';
|
|
9
9
|
import path from 'node:path';
|
|
10
|
+
import { fileURLToPath } from 'node:url';
|
|
10
11
|
import { execa } from 'execa';
|
|
11
12
|
import { serviceFile, daemonLogFile } from '../core/paths.js';
|
|
12
13
|
import { loadConfig, journal, hasCommandSync } from '../core/store.js';
|
|
@@ -15,6 +16,8 @@ function findCliBin() {
|
|
|
15
16
|
// npm-installed global binary (preferred) or our own bin entry.
|
|
16
17
|
const candidates = [
|
|
17
18
|
process.env.PBB_BIN,
|
|
19
|
+
// This module lives in src/commands/ → ../../bin/parrot-blackbox.js
|
|
20
|
+
path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..', 'bin', 'parrot-blackbox.js'),
|
|
18
21
|
path.join(path.dirname(process.argv[1] || ''), 'parrot-blackbox.js'),
|
|
19
22
|
];
|
|
20
23
|
return candidates.find((c) => c && fs.existsSync(c)) || 'parrot-blackbox';
|
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
|
}
|