parrot-blackbox 1.0.0

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.
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Minimal rclone wrapper. Deliberately thin: every call goes through the
3
+ * `rclone` binary (works for MEGA and Google Drive alike, and is trivially
4
+ * stubbed inside the sandbox tests).
5
+ */
6
+
7
+ import { execa, execaSync } from 'execa';
8
+ import { parseBytes } from '../util/misc.js';
9
+
10
+ const RCLONE = process.env.PBB_RCLONE || 'rclone';
11
+
12
+ function rejectFalse(args, opts = {}) {
13
+ return execa(RCLONE, args, { reject: false, ...opts });
14
+ }
15
+
16
+ export async function hasRclone() {
17
+ try {
18
+ const res = await execa('bash', ['-c', `command -v ${RCLONE}`], { reject: false });
19
+ return Boolean(res.stdout.trim());
20
+ } catch {
21
+ return false;
22
+ }
23
+ }
24
+
25
+ /** `rclone about remote:` → {total, used, free} bytes (nulls when unknown). */
26
+ export async function aboutRemote(remote) {
27
+ const res = await rejectFalse(['about', ...remote.split(' '), '--json']);
28
+ if (res.exitCode !== 0) {
29
+ return { total: null, used: null, free: null, error: res.stderr?.trim() || `exit ${res.exitCode}` };
30
+ }
31
+ try {
32
+ const j = JSON.parse(res.stdout);
33
+ const toNum = (v) => (typeof v === 'number' ? v : parseBytes(v));
34
+ return { total: toNum(j.total), used: toNum(j.used), free: toNum(j.free) };
35
+ } catch {
36
+ return { total: null, used: null, free: null, error: 'unparsable about output' };
37
+ }
38
+ }
39
+
40
+ /** List remote names. */
41
+ export async function listRemotes() {
42
+ const res = await rejectFalse(['listremotes']);
43
+ return res.stdout.split('\n').map((l) => l.trim().replace(/:$/, '')).filter(Boolean);
44
+ }
45
+
46
+ /** Recursive JSON listing of a remote path. */
47
+ export async function lsjson(remotePath, { recursive = true } = {}) {
48
+ const args = ['lsjson', remotePath];
49
+ if (recursive) args.push('--recursive');
50
+ args.push('--json');
51
+ const res = await rejectFalse(args);
52
+ if (res.exitCode !== 0) return { ok: false, entries: [], error: res.stderr?.trim() };
53
+ try {
54
+ return { ok: true, entries: JSON.parse(res.stdout), error: null };
55
+ } catch {
56
+ return { ok: false, entries: [], error: 'unparsable lsjson output' };
57
+ }
58
+ }
59
+
60
+ /** Copy a local dir tree into `remote:path` (path created implicitly). */
61
+ export async function copyDir(localDir, remotePath) {
62
+ const res = await rejectFalse(['copy', localDir, remotePath]);
63
+ return { ok: res.exitCode === 0, exitCode: res.exitCode, error: res.stderr?.trim() };
64
+ }
65
+
66
+ /** Copy a single local file to an exact remote path. */
67
+ export async function copyToFile(localFile, remotePath, { ignoreExisting = false } = {}) {
68
+ const args = ['copyto', localFile, remotePath];
69
+ if (ignoreExisting) args.push('--ignore-existing');
70
+ const res = await rejectFalse(args);
71
+ return { ok: res.exitCode === 0, exitCode: res.exitCode, error: res.stderr?.trim() };
72
+ }
73
+
74
+ /** Create a remote directory. */
75
+ export async function mkdirRemote(remotePath) {
76
+ const res = await rejectFalse(['mkdir', remotePath]);
77
+ return { ok: res.exitCode === 0, exitCode: res.exitCode, error: res.stderr?.trim() };
78
+ }
79
+
80
+ /** Permanently delete a remote path. */
81
+ export async function purge(remotePath) {
82
+ const res = await rejectFalse(['purge', remotePath]);
83
+ return { ok: res.exitCode === 0, exitCode: res.exitCode, error: res.stderr?.trim() };
84
+ }
85
+
86
+ /** Fetch a small remote file's contents. */
87
+ export async function catRemote(remotePath) {
88
+ const res = await rejectFalse(['cat', remotePath]);
89
+ return { ok: res.exitCode === 0, stdout: res.stdout, error: res.stderr?.trim() };
90
+ }
91
+
92
+ /** Number of files (recursive) under a remote path; -1 on error. */
93
+ export async function remoteFileCount(remotePath) {
94
+ const res = await lsjson(remotePath, { recursive: true });
95
+ if (!res.ok) return -1;
96
+ return res.entries.filter((e) => !e.IsDir).length;
97
+ }
98
+
99
+ export function rcloneVersion() {
100
+ try {
101
+ const res = execaSync(RCLONE, ['version'], { reject: false });
102
+ return (res.stdout || res.stderr || '').trim();
103
+ } catch {
104
+ return null;
105
+ }
106
+ }
@@ -0,0 +1,114 @@
1
+ /** Small shared utilities: deep merge, sizes, glob-to-regexp, hashing. */
2
+
3
+ import crypto from 'node:crypto';
4
+ import fs from 'node:fs';
5
+ import path from 'node:path';
6
+
7
+ /** Recursive deep merge (later objects win). Arrays are replaced wholesale. */
8
+ export default function deepMerge(base, override) {
9
+ if (Array.isArray(base) || Array.isArray(override)) {
10
+ return override === undefined ? base : override;
11
+ }
12
+ if (isPlainObj(base) && isPlainObj(override)) {
13
+ const out = { ...base };
14
+ for (const key of Object.keys(override)) {
15
+ out[key] = deepMerge(base[key], override[key]);
16
+ }
17
+ return out;
18
+ }
19
+ return override === undefined ? base : override;
20
+ }
21
+
22
+ function isPlainObj(v) {
23
+ return v !== null && typeof v === 'object' && !Array.isArray(v);
24
+ }
25
+
26
+ export function bytesHuman(n) {
27
+ if (n === null || n === undefined) return '?';
28
+ const units = ['B', 'KiB', 'MiB', 'GiB', 'TiB'];
29
+ let v = Number(n);
30
+ let i = 0;
31
+ while (v >= 1024 && i < units.length - 1) {
32
+ v /= 1024;
33
+ i += 1;
34
+ }
35
+ return `${v.toFixed(v >= 10 || i === 0 ? 0 : 1)} ${units[i]}`;
36
+ }
37
+
38
+ /** Parse `1.5 GiB`, `2048`, `3.2G` → integer bytes. */
39
+ export function parseBytes(str) {
40
+ if (typeof str === 'number') return str;
41
+ const m = /^\s*([\d.]+)\s*([kmgt]?i?b?)\s*$/i.exec(String(str ?? ''));
42
+ if (!m) return null;
43
+ const mult = { b: 1, kb: 1024, kib: 1024, mb: 1024 ** 2, mib: 1024 ** 2, gb: 1024 ** 3, gib: 1024 ** 3, tb: 1024 ** 4, tib: 1024 ** 4, k: 1024, m: 1024 ** 2, g: 1024 ** 3, t: 1024 ** 4 }[m[2].toLowerCase()] ?? 1;
44
+ return Math.round(parseFloat(m[1]) * mult);
45
+ }
46
+
47
+ /** sha256 of a file. */
48
+ export async function sha256File(file) {
49
+ return new Promise((resolve, reject) => {
50
+ const h = crypto.createHash('sha256');
51
+ const s = fs.createReadStream(file);
52
+ s.on('error', reject);
53
+ s.on('data', (c) => h.update(c));
54
+ s.on('end', () => resolve(h.digest('hex')));
55
+ });
56
+ }
57
+
58
+ /** Translate a small glob subset to RegExp. Supports **, *, ?, {a,b}. */
59
+ export function globToRegExp(pattern) {
60
+ let src = String(pattern).replace(/[.+^${}()|[\]\\]/g, '\\$&');
61
+ src = src.replace(/\*\*/g, '@@DOUBLESTAR@@');
62
+ src = src.replace(/\*/g, '[^/]*');
63
+ src = src.replace(/\?/g, '[^/]');
64
+ src = src.replace(/@@DOUBLESTAR@@/g, '.*');
65
+ src = src.replace(/{([^}]+)}/g, (_, body) => `(${body.split(',').map((x) => x.trim().replace(/[.+^${}()|[\]\\]/g, '\\$&')).join('|')})`);
66
+ return new RegExp(`^${src}$`);
67
+ }
68
+
69
+ /** Does POSIX-ish relative path match any pattern? */
70
+ export function matchesAny(relPath, patterns) {
71
+ if (!patterns || patterns.length === 0) return false;
72
+ const p = relPath.replace(/\\/g, '/');
73
+ return patterns.some((pat) => {
74
+ const rx = globToRegExp(String(pat));
75
+ return rx.test(p) || rx.test(p + '/');
76
+ });
77
+ }
78
+
79
+ /** Recursive byte size of a directory (breaks symlink loops). */
80
+ export function dirSize(dir) {
81
+ const seen = new Set();
82
+ function walk(d) {
83
+ let total = 0;
84
+ for (const ent of fs.readdirSync(d, { withFileTypes: true })) {
85
+ const abs = path.join(d, ent.name);
86
+ let real;
87
+ try {
88
+ real = fs.realpathSync(abs);
89
+ } catch {
90
+ continue;
91
+ }
92
+ if (seen.has(real)) continue;
93
+ seen.add(real);
94
+ try {
95
+ if (ent.isSymbolicLink()) continue;
96
+ if (ent.isDirectory()) total += walk(abs);
97
+ else total += fs.statSync(abs).size;
98
+ } catch {
99
+ /* skip unreadable */
100
+ }
101
+ }
102
+ return total;
103
+ }
104
+ return walk(dir);
105
+ }
106
+
107
+ /** Safe backtick shell-escape for a single path. */
108
+ export function shellQuote(s) {
109
+ return `'${String(s).replace(/'/g, `'\\''`)}'`;
110
+ }
111
+
112
+ export function pad2(n) {
113
+ return String(n).padStart(2, '0');
114
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Connectivity check. The offline/online edge is what triggers catch-up: when
3
+ * the daemon observes the laptop coming back online, pending backups run
4
+ * immediately in order.
5
+ *
6
+ * Overridable in tests via PBB_NETWORK=offline|online.
7
+ */
8
+
9
+ import { execa } from 'execa';
10
+ import { loadConfig } from '../core/store.js';
11
+
12
+ export async function isOnline() {
13
+ if (process.env.PBB_NETWORK === 'offline') return false;
14
+ if (process.env.PBB_NETWORK === 'online') return true;
15
+
16
+ const cfg = loadConfig();
17
+ const host = cfg.network.pingHost || 'https://api.mega.nz';
18
+ try {
19
+ const res = await execa('curl', ['-fsSI', '--connect-timeout', '5', '--max-time', '10', host], {
20
+ reject: false,
21
+ });
22
+ return res.exitCode === 0;
23
+ } catch {
24
+ return false;
25
+ }
26
+ }
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Privileged execution.
3
+ *
4
+ * - `sudoInteractive` — runs `sudo <args>` with the terminal inherited so the
5
+ * password prompt is visible and Ctrl+C works (the gitswitch/theamify/
6
+ * warp-wizard pattern: callers stop any spinner FIRST).
7
+ * - `sudoNonInteractive` — runs `sudo -n <args>`; used by the background daemon
8
+ * so it can *never* hang waiting on a password. When the sudo timestamp has
9
+ * lapsed the job is deferred and retried later; a single interactive
10
+ * `parrot-blackbox snapshot now` (or any sudo use on the box) re-arms it.
11
+ *
12
+ * PBB_SUDO_DIRECT=1 (used by unit tests) bypasses sudo entirely.
13
+ */
14
+
15
+ import { execa } from 'execa';
16
+
17
+ function sudoPrefix() {
18
+ return process.env.PBB_SUDO_DIRECT === '1' ? [] : ['sudo'];
19
+ }
20
+
21
+ /** Interactive sudo baseline for injecting PBB_SUDO_DIRECT consistent with the rest. */
22
+ export async function sudoInteractive(args, { timeout = 0 } = {}) {
23
+ const full = [...sudoPrefix(), ...args];
24
+ const res = await execa(full[0], full.slice(1), { stdio: 'inherit', reject: false, timeout });
25
+ if (res.exitCode !== 0) {
26
+ throw new Error(`elevated command failed: ${args.join(' ')} (exit ${res.exitCode})`);
27
+ }
28
+ return res;
29
+ }
30
+
31
+ /**
32
+ * Interactive sudo that CAPTURES stdout (needed for `timeshift --list` to feed
33
+ * the parser) while keeping stdin inherited so the password prompt stays
34
+ * usable — like the real sudo, which reads passwords from /dev/tty.
35
+ */
36
+ export async function sudoInteractiveCapture(args, { timeout = 0 } = {}) {
37
+ const full = [...sudoPrefix(), ...args];
38
+ const res = await execa(full[0], full.slice(1), { stdin: 'inherit', reject: false, timeout });
39
+ if (res.exitCode !== 0) {
40
+ throw new Error(`elevated command failed: ${args.join(' ')} (exit ${res.exitCode})`);
41
+ }
42
+ return res;
43
+ }
44
+
45
+ /** Non-interactive sudo (daemon-safe). Returns execa result, never hangs. */
46
+ export async function sudoNonInteractive(args) {
47
+ const full = process.env.PBB_SUDO_DIRECT === '1' ? args : ['sudo', '-n', ...args];
48
+ const res = await execa(full[0], full.slice(1), { reject: false });
49
+ return res;
50
+ }
51
+
52
+ /** Can the current process currently run privileged commands without a prompt? */
53
+ export async function sudoAvailable() {
54
+ const res = await sudoNonInteractive(['true']);
55
+ return res.exitCode === 0;
56
+ }