parrot-blackbox 1.0.5 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "parrot-blackbox",
3
- "version": "1.0.5",
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",
@@ -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,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
- const m = /^(\d{4}-\d{2}-\d{2})\s+(\d{2}:\d{2}:\d{2})\s+([A-Z])\s+(\S+)(?:\s+(.*))?$/.exec(line);
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
- const name = `${date}_${time.replace(/:/g, '-')}`;
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) || [];
@@ -85,18 +102,28 @@ export async function createSnapshot({ comment, privileged = 'noninteractive' }
85
102
  const before = new Set((await listLocalSnapshots({ privileged })).map((s) => s.name));
86
103
  const args = ['timeshift', '--create', '--comments', comment || 'parrot-blackbox', '--tags', 'W'];
87
104
 
105
+ let createOut = '';
88
106
  if (privileged === 'interactive') {
89
- const res = await sudoInteractive(args);
107
+ const res = await sudoInteractiveCapture(args);
90
108
  if (res.exitCode !== 0) throw new Error(`timeshift --create failed (exit ${res.exitCode})`);
109
+ createOut = `${res.stdout || ''} ${res.stderr || ''}`;
91
110
  } else {
92
111
  const res = await sudoNonInteractive(args);
93
112
  if (res.exitCode !== 0) {
94
113
  if (/password|authentication|sudo/i.test(res.stderr || '')) throw new SudoDeferredError();
95
114
  throw new Error(`timeshift --create failed (exit ${res.exitCode}): ${res.stderr?.trim()}`);
96
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() };
97
124
  }
98
125
 
99
- // Find which snapshot appeared.
126
+ // Fallback: diff the list before/after.
100
127
  const after = await listLocalSnapshots({ privileged });
101
128
  const created = after.find((s) => !before.has(s.name)) || after[after.length - 1];
102
129
  if (!created) throw new Error('timeshift reported success but no snapshot was found');
@@ -118,15 +145,39 @@ export async function deleteSnapshot(name, { privileged = 'noninteractive' } = {
118
145
  }
119
146
  return true;
120
147
  }
121
- /** Resolve the on-disk directory of a snapshot. */
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
+ */
122
154
  export function snapshotDirFor(snapshot, { privileged = 'noninteractive' } = {}) {
123
155
  if (snapshot.dir && fs.existsSync(snapshot.dir)) return snapshot.dir;
124
156
  const base = timeshiftDir();
125
157
  const candidates = [
126
158
  path.join(base, 'snapshots', snapshot.name),
127
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}`,
128
163
  ];
129
- return candidates.find((c) => fs.existsSync(c)) || candidates[0];
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];
130
181
  }
131
182
 
132
183
  /**
@@ -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 host = cfg.network.pingHost || 'https://api.mega.nz';
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', ['-fsSI', '--connect-timeout', '5', '--max-time', '10', host], {
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
- return res.exitCode === 0;
55
+ if (res.exitCode === 0) return true;
23
56
  } catch {
24
- return false;
57
+ /* give up */
25
58
  }
59
+ return false;
26
60
  }