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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 DonArtkins
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,260 @@
1
+ # parrot-blackbox πŸ¦œπŸ“¦
2
+
3
+ **Crash-proof, multi-cloud backup & recovery automation for Parrot OS.**
4
+
5
+ `parrot-blackbox` is the "black box flight recorder you lost last time your
6
+ Parrot install died" β€” a single CLI + background daemon that:
7
+
8
+ 1. **Automates backups** β€” a full **Timeshift system snapshot** every
9
+ **Saturday at 22:00** (the weekly snapshot IS the backup β€” no noisy daily
10
+ file backups, so storage stays lean).
11
+ 2. **Installs whatever your system needs** β€” running the wizard checks for
12
+ `rclone`, `timeshift`, `git` and `curl` and **auto-installs the missing
13
+ ones** (sudo prompt, exactly like theamify) so snapshot backup AND restore
14
+ always work on a fresh Parrot.
15
+ 3. **Is crash-proof** β€” if the laptop is off or offline at backup time, the
16
+ missed backups are run **in order, oldest first, the moment WiFi is back**.
17
+ Every job is journalled and retried; a lock prevents collisions; state is
18
+ written atomically. Nothing is silently lost.
19
+ 4. **Manages ~150 GB of free cloud storage for you** β€” 5 MEGA + 5 Google Drive
20
+ accounts are connected as one pool. Files are placed whole on whichever
21
+ account has the most relative headroom; anything bigger than a single
22
+ account's free space is split into byte-range chunks across accounts and a
23
+ manifest remembers how to reassemble it. You can never be "out of space".
24
+ 5. **Keeps the disk honest** β€” old snapshots (keep the latest 3, the middle one
25
+ as the sanity safety-net) are pruned **both locally and in the cloud in the
26
+ same pass**. Daily file backups are available as an **opt-in** if you ever
27
+ want them.
28
+ 6. **Brings you back from a fresh install** β€” restore a snapshot from the cloud
29
+ onto fresh Parrot (works whether or not you used disk encryption; it just
30
+ needs your `sudo` password, exactly like gitswitch/theamify).
31
+
32
+ ---
33
+
34
+ ## Why this exists
35
+
36
+ From `research.txt`: a Parrot OS crash once wiped every local file, every
37
+ customization, and the VS Codium profile. Only GitHub was safe. Timeshift
38
+ snapshots existed β€” but they lived on the *same* disk, so a dead SSD took them
39
+ too. This tool automates the fix:
40
+
41
+ - Timeshift snapshots are created **and uploaded to the cloud pool** weekly.
42
+ - File backups live on MEGA + Drive only.
43
+ - The only recovery-critical local thing left is the CLI itself (`npx` is a
44
+ clone away).
45
+
46
+ ---
47
+
48
+ ## Requirements
49
+
50
+ | Tool | Why |
51
+ |---|---|
52
+ | Node.js β‰₯ 18 | runs the CLI |
53
+ | `rclone` | talks to MEGA and Google Drive (one rclone remote = one account) |
54
+ | `timeshift` | system snapshots (BTRFS mode; rsync mode also works) |
55
+ | `git` | detecting repos so their files are excluded |
56
+ | `curl` | connectivity checks |
57
+
58
+ ```bash
59
+ sudo apt install rclone timeshift git curl
60
+ ```
61
+
62
+ ---
63
+
64
+ ## Install
65
+
66
+ ```bash
67
+ npm install -g parrot-blackbox
68
+ ```
69
+
70
+ Run the setup wizard:
71
+
72
+ ```bash
73
+ parrot-blackbox
74
+ ```
75
+
76
+ **The wizard first checks your system and auto-installs anything needed** for
77
+ snapshot backup + restore (`rclone`, `timeshift`, `git`, `curl` β€” it detects
78
+ your package manager and runs the install with an interactive sudo prompt,
79
+ the gitswitch/theamify way). Then it walks you through: authorizing your
80
+ MEGA / Drive accounts in `rclone config` β†’ registering each remote as an
81
+ account β†’ installing the always-on service β†’ an optional first snapshot.
82
+
83
+ ---
84
+
85
+ ## Quick start (the important bit)
86
+
87
+ ```bash
88
+ # 1. Install, then run the wizard (installs missing tools for you):
89
+ npm install -g parrot-blackbox
90
+ parrot-blackbox
91
+
92
+ # 2. Create your rclone remotes (one per MEGA / Drive account) if not done:
93
+ rclone config
94
+
95
+ # 3. Add them to the pool (repeat for every account):
96
+ parrot-blackbox account add mega mega-account-1
97
+ parrot-blackbox account add gdrive my-drive-1
98
+ parrot-blackbox account list # see the whole pool + quota
99
+
100
+ # 4. Force your FIRST snapshot backup right now (do this before a fresh
101
+ # install β€” it captures the whole system and uploads it to the cloud):
102
+ parrot-blackbox force
103
+ ```
104
+
105
+ `parrot-blackbox status` shows the pool, network state, daemon state, last run
106
+ ---
107
+
108
+ ## Commands
109
+
110
+ | Command | What it does |
111
+ |---|---|
112
+ | `parrot-blackbox` | Interactive setup wizard (checks & **auto-installs** needed tools) |
113
+ | `parrot-blackbox run` | Run any due/pending backups now (safe for cron) |
114
+ | `parrot-blackbox force` | ⭐ Run every enabled backup NOW (default = weekly snapshot) `[sudo]` |
115
+ | `parrot-blackbox snapshot now` | Create + upload a Weekly Timeshift snapshot `[sudo]` |
116
+ | `parrot-blackbox snapshot list` | Local & cloud snapshots |
117
+ | `parrot-blackbox snapshot prune` | Delete snapshots past the keep limit `[sudo]` |
118
+ | `parrot-blackbox list` | List cloud file backups |
119
+ | `parrot-blackbox restore` | Interactive restore wizard `[sudo]` |
120
+ | `parrot-blackbox restore files <id> <dir>` | Recover a file backup into a folder |
121
+ | `parrot-blackbox restore snapshot <id> --yes` | Overwrite the whole system from a cloud snapshot `[sudo]` |
122
+ | `parrot-blackbox account add <mega\|gdrive> <remote>` | Add an account to the pool |
123
+ | `parrot-blackbox account list` | Pool summary + per-account quota |
124
+ | `parrot-blackbox account remove <id>` | Remove an account |
125
+ | `parrot-blackbox account quota <id> <GiB>` | Override an account's quota |
126
+ | `parrot-blackbox daemon start\|stop\|status` | Background automation |
127
+ | `parrot-blackbox schedule install\|remove` | systemd / cron always-on setup |
128
+ | `parrot-blackbox doctor` | Full diagnostics |
129
+ | `parrot-blackbox status` | Quick status |
130
+ | `parrot-blackbox uninstall` | Remove everything (cloud backups kept) |
131
+
132
+ ---
133
+
134
+ ## How the smart storage pool works
135
+
136
+ ```
137
+ 5 MEGA (20 GiB each)
138
+ 5 Drive (10 GiB each)
139
+ ──────────────────────────────
140
+ β‰ˆ 150 GiB managed automatically
141
+ ```
142
+
143
+ - Every account is exactly one rclone remote (`mega-1:`, `drive-2:` …).
144
+ - Each backup has **one manifest** that records where every file (or chunk) is.
145
+ - Placement picks the account that would end up with the **lowest used
146
+ percentage** (water-filling), then most free.
147
+ - A file that doesn't fit any single account is split at `chunkSize` boundaries
148
+ and the pieces are spread across accounts β€” restore reassembles byte-perfect.
149
+ - The manifest is written to the cloud **and** mirrored locally, so a wiped
150
+ machine can still find and restore everything.
151
+
152
+ Never out of storage again β€” and if you ever pass 175 GiB of backups, the
153
+ allocator fails loudly with guidance instead of half-uploading.
154
+
155
+ ---
156
+
157
+ ## Retention (the "never out of disk / cloud" guarantees)
158
+
159
+ | What | Default | Result |
160
+ |---|---|---|
161
+ | Snapshots (Timeshift) | enabled, keep the newest **3** | latest + 2 previous weeks; the middle one is the sanity net |
162
+ | File backups | **disabled** (opt-in), keep newest 3 if enabled | no noisy daily uploads unless you want them |
163
+ | Pruning | happens for **local AND cloud in the same pass** | a fresh install can't resurrect old clutter |
164
+
165
+ The snapshot keep strategy: with `keep: 3` you always have *today's* snapshot
166
+ plus two earlier ones β€” so a crash mid-backup can still roll back to the most
167
+ recent working state, without wasting cloud or disk space. If you want the most
168
+ space-efficient option, set `keep: 1` and every successful backup erases the
169
+ previous one, leaving a single sane restore point:
170
+
171
+ ```
172
+ $EDITOR ~/.config/parrot-blackbox/config.json # jobs.snapshots.keep = 1
173
+ ```
174
+
175
+ To ALSO run a daily file-level backup (opt-in), flip it on in the same file:
176
+
177
+ ```
178
+ $EDITOR ~/.config/parrot-blackbox/config.json # jobs.files.enabled = true
179
+ ```
180
+ ---
181
+
182
+ ## Recovery guide (fresh install / new machine)
183
+
184
+ ### Option A β€” restore your files (fonts, images, docs…)
185
+
186
+ ```bash
187
+ parrot-blackbox restore files <backup-id> ~/recovered
188
+ ```
189
+
190
+ ### Option B β€” restore the whole system snapshot (recommended)
191
+
192
+ ```bash
193
+ # on the fresh Parrot:
194
+ npm install -g parrot-blackbox
195
+ rclone config # re-add the same remotes
196
+ parrot-blackbox account add mega mega-1 # …add every account
197
+ parrot-blackbox snapshot list # see what's in the cloud
198
+ parrot-blackbox restore snapshot <snapshot-id> --yes
199
+ ```
200
+
201
+ This downloads the snapshot into Timeshift, then runs the interactive restore
202
+ (expect a `sudo` password prompt) β€” it **overwrites the fresh install's system
203
+ root** with the snapshot. After a reboot you're back to your old system; then
204
+ `sudo apt update && sudo apt upgrade` to move onto a newer Parrot release if
205
+ you installed one. Encryption or not makes no difference β€” snapshot restore is
206
+ run from inside the OS.
207
+
208
+ ---
209
+
210
+ ## Automation details (what "crash-proof" actually means)
211
+
212
+ - **Scheduling** β€” file backups daily 22:00, snapshots every Saturday 22:00
213
+ (a recent Friday-through-Sunday window is hidden from storage by keeping
214
+ only 3 generations). Both schedules are configurable.
215
+ - **Catch-up** β€” the daemon polls every 60s. On each tick it computes *every*
216
+ calendar due that has passed since the last one it considered, drops the
217
+ ancient backlog beyond `catchUpLimit`, and drains the rest **oldest first**.
218
+ If it's offline at that moment, the dues stay pending and the daemon watches
219
+ for the offline→online edge to fire immediately.
220
+ - **Crash-proofing** β€” state is written atomically (tmp + rename); the journal
221
+ appends one line per event; every job opens a journal entry and only closes
222
+ it on success; a single process lock (`withLock`) keeps the daemon and a
223
+ manual `force` from colliding; stale locks (dead pid / expired TTL) are
224
+ reclaimed automatically.
225
+ - **Non-interactive sudo** β€” the daemon never hangs on a password: it uses
226
+ `sudo -n`, and if the sudo timestamp is lapsed it marks the snapshot job
227
+ `deferred` and retries on the next tick. Run any interactive command (e.g.
228
+ `parrot-blackbox snapshot now`) once to re-arm sudo.
229
+
230
+ ---
231
+
232
+ ## Uninstall
233
+
234
+ ```bash
235
+ parrot-blackbox uninstall
236
+ ```
237
+
238
+ Stops the daemon, drops the systemd/cron schedule, deletes local config/state
239
+ and the npm package. **Cloud backups are never touched.**
240
+
241
+ ---
242
+
243
+ ## Development
244
+
245
+ ```bash
246
+ npm install
247
+ npm test # unit tests + sandbox e2e tests (real CLI, fake cloud)
248
+ npm run publish:dry-run # inspect the tarball
249
+ npm run release:patch # test β†’ version bump β†’ git push --follow-tags
250
+ npm publish # to the public npm registry
251
+ ```
252
+
253
+ The e2e suite (Level-5 safety proof) runs the **real CLI** against **stub**
254
+ `rclone`/`timeshift`/`sudo` binaries and a **fake cloud** β€” it never touches a
255
+ real disk, real account or real network.
256
+
257
+ ## License
258
+
259
+ MIT
260
+ and pending backups. `parrot-blackbox doctor` is the full diagnostic.
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ import '../src/cli.js';
package/package.json ADDED
@@ -0,0 +1,58 @@
1
+ {
2
+ "name": "parrot-blackbox",
3
+ "version": "1.0.0",
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
+ "type": "module",
6
+ "main": "src/cli.js",
7
+ "bin": {
8
+ "parrot-blackbox": "bin/parrot-blackbox.js"
9
+ },
10
+ "files": [
11
+ "bin/",
12
+ "src/"
13
+ ],
14
+ "engines": {
15
+ "node": ">=18"
16
+ },
17
+ "scripts": {
18
+ "test": "node --test",
19
+ "test:sandbox": "node --test test/e2e.test.js",
20
+ "publish:dry-run": "npm publish --dry-run --access public",
21
+ "prepublishOnly": "npm test",
22
+ "release:patch": "npm test && npm version patch -m \"chore(release): %s\" && git push --follow-tags",
23
+ "release:minor": "npm test && npm version minor -m \"chore(release): %s\" && git push --follow-tags",
24
+ "release:major": "npm test && npm version major -m \"chore(release): %s\" && git push --follow-tags"
25
+ },
26
+ "repository": {
27
+ "type": "git",
28
+ "url": "git+https://github.com/DonArtkins/parrot-blackbox.git"
29
+ },
30
+ "homepage": "https://github.com/DonArtkins/parrot-blackbox#readme",
31
+ "bugs": {
32
+ "url": "https://github.com/DonArtkins/parrot-blackbox/issues"
33
+ },
34
+ "keywords": [
35
+ "backup",
36
+ "recovery",
37
+ "snapshot",
38
+ "timeshift",
39
+ "mega",
40
+ "google-drive",
41
+ "rclone",
42
+ "parrot",
43
+ "parrot-os",
44
+ "linux",
45
+ "automation",
46
+ "crash-proof",
47
+ "cli",
48
+ "wizard"
49
+ ],
50
+ "author": "Don Artkins (https://github.com/DonArtkins)",
51
+ "license": "MIT",
52
+ "dependencies": {
53
+ "@clack/prompts": "^0.7.0",
54
+ "citty": "^0.2.2",
55
+ "execa": "^8.0.1",
56
+ "picocolors": "^1.1.1"
57
+ }
58
+ }
@@ -0,0 +1,122 @@
1
+ /**
2
+ * Source walker with the parrot-blackbox golden rule: EVERYTHING tracked by
3
+ * GitHub is already backed up, so any directory inside a git work tree is
4
+ * skipped entirely (we keep only what GitHub does NOT have). User-provided
5
+ * exclude globs are applied on top.
6
+ */
7
+
8
+ import fs from 'node:fs';
9
+ import path from 'node:path';
10
+ import { execaSync } from 'execa';
11
+ import { matchesAny } from '../util/misc.js';
12
+
13
+ /** Resolve `~/x` and relative paths against a home dir. */
14
+ export function expandPath(p, home) {
15
+ if (String(p).startsWith('~/')) return path.join(home, p.slice(2));
16
+ if (String(p) === '~') return home;
17
+ return path.resolve(p);
18
+ }
19
+
20
+ /** Nearest enclosing git work-tree root above `dir`, or null. */
21
+ export function gitTopLevel(dir) {
22
+ try {
23
+ const res = execaSync('git', ['-C', dir, 'rev-parse', '--show-toplevel'], { reject: false });
24
+ if (res.exitCode === 0 && res.stdout.trim()) return path.resolve(res.stdout.trim());
25
+ } catch {
26
+ /* no git available */
27
+ }
28
+ return null;
29
+ }
30
+
31
+ function pathHasGitDir(dir) {
32
+ return fs.existsSync(path.join(dir, '.git'));
33
+ }
34
+
35
+ /**
36
+ * Collect files to back up from `sources`, skipping:
37
+ * - any source that lives inside a git work tree (GitHub has those files)
38
+ * - any nested directory that is itself a git repo
39
+ * - anything matching the exclude globs
40
+ * @param {string[]} sources
41
+ * @param {{exclude?:string[], home?:string}} opts
42
+ * @returns {{files:Array<{abs:string,rel:string}>, skippedRepos:string[], skipped:number, missing:string[]}}
43
+ */
44
+ export function collectFiles(sources, { exclude = [], home = process.env.HOME } = {}) {
45
+ const files = [];
46
+ const skippedRepos = [];
47
+ const missing = [];
48
+ let skipped = 0;
49
+
50
+ for (const src of sources) {
51
+ const abs = expandPath(src, home);
52
+ if (!fs.existsSync(abs)) {
53
+ missing.push(src);
54
+ continue;
55
+ }
56
+
57
+ const top = gitTopLevel(abs);
58
+ if (top) {
59
+ // The whole source is inside a git repo β€” GitHub already owns it.
60
+ skippedRepos.push(abs);
61
+ continue;
62
+ }
63
+
64
+ const prefix = path.basename(abs) || 'sources';
65
+ walk(abs, prefix, abs);
66
+ }
67
+
68
+ function walk(abs, relPrefix, sourceRoot) {
69
+ for (const ent of fs.readdirSync(abs, { withFileTypes: true })) {
70
+ const eAbs = path.join(abs, ent.name);
71
+ const rel = relPrefix ? `${relPrefix}/${ent.name}` : ent.name;
72
+ const relFromSource = path.relative(sourceRoot, eAbs);
73
+ if (matchesAny(relFromSource.replace(/\\/g, '/'), exclude)) {
74
+ skipped += 1;
75
+ continue;
76
+ }
77
+ let st;
78
+ try {
79
+ st = fs.statSync(eAbs);
80
+ } catch {
81
+ skipped += 1;
82
+ continue;
83
+ }
84
+ if (ent.isSymbolicLink()) {
85
+ skipped += 1; // symlinks resolved when restoring via snapshot; keep tree clean here
86
+ continue;
87
+ }
88
+ if (ent.isDirectory()) {
89
+ if (pathHasGitDir(eAbs)) {
90
+ skippedRepos.push(eAbs); // nested git repo β€” GitHub has it
91
+ continue;
92
+ }
93
+ walk(eAbs, rel, sourceRoot);
94
+ } else {
95
+ files.push({ abs: eAbs, rel });
96
+ }
97
+ }
98
+ }
99
+
100
+ return { files, skippedRepos, skipped, missing };
101
+ }
102
+
103
+ /** Copy collected files into a staging root, preserving `rel`. */
104
+ export function stageFiles(files, stagingRoot) {
105
+ for (const f of files) {
106
+ const dest = path.join(stagingRoot, ...f.rel.split('/'));
107
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
108
+ fs.copyFileSync(f.abs, dest);
109
+ }
110
+ return stagingRoot;
111
+ }
112
+
113
+ /** Total byte size of collected files. */
114
+ export function sumFiles(files) {
115
+ return files.reduce((s, f) => {
116
+ try {
117
+ return s + fs.statSync(f.abs).size;
118
+ } catch {
119
+ return s;
120
+ }
121
+ }, 0);
122
+ }
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Recovery. Two paths:
3
+ * - `restore snapshot` β€” pull a cloud snapshot onto local disk, register it
4
+ * with Timeshift, then run the interactive restore (overwrites the running
5
+ * system's root files, exactly what you want over a fresh install). sudo
6
+ * password is prompted interactively, the same way gitswitch/theamify do.
7
+ * - `restore files` β€” download one file-backup generation to a local
8
+ * directory so specific lost files (fonts, images…) come back without
9
+ * touching the system.
10
+ *
11
+ * Restoring a snapshot is Level-5 destructive: requires an explicit
12
+ * confirmation (or `--yes`) before anything is overwritten.
13
+ */
14
+
15
+ import fs from 'node:fs';
16
+ import path from 'node:path';
17
+ import { journal } from '../core/store.js';
18
+ import { stateDir, timeshiftDir } from '../core/paths.js';
19
+ import { refreshAccounts } from '../storage/accounts.js';
20
+ import { discoverManifest, restoreArtifact } from '../storage/archive.js';
21
+ import { listLocalSnapshots } from './snapshot.js';
22
+ import { sudoInteractive } from '../util/sudo.js';
23
+ import { bytesHuman } from '../util/misc.js';
24
+
25
+ /** Restore a file backup generation into a writable local directory. */
26
+ export async function restoreFiles({ id, toDir, accounts, cfg, onProgress }) {
27
+ const found = await discoverManifest('files', id, accounts, cfg.storage.remoteRoot);
28
+ if (!found) {
29
+ // Fall back to scanning every account for the artifact id.
30
+ throw new Error(`no file backup found for id "${id}" β€” check with \`parrot-blackbox list\``);
31
+ }
32
+ fs.mkdirSync(toDir, { recursive: true });
33
+ const res = await restoreArtifact(found.manifest, toDir, { onProgress });
34
+ journal('restore', `files id=${id} -> ${toDir} files=${res.files} bytes=${res.bytes}`);
35
+ return { id, toDir, ...res, manifest: found.manifest };
36
+ }
37
+
38
+ /**
39
+ * Restore a system snapshot.
40
+ * @param {object} opts {id, accounts, cfg, toDir?, confirm, privileged?, onProgress}
41
+ */
42
+ export async function restoreSnapshot({ id, accounts, cfg, toDir, confirm = false, privileged = 'interactive', onProgress }) {
43
+ if (!confirm) {
44
+ throw new Error('Refusing without confirmation β€” pass `--yes` (or confirm interactively). This overwrites the whole system.');
45
+ }
46
+
47
+ const found = await discoverManifest('snapshots', id, accounts, cfg.storage.remoteRoot);
48
+ if (!found) {
49
+ throw new Error(`no snapshot backup found for id "${id}" β€” check with \`parrot-blackbox snapshot list\``);
50
+ }
51
+
52
+ // Is it already on local disk?
53
+ const localSnaps = await listLocalSnapshots({ privileged }).catch(() => []);
54
+ const localSnap = localSnaps.find((s) => s.name === id);
55
+
56
+ if (!localSnap) {
57
+ // Download to a staging dir first, then move into the Timeshift folder.
58
+ const tmpRoot = path.join(stateDir(), 'restore', id);
59
+ fs.rmSync(tmpRoot, { recursive: true, force: true });
60
+ fs.mkdirSync(tmpRoot, { recursive: true });
61
+ const res = await restoreArtifact(found.manifest, tmpRoot, { onProgress });
62
+ journal('restore', `snapshot id=${id} downloaded ${res.bytes} bytes`);
63
+ await placeIntoTimeshift(id, tmpRoot, cfg, privileged);
64
+ fs.rmSync(tmpRoot, { recursive: true, force: true });
65
+ console.log(`βœ” Downloaded snapshot ${bytesHuman(res.bytes)} and registered it with Timeshift.`);
66
+ }
67
+
68
+ // Run the actual restore (interactive sudo β†’ the password prompt is visible).
69
+ console.log(`\nRestoring snapshot ${id} over the current system…\n`);
70
+ const res = await sudoInteractive(['timeshift', '--restore', '--snapshot', id, '--yes']);
71
+ if (res.exitCode !== 0) throw new Error(`timeshift --restore failed (exit ${res.exitCode})`);
72
+ journal('restore', `snapshot id=${id} RESTORED`);
73
+ console.log(`\nβœ” Restore complete. REBOOT now to boot into the restored system.\n`);
74
+ return { id };
75
+ }
76
+
77
+ /** Move a downloaded snapshot tree into the Timeshift snapshot folder (root-owned). */
78
+ async function placeIntoTimeshift(id, tmpRoot, cfg, privileged) {
79
+ const base = determineSnapshotBase();
80
+ const target = path.join(base, id);
81
+ const cmd = `mkdir -p ${shq(base)} && rm -rf ${shq(target)} && cp -a ${shq(tmpRoot)}/. ${shq(target)}/ && chown -R root:root ${shq(target)}`;
82
+ const res = await sudoInteractive(['bash', '-c', cmd]);
83
+ if (res.exitCode !== 0) throw new Error(`could not move snapshot into ${base} (exit ${res.exitCode})`);
84
+ return target;
85
+ }
86
+
87
+ function determineSnapshotBase() {
88
+ return path.join(timeshiftDir(), 'snapshots');
89
+ }
90
+
91
+ function shq(s) {
92
+ return `'${String(s).replace(/'/g, `'\\''`)}'`;
93
+ }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Retention: keep only the newest `keep` artifacts (by due date), return the
3
+ * rest for removal. File backups and snapshots share this rule β€” snapshots are
4
+ * pruned BOTH locally (timeshift delete) and in the cloud in one pass.
5
+ */
6
+
7
+ /** Newest-first list of artifact ids with the rest marked for removal. */
8
+ export function planPrune(ids, keep) {
9
+ if (typeof keep !== 'number' || keep < 1) keep = 3;
10
+ const sorted = [...ids].sort(); // ISO-ish ids sort chronologically
11
+ if (sorted.length <= keep) return { keep: sorted, prune: [] };
12
+ return { keep: sorted.slice(-keep), prune: sorted.slice(0, -keep) };
13
+ }
14
+
15
+ /** Older-than-N-days pruning rule for file backups (kept, prune by date). */
16
+ export function pruneOlderThan(artifacts, maxAgeDays) {
17
+ const cutoff = Date.now() - maxAgeDays * 86_400_000;
18
+ return artifacts.filter((a) => {
19
+ const t = a.createdAt ? new Date(a.createdAt).getTime() : 0;
20
+ return t < cutoff;
21
+ });
22
+ }