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 +21 -0
- package/README.md +260 -0
- package/bin/parrot-blackbox.js +2 -0
- package/package.json +58 -0
- package/src/backup/git-exclude.js +122 -0
- package/src/backup/restore.js +93 -0
- package/src/backup/retention.js +22 -0
- package/src/backup/snapshot.js +214 -0
- package/src/backup/workspace.js +106 -0
- package/src/cli.js +426 -0
- package/src/commands/manage.js +128 -0
- package/src/commands/service.js +122 -0
- package/src/commands/setup.js +214 -0
- package/src/core/lock.js +67 -0
- package/src/core/paths.js +95 -0
- package/src/core/store.js +173 -0
- package/src/core/time.js +142 -0
- package/src/daemon/daemon.js +107 -0
- package/src/daemon/scheduler.js +135 -0
- package/src/storage/accounts.js +110 -0
- package/src/storage/allocator.js +189 -0
- package/src/storage/archive.js +138 -0
- package/src/storage/rclone.js +106 -0
- package/src/util/misc.js +114 -0
- package/src/util/network.js +26 -0
- package/src/util/sudo.js +56 -0
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.
|
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
|
+
}
|