parrot-blackbox 1.0.1 → 1.0.3
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/README.md +266 -13
- package/package.json +1 -1
- package/src/cli.js +24 -3
- package/src/commands/manage.js +106 -1
- package/src/commands/tools.js +93 -0
- package/src/commands/wizard.js +368 -0
- package/src/lib/self.js +122 -0
package/README.md
CHANGED
|
@@ -67,21 +67,21 @@ sudo apt install rclone timeshift git curl
|
|
|
67
67
|
npm install -g parrot-blackbox
|
|
68
68
|
```
|
|
69
69
|
|
|
70
|
-
Run
|
|
70
|
+
Run it — you land in the **menu wizard** (just like gitswitch):
|
|
71
71
|
|
|
72
72
|
```bash
|
|
73
73
|
parrot-blackbox
|
|
74
74
|
```
|
|
75
75
|
|
|
76
|
-
**
|
|
77
|
-
1. **Checks
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
76
|
+
**On every launch the wizard:**
|
|
77
|
+
1. **Checks npm for the latest version** and offers to self-update
|
|
78
|
+
(`npm install -g parrot-blackbox@latest`) if a newer one is published — the
|
|
79
|
+
latest always comes from the npm registry, never from local state.
|
|
80
|
+
2. Shows a **menu with every feature**: add account, storage pool, check &
|
|
81
|
+
install tools, snapshot now, run backup, list backups, restore, always-on
|
|
82
|
+
service, daemon, guided setup, status, doctor, **repair**, **update**,
|
|
83
|
+
**uninstall**. Choosing an action runs it and returns to the menu — saying
|
|
84
|
+
"No" to a prompt never kicks you out; only **Exit** / **Ctrl+C** leaves.
|
|
85
85
|
|
|
86
86
|
---
|
|
87
87
|
|
|
@@ -110,8 +110,14 @@ and pending backups.
|
|
|
110
110
|
## Managing cloud accounts (rclone remotes)
|
|
111
111
|
|
|
112
112
|
Every MEGA / Google Drive login becomes one rclone remote = one pool account.
|
|
113
|
-
|
|
114
|
-
|
|
113
|
+
There are **two ways** to connect them:
|
|
114
|
+
|
|
115
|
+
- **Guided (recommended):** `parrot-blackbox` creates the rclone remote *for
|
|
116
|
+
you* — no menus. `parrot-blackbox remote add mega` prompts for the remote
|
|
117
|
+
name, email, password and optional 2FA, then registers it.
|
|
118
|
+
- **Manual:** configure remote(s) yourself with the raw `rclone config` — the
|
|
119
|
+
**complete step-by-step walkthrough of every prompt and every letter is
|
|
120
|
+
documented below** in the next section.
|
|
115
121
|
|
|
116
122
|
**Add** (guided — creates the remote AND saves it in the pool):
|
|
117
123
|
```bash
|
|
@@ -150,11 +156,258 @@ passwords, and the allocator's manifests are safe to share.
|
|
|
150
156
|
|
|
151
157
|
---
|
|
152
158
|
|
|
159
|
+
## 📖 The full `rclone config` manual walkthrough (every letter, every prompt)
|
|
160
|
+
|
|
161
|
+
> The wizard's *"Configure rclone myself, then register the remote"* path (and
|
|
162
|
+
> the `parrot-blackbox remote config` command) open rclone's interactive menu.
|
|
163
|
+
> This is that menu, explained **prompt by prompt** so you never get lost.
|
|
164
|
+
> The quick alternative that skips all of this: `parrot-blackbox remote add mega`
|
|
165
|
+
> (or `gdrive`) creates the remote for you automatically.
|
|
166
|
+
|
|
167
|
+
### 1. The main menu — what each letter does
|
|
168
|
+
|
|
169
|
+
```
|
|
170
|
+
Current remotes:
|
|
171
|
+
|
|
172
|
+
Name Type
|
|
173
|
+
==== ====
|
|
174
|
+
mega mega
|
|
175
|
+
|
|
176
|
+
e) Edit existing remote
|
|
177
|
+
n) New remote
|
|
178
|
+
d) Delete remote
|
|
179
|
+
r) Rename remote
|
|
180
|
+
c) Copy remote
|
|
181
|
+
s) Set configuration password
|
|
182
|
+
q) Quit config
|
|
183
|
+
e/n/d/r/c/s/q>
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
| Letter | Word | What it does |
|
|
187
|
+
|---|---|---|
|
|
188
|
+
| `e` | **Edit** | Change an **existing** remote's login (email / password / 2FA) or storage type |
|
|
189
|
+
| `n` | **New** | **Create a brand-new remote** → the flow below |
|
|
190
|
+
| `d` | **Delete** | Remove a remote from the config — rclone asks `y` to confirm |
|
|
191
|
+
| `r` | **Rename** | Give an existing remote a different name |
|
|
192
|
+
| `c` | **Copy** | Duplicate an existing remote under a new name (handy for a 2nd account of the same type) |
|
|
193
|
+
| `s` | **Set config password** | Lock the whole `rclone.conf` with a passphrase (optional; skip unless you want it) |
|
|
194
|
+
| `q` | **Quit** | Leave the menu → you return to parrot-blackbox |
|
|
195
|
+
|
|
196
|
+
> **The letters you'll use most:** `n` (new remote), `e` (fix a wrong password),
|
|
197
|
+
> `d` (remove an account you don't use), `q` (done).
|
|
198
|
+
|
|
199
|
+
---
|
|
200
|
+
|
|
201
|
+
### 2. Adding a MEGA remote (storage number **39**)
|
|
202
|
+
|
|
203
|
+
Start the menu with `n`, then follow exactly:
|
|
204
|
+
|
|
205
|
+
```
|
|
206
|
+
e/n/d/r/c/s/q> n ⬅ type the letter n and press Enter
|
|
207
|
+
Enter name for new remote.
|
|
208
|
+
name> mega-account-1 ⬅ type the remote name and press Enter
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
> **What the name is:** a label for this login. Use something meaningful per
|
|
212
|
+
> account, e.g. `mega-account-1`, `mega-2`, `mega-main`. You'll type this exact
|
|
213
|
+
> name into parrot-blackbox later.
|
|
214
|
+
|
|
215
|
+
```
|
|
216
|
+
Option Storage.
|
|
217
|
+
Type of storage to configure.
|
|
218
|
+
...long numbered list of 68 providers...
|
|
219
|
+
Storage> 39 ⬅ type 39 for Mega and press Enter
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
> **The storage number for each provider:**
|
|
223
|
+
> | Provider | Number to type | (what it says in the list) |
|
|
224
|
+
> |---|---|---|
|
|
225
|
+
> | **Mega** (the one you use) | **`39`** | `39 / Mega - Tier 2 \ (mega)` |
|
|
226
|
+
> | **Google Drive** | **`24`** | `24 / Google Drive - Tier 1 \ (drive)` |
|
|
227
|
+
|
|
228
|
+
Next rclone asks for login details — this means **the email + password of the
|
|
229
|
+
cloud account** you want to back up to:
|
|
230
|
+
|
|
231
|
+
```
|
|
232
|
+
Option user.
|
|
233
|
+
User name.
|
|
234
|
+
Enter a value.
|
|
235
|
+
user> info.donartkins.ke@gmail.com ⬅ type the account's EMAIL and press Enter
|
|
236
|
+
|
|
237
|
+
Option pass.
|
|
238
|
+
Password.
|
|
239
|
+
Choose an alternative below.
|
|
240
|
+
y) Yes, type in my own password
|
|
241
|
+
g) Generate random password
|
|
242
|
+
y/g> y ⬅ type y to enter your own password
|
|
243
|
+
|
|
244
|
+
Enter the password:
|
|
245
|
+
password: ******** ⬅ type the account PASSWORD (invisible)
|
|
246
|
+
Confirm the password:
|
|
247
|
+
password: ******** ⬅ type it again to confirm
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
> **What this means:** `user` = the **email address** of your MEGA account.
|
|
251
|
+
> `pass` = that account's **password**. rclone encrypts it before saving it —
|
|
252
|
+
> you'll see `*** ENCRYPTED ***` later.
|
|
253
|
+
|
|
254
|
+
After the password, optional but present for accounts that have it:
|
|
255
|
+
|
|
256
|
+
```
|
|
257
|
+
Option 2fa.
|
|
258
|
+
The 2FA code of your MEGA account if the account is set up with one
|
|
259
|
+
Enter a value. Press Enter to leave empty.
|
|
260
|
+
2fa> ⬅ if you use 2FA on this MEGA account, enter the
|
|
261
|
+
current 6-digit code here; if not, press Enter
|
|
262
|
+
```
|
|
263
|
+
---
|
|
264
|
+
|
|
265
|
+
### 3. Advanced config — ⚠️ **SAY NO** (this is the mistake you kept making)
|
|
266
|
+
|
|
267
|
+
rclone then asks `Edit advanced config?` **twice**. You almost always want
|
|
268
|
+
**`n` (No)** here — parrot-blackbox works fine with the defaults:
|
|
269
|
+
|
|
270
|
+
```
|
|
271
|
+
Edit advanced config?
|
|
272
|
+
y) Yes
|
|
273
|
+
n) No (default)
|
|
274
|
+
y/n> n ⬅ type n and press Enter ✅ SAY NO
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
If you mistakenly choose `y`, rclone will march you through `debug`,
|
|
278
|
+
`hard_delete`, `use_https`, `encoding`, `description`… — you can press **Enter**
|
|
279
|
+
on every single one to accept defaults, then it asks `Edit advanced config?`
|
|
280
|
+
again → answer `n` the second time. But the easy path is simply **`n` the
|
|
281
|
+
first time**.
|
|
282
|
+
|
|
283
|
+
When it finishes you get a confirmation:
|
|
284
|
+
|
|
285
|
+
```
|
|
286
|
+
Configuration complete.
|
|
287
|
+
Options:
|
|
288
|
+
- type: mega
|
|
289
|
+
- user: info.donartkins.ke@gmail.com
|
|
290
|
+
- pass: *** ENCRYPTED ***
|
|
291
|
+
Keep this "mega-account-1" remote?
|
|
292
|
+
y) Yes this is OK (default)
|
|
293
|
+
e) Edit this remote
|
|
294
|
+
d) Delete this remote
|
|
295
|
+
y/e/d> y ⬅ type y to save this remote
|
|
296
|
+
```
|
|
297
|
+
|
|
298
|
+
> **Important:** this confirmation `y` ("keep this remote") is DIFFERENT from
|
|
299
|
+
> the advanced-config prompt. Here you DO want `y` — it saves the remote you
|
|
300
|
+
> just created.
|
|
301
|
+
|
|
302
|
+
---
|
|
303
|
+
|
|
304
|
+
### 4. Adding a Google Drive remote (storage number **24**)
|
|
305
|
+
|
|
306
|
+
Same `n` flow, but the two differences are the storage number and the auth step:
|
|
307
|
+
|
|
308
|
+
```
|
|
309
|
+
e/n/d/r/c/s/q> n ⬅ n for new
|
|
310
|
+
name> gdrive-account-1 ⬅ the remote name
|
|
311
|
+
Storage> 24 ⬅ 24 = Google Drive
|
|
312
|
+
|
|
313
|
+
-- rclone then prints a big "Use auto config?" section --
|
|
314
|
+
Use auto config?
|
|
315
|
+
y) Yes
|
|
316
|
+
n) No
|
|
317
|
+
y/n> y ⬅ y (opens your browser automatically)
|
|
318
|
+
```
|
|
319
|
+
|
|
320
|
+
rclone opens a **browser page asking you to sign in to Google** and grant
|
|
321
|
+
access — log in, allow, done. Back in the terminal:
|
|
322
|
+
|
|
323
|
+
```
|
|
324
|
+
Edit advanced config?
|
|
325
|
+
y) Yes
|
|
326
|
+
n) No (default)
|
|
327
|
+
y/n> n ⬅ ✅ NO
|
|
328
|
+
|
|
329
|
+
Keep this "gdrive-account-1" remote?
|
|
330
|
+
y) Yes this is OK (default)
|
|
331
|
+
y/e/d> y ⬅ y to keep it
|
|
332
|
+
```
|
|
333
|
+
|
|
334
|
+
> If the browser doesn't open (headless), choose the manual option rclone gives
|
|
335
|
+
> you and paste the printed verification code into `http://localhost:53682` —
|
|
336
|
+
> but on your desktop the browser usually just opens.
|
|
337
|
+
|
|
338
|
+
---
|
|
339
|
+
|
|
340
|
+
### 5. After you quit (`q`) — register the remotes
|
|
341
|
+
|
|
342
|
+
You arrive back in parrot-blackbox with the message
|
|
343
|
+
`Starting rclone config — follow its prompts, then come back.` Once rclone's
|
|
344
|
+
menu is closed (`q`), the wizard automatically shows the remotes it found and
|
|
345
|
+
lets you pick which to register into the pool:
|
|
346
|
+
|
|
347
|
+
```
|
|
348
|
+
Select which rclone remotes to add to the backup pool: ⬅ tick your new names
|
|
349
|
+
```
|
|
350
|
+
|
|
351
|
+
Each one is then tagged with its provider as you type it into rclone. If you
|
|
352
|
+
quit the wizard before registering, that's fine — either re-run
|
|
353
|
+
`parrot-blackbox` or:
|
|
354
|
+
|
|
355
|
+
```bash
|
|
356
|
+
parrot-blackbox account add mega mega-account-1 # provider + the remote name you chose in rclone
|
|
357
|
+
parrot-blackbox remote list # confirm it's now registered
|
|
358
|
+
```
|
|
359
|
+
|
|
360
|
+
---
|
|
361
|
+
|
|
362
|
+
### 6. Editing / renaming / deleting a remote later (the `e` `r` `d` letters)
|
|
363
|
+
|
|
364
|
+
Fix a wrong password, rename, or delete an old account:
|
|
365
|
+
|
|
366
|
+
```
|
|
367
|
+
e/n/d/r/c/s/q> e ⬅ e = Edit an existing remote
|
|
368
|
+
Choose a number from below ... ⬅ pick the remote to edit
|
|
369
|
+
Edit remote
|
|
370
|
+
u) Update current
|
|
371
|
+
y) Yes type in my own password
|
|
372
|
+
g) Generate random password
|
|
373
|
+
q) Quit config
|
|
374
|
+
u> y ⬅ choose what to change (u = update this field)
|
|
375
|
+
Name or type of remote > user ⬅ which field: user, pass, 2fa, type, ...
|
|
376
|
+
user> new@email.com ⬅ the new value
|
|
377
|
+
```
|
|
378
|
+
|
|
379
|
+
Rename:
|
|
380
|
+
```
|
|
381
|
+
e/n/d/r/c/s/q> r ⬅ r = Rename
|
|
382
|
+
Old name> mega-account-1 ⬅ current name
|
|
383
|
+
New name> mega-backup-1 ⬅ new name
|
|
384
|
+
```
|
|
385
|
+
|
|
386
|
+
Delete:
|
|
387
|
+
```
|
|
388
|
+
e/n/d/r/c/s/q> d ⬅ d = Delete
|
|
389
|
+
Choose a number from below ... ⬅ pick the remote
|
|
390
|
+
y) Yes this is OK
|
|
391
|
+
e) Edit this remote
|
|
392
|
+
y/e> y ⬅ y confirms permanent deletion
|
|
393
|
+
```
|
|
394
|
+
|
|
395
|
+
> Any rename/delete also must be mirrored in parrot-blackbox's pool:
|
|
396
|
+
> `parrot-blackbox remote list` shows what's registered, and
|
|
397
|
+
> `parrot-blackbox remote remove <name>` removes an entry from BOTH rclone and
|
|
398
|
+
> the pool in one go.
|
|
399
|
+
|
|
400
|
+
---
|
|
401
|
+
|
|
153
402
|
## Commands
|
|
154
403
|
|
|
155
404
|
| Command | What it does |
|
|
156
405
|
|---|---|
|
|
157
|
-
| `parrot-blackbox` |
|
|
406
|
+
| `parrot-blackbox` | ⭐ **Menu wizard** — every feature in one menu; automatic update check on launch |
|
|
407
|
+
| `parrot-blackbox install` | Same as the menu wizard |
|
|
408
|
+
| `parrot-blackbox repair [--yes]` | Fix a broken install (tools, config, service, pool) |
|
|
409
|
+
| `parrot-blackbox update [--force]` | Check npm & update to the latest published version |
|
|
410
|
+
| `parrot-blackbox setup` | Guided full setup (tools, accounts, schedule, service) |
|
|
158
411
|
| `parrot-blackbox run` | Run any due/pending backups now (safe for cron) |
|
|
159
412
|
| `parrot-blackbox force` | ⭐ Run every enabled backup NOW (default = weekly snapshot) `[sudo]` |
|
|
160
413
|
| `parrot-blackbox snapshot now` | Create + upload a Weekly Timeshift snapshot `[sudo]` |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "parrot-blackbox",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.3",
|
|
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/cli.js
CHANGED
|
@@ -3,6 +3,9 @@ import pc from 'picocolors';
|
|
|
3
3
|
import * as p from '@clack/prompts';
|
|
4
4
|
import { createRequire } from 'node:module';
|
|
5
5
|
import { runSetup } from './commands/setup.js';
|
|
6
|
+
import { runWizard } from './commands/wizard.js';
|
|
7
|
+
import { runRepair } from './commands/manage.js';
|
|
8
|
+
import { runSelfUpdate } from './lib/self.js';
|
|
6
9
|
import { guidedRemoteAdd, deleteRemote, registerRemotesAsAccounts, remoteStatus } from './commands/remote.js';
|
|
7
10
|
import { runDoctor, runStatus, runUninstallWizard } from './commands/manage.js';
|
|
8
11
|
import { installService, removeService } from './commands/service.js';
|
|
@@ -36,7 +39,11 @@ function printUsage() {
|
|
|
36
39
|
${pc.bold('parrot-blackbox')} ${pc.dim(`v${pkg.version}`)} — crash-proof multi-cloud backup & recovery for Parrot OS
|
|
37
40
|
|
|
38
41
|
${pc.bold('Usage:')}
|
|
39
|
-
parrot-blackbox
|
|
42
|
+
parrot-blackbox ⭐ Menu wizard (all features; auto-update check on launch)
|
|
43
|
+
parrot-blackbox install Same as the menu wizard
|
|
44
|
+
parrot-blackbox repair [--yes] Fix a broken install (tools, config, service, pool)
|
|
45
|
+
parrot-blackbox update [--force] Check npm & update to the latest version
|
|
46
|
+
parrot-blackbox setup Guided full setup (tools, accounts, schedule, service)
|
|
40
47
|
parrot-blackbox run Run any due / pending backups now (safe for cron)
|
|
41
48
|
parrot-blackbox force ⭐ Run every enabled backup NOW (default = weekly snapshot) ${pc.dim('[sudo]')}
|
|
42
49
|
parrot-blackbox snapshot now Create a weekly snapshot + upload it now ${pc.dim('[sudo]')}
|
|
@@ -349,13 +356,27 @@ const main = defineCommand({
|
|
|
349
356
|
return;
|
|
350
357
|
}
|
|
351
358
|
|
|
352
|
-
if (!cmd) return
|
|
359
|
+
if (!cmd) return runWizard();
|
|
353
360
|
|
|
354
361
|
switch (cmd) {
|
|
355
362
|
case 'setup':
|
|
363
|
+
// Guided full setup — a distinct, deeper flow (still menu-accessible).
|
|
364
|
+
return runSetup();
|
|
365
|
+
|
|
356
366
|
case 'wizard':
|
|
367
|
+
case 'menu':
|
|
357
368
|
case 'install':
|
|
358
|
-
return
|
|
369
|
+
return runWizard();
|
|
370
|
+
|
|
371
|
+
case 'repair':
|
|
372
|
+
case 'fix':
|
|
373
|
+
return runRepair({ auto: process.argv.includes('--yes') });
|
|
374
|
+
|
|
375
|
+
case 'update':
|
|
376
|
+
case 'self-update':
|
|
377
|
+
case 'selfupdate':
|
|
378
|
+
case 'upgrade':
|
|
379
|
+
return runSelfUpdate({ force: process.argv.includes('--force') });
|
|
359
380
|
|
|
360
381
|
case 'run':
|
|
361
382
|
process.exitCode = await invokeRun('noninteractive');
|
package/src/commands/manage.js
CHANGED
|
@@ -124,5 +124,110 @@ export async function runUninstallWizard() {
|
|
|
124
124
|
if (removed.length) p.log.success(`Removed local data: ${removed.join(', ')}`);
|
|
125
125
|
else p.log.message(pc.dim('No local parrot-blackbox data found.'));
|
|
126
126
|
|
|
127
|
-
|
|
127
|
+
// Remove the npm package too, exactly like gitswitch/theamify.
|
|
128
|
+
const { selfUninstall } = await import('../lib/self.js');
|
|
129
|
+
const pkgRemoved = await selfUninstall();
|
|
130
|
+
|
|
131
|
+
p.outro(pc.green(
|
|
132
|
+
`Uninstalled. Cloud backups remain safe in your accounts.${pkgRemoved ? ' The parrot-blackbox command is gone from PATH.' : ''}`,
|
|
133
|
+
));
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* `repair` — fix a broken/partial install. Re-runs every integrity probe:
|
|
138
|
+
* - system tools (rclone / timeshift / git / curl) checked + auto-installed
|
|
139
|
+
* - config + state dirs recreated if missing / regenerated if corrupt
|
|
140
|
+
* - always-on service (systemd / cron) re-installed if missing
|
|
141
|
+
* - storage pool entries cross-checked against rclone remotes (stale removed)
|
|
142
|
+
* - optional npm reinstall when the data dir is healthy but the CLI is broken
|
|
143
|
+
* When `auto` is true it runs in non-interactive / repair-and-summary mode.
|
|
144
|
+
*/
|
|
145
|
+
export async function runRepair({ auto = false } = {}) {
|
|
146
|
+
const { runToolsCheck } = await import('./tools.js');
|
|
147
|
+
const { installService } = await import('./service.js');
|
|
148
|
+
const { listRemotes } = await import('../storage/rclone.js');
|
|
149
|
+
const { listAccounts, removeAccount } = await import('../storage/accounts.js');
|
|
150
|
+
|
|
151
|
+
p.intro(pc.bgGreen(pc.black(' 🛠 parrot-blackbox repair ')));
|
|
152
|
+
const fixed = [];
|
|
153
|
+
|
|
154
|
+
// 1. Tools
|
|
155
|
+
const stillMissing = await runToolsCheck();
|
|
156
|
+
if (stillMissing.length === 0) p.log.success('Tools OK.');
|
|
157
|
+
else { p.log.warn('Some tools are still missing — snapshot backup/restore may be unavailable.'); }
|
|
158
|
+
|
|
159
|
+
// 2. Config & state
|
|
160
|
+
let cfgPath;
|
|
161
|
+
const { configFile, ensureStateDirs } = await import('../core/paths.js');
|
|
162
|
+
const { loadConfig } = await import('../core/store.js');
|
|
163
|
+
try {
|
|
164
|
+
cfgPath = configFile();
|
|
165
|
+
ensureStateDirs();
|
|
166
|
+
loadConfig(); // throws if corrupt JSON
|
|
167
|
+
p.log.success('Config & state OK.');
|
|
168
|
+
} catch (e) {
|
|
169
|
+
p.log.warn(`Config/state issue: ${e.message}`);
|
|
170
|
+
// Regenerate a fresh config if absent or corrupt.
|
|
171
|
+
try {
|
|
172
|
+
const { defaultConfig, saveConfig } = await import('../core/store.js');
|
|
173
|
+
let cfg = null;
|
|
174
|
+
try { cfg = loadConfig(); } catch { cfg = null; }
|
|
175
|
+
if (!cfg) {
|
|
176
|
+
saveConfig(defaultConfig());
|
|
177
|
+
fixed.push('config');
|
|
178
|
+
p.log.success('Config recreated.');
|
|
179
|
+
}
|
|
180
|
+
} catch (e2) {
|
|
181
|
+
p.log.warn(`Could not recreate config: ${e2.message}`);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// 3. Service
|
|
186
|
+
try {
|
|
187
|
+
const { serviceFile, daemonLogFile } = await import('../core/paths.js');
|
|
188
|
+
const { serviceBackend } = await import('./service.js');
|
|
189
|
+
const fsMod = await import('node:fs');
|
|
190
|
+
if (serviceBackend() === 'systemd' && !fsMod.existsSync(serviceFile())) {
|
|
191
|
+
const backend = await installService();
|
|
192
|
+
p.log.success(`Always-on service re-installed via ${backend}.`);
|
|
193
|
+
fixed.push('service');
|
|
194
|
+
} else {
|
|
195
|
+
p.log.success('Service OK.');
|
|
196
|
+
}
|
|
197
|
+
} catch (e) {
|
|
198
|
+
p.log.warn(`Service check failed: ${e.message}`);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// 4. Pool ↔ rclone cross-check
|
|
202
|
+
try {
|
|
203
|
+
const remotes = await listRemotes();
|
|
204
|
+
const accs = listAccounts();
|
|
205
|
+
let stale = 0;
|
|
206
|
+
for (const a of accs) {
|
|
207
|
+
if (!remotes.includes(a.remote)) {
|
|
208
|
+
removeAccount(a.remote);
|
|
209
|
+
stale += 1;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
if (stale) { p.log.success(`Removed ${stale} stale pool entry(ies) whose rclone remote no longer exists.`); fixed.push(`pool(${stale})`); }
|
|
213
|
+
else p.log.success(`Pool OK (${accs.length} account(s), ${remotes.length} rclone remote(s)).`);
|
|
214
|
+
} catch (e) {
|
|
215
|
+
p.log.warn(`Pool check failed: ${e.message}`);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// 5. Optional npm reinstall (repair a broken CLI install)
|
|
219
|
+
if (!auto) {
|
|
220
|
+
const want = await p.confirm({
|
|
221
|
+
message: 'Reinstall parrot-blackbox from npm to repair the executable?',
|
|
222
|
+
initialValue: false,
|
|
223
|
+
});
|
|
224
|
+
if (!p.isCancel(want) && want) {
|
|
225
|
+
const { runSelfUpdate } = await import('../lib/self.js');
|
|
226
|
+
p.log.step('Reinstalling from npm…');
|
|
227
|
+
const did = await runSelfUpdate({ force: true });
|
|
228
|
+
if (did) fixed.push('npm');
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
p.outro(pc.green(fixed.length ? `Repair complete — fixed: ${fixed.join(', ')}.` : 'Nothing to repair — everything looks healthy.'));
|
|
128
233
|
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* System tools needed for snapshot backup & restore — detection + auto-install
|
|
3
|
+
* (the gitswitch/theamify companion-tool pattern). Shared by the wizard menu,
|
|
4
|
+
* the guided setup and the CLI.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import * as p from '@clack/prompts';
|
|
8
|
+
import pc from 'picocolors';
|
|
9
|
+
import { execa } from 'execa';
|
|
10
|
+
import { hasCommandSync } from '../core/store.js';
|
|
11
|
+
|
|
12
|
+
export const REQUIRED = [
|
|
13
|
+
{ bin: 'rclone', pkg: 'rclone', why: 'talks to MEGA / Google Drive (cloud storage)' },
|
|
14
|
+
{ bin: 'timeshift', pkg: 'timeshift', why: 'system snapshots — create AND restore' },
|
|
15
|
+
{ bin: 'git', pkg: 'git', why: 'skip GitHub-tracked files' },
|
|
16
|
+
{ bin: 'curl', pkg: 'curl', why: 'connectivity checks' },
|
|
17
|
+
];
|
|
18
|
+
|
|
19
|
+
/** Detect the distro package manager (apt/dnf/yum/pacman/zypper/apk). */
|
|
20
|
+
export function detectPackageManager() {
|
|
21
|
+
const order = ['apt-get', 'dnf', 'yum', 'pacman', 'zypper', 'apk'];
|
|
22
|
+
return order.find((name) => hasCommandSync(name)) || null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Tools that are missing on the system right now. */
|
|
26
|
+
export function missingTools() {
|
|
27
|
+
return REQUIRED.filter((t) => !hasCommandSync(t.bin));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Auto-install the tools the system needs. Prompts per missing tool, then runs
|
|
32
|
+
* the package-manager install with an interactive sudo prompt (spinner released
|
|
33
|
+
* first, Ctrl+C safe). Returns the freshly installed tool names.
|
|
34
|
+
*/
|
|
35
|
+
export async function ensureSystemTools() {
|
|
36
|
+
const missing = missingTools();
|
|
37
|
+
if (missing.length === 0) return [];
|
|
38
|
+
|
|
39
|
+
const pm = detectPackageManager();
|
|
40
|
+
if (!pm) {
|
|
41
|
+
p.log.warn('No supported package manager detected (apt/dnf/yum/pacman/zypper/apk).');
|
|
42
|
+
p.log.message(pc.dim(`Install manually, then re-run: sudo apt install ${missing.map((m) => m.pkg).join(' ')}`));
|
|
43
|
+
return [];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const installed = [];
|
|
47
|
+
for (const tool of missing) {
|
|
48
|
+
const want = await p.confirm({
|
|
49
|
+
message: `${pc.cyan(tool.bin)} is missing. Install it now? (needed for ${tool.why})`,
|
|
50
|
+
initialValue: true,
|
|
51
|
+
});
|
|
52
|
+
if (p.isCancel(want) || !want) {
|
|
53
|
+
p.log.warn(`Skipped ${pc.cyan(tool.bin)} — snapshot backup/restore may not work without it.`);
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
p.log.step(`Installing ${pc.cyan(tool.bin)}…`);
|
|
57
|
+
const s = p.spinner();
|
|
58
|
+
s.start(`Installing ${tool.bin}…`);
|
|
59
|
+
s.stop(''); // release the terminal first so the sudo password prompt is usable
|
|
60
|
+
try {
|
|
61
|
+
const args = pm === 'pacman' ? ['-S', '--noconfirm', tool.pkg] : [pm, 'install', '-y', tool.pkg];
|
|
62
|
+
const res = await execa('sudo', args, { stdio: 'inherit', reject: false });
|
|
63
|
+
if (res.exitCode === 0 && hasCommandSync(tool.bin)) {
|
|
64
|
+
p.log.success(`${pc.cyan(tool.bin)} installed.`);
|
|
65
|
+
installed.push(tool.bin);
|
|
66
|
+
} else {
|
|
67
|
+
p.log.warn(`Could not install ${pc.cyan(tool.bin)} — run: sudo ${args.join(' ')}`);
|
|
68
|
+
}
|
|
69
|
+
} catch (e) {
|
|
70
|
+
p.log.warn(`${pc.cyan(tool.bin)} install failed: ${e.message}`);
|
|
71
|
+
}
|
|
72
|
+
s.stop('');
|
|
73
|
+
}
|
|
74
|
+
return installed;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Check & install; reports status. Returns still-missing tools. */
|
|
78
|
+
export async function runToolsCheck() {
|
|
79
|
+
const missing = missingTools();
|
|
80
|
+
if (missing.length === 0) {
|
|
81
|
+
p.log.success(`All tools present: ${REQUIRED.map((r) => r.bin).join(', ')}`);
|
|
82
|
+
return [];
|
|
83
|
+
}
|
|
84
|
+
const installed = await ensureSystemTools();
|
|
85
|
+
const still = missingTools();
|
|
86
|
+
if (installed.length) p.log.success(`Installed: ${installed.join(', ')}`);
|
|
87
|
+
if (still.length) p.log.warn(`Still missing: ${still.map((m) => m.bin).join(', ')}`);
|
|
88
|
+
else p.log.success(`All required tools now present: ${REQUIRED.map((r) => r.bin).join(', ')}`);
|
|
89
|
+
if (still.includes('timeshift')) {
|
|
90
|
+
p.log.message(pc.dim('Timeshift missing = snapshot backup & restore are unavailable. Install it before relying on snapshots.'));
|
|
91
|
+
}
|
|
92
|
+
return still;
|
|
93
|
+
}
|
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The gitswitch-style menu wizard (default command).
|
|
3
|
+
*
|
|
4
|
+
* - CARRIES OUT AN AUTOMATIC UPDATE CHECK on launch (latest from the npm
|
|
5
|
+
* registry, never from local state) and offers to install it.
|
|
6
|
+
* - Then shows a menu with EVERY feature; choosing an action runs it and
|
|
7
|
+
* RETURNS TO THE MENU. Saying "No" to a prompt never kicks you out — only
|
|
8
|
+
* Exit / Ctrl+C leaves the wizard.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import * as p from '@clack/prompts';
|
|
12
|
+
import pc from 'picocolors';
|
|
13
|
+
import fs from 'node:fs';
|
|
14
|
+
import { createRequire } from 'node:module';
|
|
15
|
+
import { runToolsCheck } from './tools.js';
|
|
16
|
+
import { guidedRemoteAdd } from './remote.js';
|
|
17
|
+
import { listAccounts, refreshAccounts, poolSummary, addAccount, removeAccount } from '../storage/accounts.js';
|
|
18
|
+
import { loadConfig, saveConfig } from '../core/store.js';
|
|
19
|
+
import { runDueJobs } from '../daemon/scheduler.js';
|
|
20
|
+
import { runSnapshotNow, listLocalSnapshots } from '../backup/snapshot.js';
|
|
21
|
+
import { listArtifacts } from '../storage/archive.js';
|
|
22
|
+
import { restoreFiles, restoreSnapshot } from '../backup/restore.js';
|
|
23
|
+
import { installService, removeService } from './service.js';
|
|
24
|
+
import { startDaemon, stopDaemon, daemonRunning } from '../daemon/daemon.js';
|
|
25
|
+
import { runDoctor, runStatus, runUninstallWizard } from './manage.js';
|
|
26
|
+
import { runSetup } from './setup.js';
|
|
27
|
+
import { bytesHuman } from '../util/misc.js';
|
|
28
|
+
|
|
29
|
+
const require = createRequire(import.meta.url);
|
|
30
|
+
const pkg = require('../../package.json');
|
|
31
|
+
|
|
32
|
+
async function importSelf() {
|
|
33
|
+
return import('../lib/self.js');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Launch-time upgrade check — latest is always fetched from npm. */
|
|
37
|
+
async function autoUpdateCheck() {
|
|
38
|
+
const { checkForUpdate, promptSelfUpdate } = await importSelf();
|
|
39
|
+
try {
|
|
40
|
+
const { outdated } = await checkForUpdate();
|
|
41
|
+
if (outdated) await promptSelfUpdate();
|
|
42
|
+
} catch {
|
|
43
|
+
/* offline / npm missing — never block the wizard on the update check */
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Add one (or more) cloud accounts, guided. */
|
|
48
|
+
async function addAccountAction() {
|
|
49
|
+
for (;;) {
|
|
50
|
+
const provider = await p.select({
|
|
51
|
+
message: 'Which provider?',
|
|
52
|
+
options: [
|
|
53
|
+
{ value: 'mega', label: 'MEGA (20 GB free tier)' },
|
|
54
|
+
{ value: 'gdrive', label: 'Google Drive (10 GB free tier)' },
|
|
55
|
+
{ value: 'back', label: '← Back' },
|
|
56
|
+
],
|
|
57
|
+
});
|
|
58
|
+
if (p.isCancel(provider) || provider === 'back') return;
|
|
59
|
+
const res = await guidedRemoteAdd({ provider });
|
|
60
|
+
if (res.ok) p.log.success(`✔ ${pc.bold(res.name)} (${res.provider}) added to the pool.`);
|
|
61
|
+
else if (res.error) p.log.warn(res.error);
|
|
62
|
+
else if (res.cancelled) { p.log.message(pc.dim('Cancelled.')); return; }
|
|
63
|
+
const again = await p.confirm({ message: 'Add another account?', initialValue: false });
|
|
64
|
+
if (p.isCancel(again) || !again) return;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Storage pool sub-menu: list / add / remove / quota. */
|
|
69
|
+
async function accountsMenu() {
|
|
70
|
+
const sub = await p.select({
|
|
71
|
+
message: 'Storage pool — accounts are rclone remotes (MEGA / Google Drive)',
|
|
72
|
+
options: [
|
|
73
|
+
{ value: 'list', label: '🔎 List accounts & quotas' },
|
|
74
|
+
{ value: 'add', label: '➕ Add account to the pool (existing rclone remote)' },
|
|
75
|
+
{ value: 'remove', label: '➖ Remove account from the pool' },
|
|
76
|
+
{ value: 'quota', label: '📐 Set an account quota (GiB)' },
|
|
77
|
+
{ value: 'back', label: '← Back' },
|
|
78
|
+
],
|
|
79
|
+
});
|
|
80
|
+
if (p.isCancel(sub) || sub === 'back') return;
|
|
81
|
+
|
|
82
|
+
if (sub === 'list') {
|
|
83
|
+
const accs = listAccounts();
|
|
84
|
+
const cfg = loadConfig();
|
|
85
|
+
if (!accs.length) { p.log.message(pc.dim('No accounts yet — choose “Add account”.')); return; }
|
|
86
|
+
const refreshed = await refreshAccounts(cfg);
|
|
87
|
+
p.log.message(poolSummary(refreshed).text);
|
|
88
|
+
for (const a of refreshed) {
|
|
89
|
+
p.log.message(` - ${pc.bold(a.label)} ${a.provider} remote=${a.remote} ${bytesHuman(a.free)} free / ${bytesHuman(a.total)}`);
|
|
90
|
+
}
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
if (sub === 'add') {
|
|
94
|
+
const provider = await p.select({
|
|
95
|
+
message: 'Provider of the remote you already created (via rclone config / remote add)?',
|
|
96
|
+
options: [
|
|
97
|
+
{ value: 'mega', label: 'MEGA' },
|
|
98
|
+
{ value: 'gdrive', label: 'Google Drive' },
|
|
99
|
+
],
|
|
100
|
+
});
|
|
101
|
+
if (p.isCancel(provider)) return;
|
|
102
|
+
const name = await p.text({ message: 'rclone remote name (e.g. mega, mega-account-1):' });
|
|
103
|
+
if (p.isCancel(name) || !name) return;
|
|
104
|
+
const res = await addAccount({ provider, remote: name });
|
|
105
|
+
if (res.ok) p.log.success(`✔ ${res.account.label} added.`);
|
|
106
|
+
else p.log.warn(res.error);
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
if (sub === 'remove') {
|
|
110
|
+
const accs = listAccounts();
|
|
111
|
+
if (!accs.length) { p.log.message(pc.dim('No accounts to remove.')); return; }
|
|
112
|
+
const pick = await p.select({
|
|
113
|
+
message: 'Remove which account?',
|
|
114
|
+
options: accs.map((a) => ({ value: a.remote, label: `${a.remote} (${a.provider})` })).concat([{ value: '__back', label: '← Back' }]),
|
|
115
|
+
});
|
|
116
|
+
if (p.isCancel(pick) || pick === '__back') return;
|
|
117
|
+
if (removeAccount(pick)) p.log.success(`✔ Removed ${pc.bold(pick)} from the pool.`);
|
|
118
|
+
else p.log.warn(`Could not remove ${pick}.`);
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
if (sub === 'quota') {
|
|
122
|
+
const accs = listAccounts();
|
|
123
|
+
if (!accs.length) { p.log.message(pc.dim('No accounts yet.')); return; }
|
|
124
|
+
const pick = await p.select({
|
|
125
|
+
message: 'Which account?',
|
|
126
|
+
options: accs.map((a) => ({ value: a.remote, label: `${a.remote} (${a.provider})` })).concat([{ value: '__back', label: '← Back' }]),
|
|
127
|
+
});
|
|
128
|
+
if (p.isCancel(pick) || pick === '__back') return;
|
|
129
|
+
const giB = await p.text({ message: `Quota in GiB for ${pick}:`, initialValue: '20' });
|
|
130
|
+
if (p.isCancel(giB) || !giB) return;
|
|
131
|
+
const cfg = loadConfig();
|
|
132
|
+
const acc = (cfg.storage.accounts || []).find((a) => a.remote === pick);
|
|
133
|
+
if (!acc) { p.log.warn(`No account matched ${pick}.`); return; }
|
|
134
|
+
acc.quotaGiB = Number(giB);
|
|
135
|
+
saveConfig(cfg);
|
|
136
|
+
p.log.success(`✔ ${pick} quota set to ${giB} GiB.`);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Run every enabled backup right now. */
|
|
141
|
+
async function backupNowAction() {
|
|
142
|
+
const s = p.spinner();
|
|
143
|
+
s.start('Running backup…');
|
|
144
|
+
s.stop('');
|
|
145
|
+
const res = await runDueJobs({ force: true, privileged: 'interactive' });
|
|
146
|
+
const report = res.report || [];
|
|
147
|
+
if (report.length === 0) { p.log.message(pc.dim('No enabled backup jobs.')); return; }
|
|
148
|
+
for (const r of report) {
|
|
149
|
+
if (r.ok) {
|
|
150
|
+
const size = r.size ? ` (${bytesHuman(r.size)})` : '';
|
|
151
|
+
p.log.success(r.snapshot
|
|
152
|
+
? `✔ Snapshot ${r.snapshot} created & uploaded${size}.`
|
|
153
|
+
: `✔ File backup ${r.due} stored${size}.`);
|
|
154
|
+
if (r.pruned?.length) p.log.message(pc.dim(`Pruned: ${r.pruned.join(', ')}`));
|
|
155
|
+
} else if (r.deferred) {
|
|
156
|
+
p.log.warn(`⏸ Snapshot deferred (sudo needed) — run \`parrot-blackbox snapshot now\` once to authenticate.`);
|
|
157
|
+
} else {
|
|
158
|
+
p.log.warn(`✖ ${r.type} ${r.due} failed: ${r.error}`);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Create + upload a snapshot immediately. */
|
|
164
|
+
async function snapshotNowAction() {
|
|
165
|
+
try {
|
|
166
|
+
const r = await runSnapshotNow();
|
|
167
|
+
p.log.success(`✔ Snapshot ${r.snapshot} created & uploaded (${bytesHuman(r.manifest?.totalSize ?? 0)}).`);
|
|
168
|
+
if (r.pruned?.length) p.log.message(pc.dim(`Pruned: ${r.pruned.join(', ')}`));
|
|
169
|
+
} catch (e) {
|
|
170
|
+
p.log.warn(`✖ ${e.message}`);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** List local + cloud snapshots and file backups. */
|
|
175
|
+
async function listBackupsAction() {
|
|
176
|
+
const cfg = loadConfig();
|
|
177
|
+
const accs = listAccounts();
|
|
178
|
+
p.log.message(pc.bold('Local snapshots (Timeshift):'));
|
|
179
|
+
try {
|
|
180
|
+
const local = await listLocalSnapshots({ privileged: 'noninteractive' });
|
|
181
|
+
if (!local.length) p.log.message(pc.dim(' none'));
|
|
182
|
+
for (const sn of local) p.log.message(` - ${pc.cyan(sn.name)}`);
|
|
183
|
+
} catch (e) {
|
|
184
|
+
p.log.message(pc.dim(` ${e.message}`));
|
|
185
|
+
}
|
|
186
|
+
if (accs.length) {
|
|
187
|
+
p.log.message(pc.bold('Cloud snapshots:'));
|
|
188
|
+
const cloudSnaps = await listArtifacts('snapshots', accs, cfg.storage.remoteRoot);
|
|
189
|
+
if (!cloudSnaps.length) p.log.message(pc.dim(' none'));
|
|
190
|
+
for (const c of cloudSnaps) p.log.message(` - ${pc.cyan(c.id)} ${bytesHuman(c.totalSize)}`);
|
|
191
|
+
p.log.message(pc.bold('Cloud file backups:'));
|
|
192
|
+
const files = await listArtifacts('files', accs, cfg.storage.remoteRoot);
|
|
193
|
+
if (!files.length) p.log.message(pc.dim(' none'));
|
|
194
|
+
for (const f of files) p.log.message(` - ${pc.cyan(f.id)} ${bytesHuman(f.totalSize)}`);
|
|
195
|
+
} else {
|
|
196
|
+
p.log.message(pc.dim('No accounts configured — add one from the menu.'));
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
/** Restore files or a system snapshot. */
|
|
200
|
+
async function restoreMenu() {
|
|
201
|
+
const accs = listAccounts();
|
|
202
|
+
if (!accs.length) { p.log.warn('No accounts configured — nothing to restore from cloud yet.'); return; }
|
|
203
|
+
const cfg = loadConfig();
|
|
204
|
+
const kind = await p.select({
|
|
205
|
+
message: 'Restore what?',
|
|
206
|
+
options: [
|
|
207
|
+
{ value: 'files', label: '📄 File backup (fonts/images/docs into a folder)' },
|
|
208
|
+
{ value: 'snapshot', label: '💽 System snapshot (overwrites the running system)', hint: '[sudo]' },
|
|
209
|
+
{ value: 'back', label: '← Back' },
|
|
210
|
+
],
|
|
211
|
+
});
|
|
212
|
+
if (p.isCancel(kind) || kind === 'back') return;
|
|
213
|
+
|
|
214
|
+
if (kind === 'files') {
|
|
215
|
+
const artifacts = await listArtifacts('files', accs, cfg.storage.remoteRoot);
|
|
216
|
+
if (!artifacts.length) { p.log.warn('No file backups found.'); return; }
|
|
217
|
+
const id = await p.select({
|
|
218
|
+
message: 'Pick a backup generation:',
|
|
219
|
+
options: artifacts.map((a) => ({ value: a.id, label: `${a.id} (${bytesHuman(a.totalSize)})` })).concat([{ value: '__back', label: '← Back' }]),
|
|
220
|
+
});
|
|
221
|
+
if (p.isCancel(id) || id === '__back') return;
|
|
222
|
+
const toDir = await p.text({ message: 'Restore into which directory?', initialValue: `./restored-${id}` });
|
|
223
|
+
if (p.isCancel(toDir) || !toDir) return;
|
|
224
|
+
fs.mkdirSync(toDir, { recursive: true });
|
|
225
|
+
try {
|
|
226
|
+
const res = await restoreFiles({ id, toDir, accounts: accs, cfg });
|
|
227
|
+
p.log.success(`✔ Restored ${res.files} file(s), ${bytesHuman(res.bytes)} into ${toDir}`);
|
|
228
|
+
} catch (e) {
|
|
229
|
+
p.log.warn(`✖ ${e.message}`);
|
|
230
|
+
}
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const cloud = await listArtifacts('snapshots', accs, cfg.storage.remoteRoot);
|
|
235
|
+
if (!cloud.length) { p.log.warn('No cloud snapshots found.'); return; }
|
|
236
|
+
const id = await p.select({
|
|
237
|
+
message: 'Pick a snapshot to restore:',
|
|
238
|
+
options: cloud.map((c) => ({ value: c.id, label: `${c.id} (${bytesHuman(c.totalSize)})` })).concat([{ value: '__back', label: '← Back' }]),
|
|
239
|
+
});
|
|
240
|
+
if (p.isCancel(id) || id === '__back') return;
|
|
241
|
+
const confirm = await p.confirm({
|
|
242
|
+
message: pc.red(`This OVERWRITES the running system with snapshot ${id}. Continue?`),
|
|
243
|
+
initialValue: false,
|
|
244
|
+
});
|
|
245
|
+
if (p.isCancel(confirm) || !confirm) { p.log.message(pc.dim('Restore aborted — nothing was touched.')); return; }
|
|
246
|
+
const s = p.spinner();
|
|
247
|
+
s.start('Preparing restore…');
|
|
248
|
+
s.stop('');
|
|
249
|
+
try {
|
|
250
|
+
await restoreSnapshot({ id, accounts: accs, cfg, confirm: true, privileged: 'interactive' });
|
|
251
|
+
} catch (e) {
|
|
252
|
+
p.log.warn(`✖ ${e.message}`);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** Always-on service sub-menu. */
|
|
257
|
+
async function serviceMenu() {
|
|
258
|
+
const sub = await p.select({
|
|
259
|
+
message: 'Always-on background service (systemd user unit / cron fallback)',
|
|
260
|
+
options: [
|
|
261
|
+
{ value: 'install', label: '✅ Install service' },
|
|
262
|
+
{ value: 'remove', label: '❌ Remove service' },
|
|
263
|
+
{ value: 'back', label: '← Back' },
|
|
264
|
+
],
|
|
265
|
+
});
|
|
266
|
+
if (p.isCancel(sub) || sub === 'back') return;
|
|
267
|
+
if (sub === 'install') {
|
|
268
|
+
const backend = await installService();
|
|
269
|
+
p.log.success(`✔ Always-on service installed via ${pc.cyan(backend)}.`);
|
|
270
|
+
} else {
|
|
271
|
+
await removeService();
|
|
272
|
+
p.log.success('✔ Service removed.');
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** Daemon sub-menu. */
|
|
277
|
+
async function daemonMenu() {
|
|
278
|
+
const sub = await p.select({
|
|
279
|
+
message: `Daemon is currently ${daemonRunning() ? pc.green('running') : pc.yellow('stopped')}`,
|
|
280
|
+
options: [
|
|
281
|
+
{ value: 'start', label: '▶️ Start daemon' },
|
|
282
|
+
{ value: 'stop', label: '⏹ Stop daemon' },
|
|
283
|
+
{ value: 'status', label: '📊 Show status' },
|
|
284
|
+
{ value: 'back', label: '← Back' },
|
|
285
|
+
],
|
|
286
|
+
});
|
|
287
|
+
if (p.isCancel(sub) || sub === 'back') return;
|
|
288
|
+
if (sub === 'start') {
|
|
289
|
+
const res = await startDaemon();
|
|
290
|
+
if (res.started) p.log.success(`✔ Daemon started (pid ${res.pid}).`);
|
|
291
|
+
else p.log.message(pc.yellow(`Daemon ${res.reason || 'already running'}.`));
|
|
292
|
+
} else if (sub === 'stop') {
|
|
293
|
+
const res = await stopDaemon();
|
|
294
|
+
if (res.stopped) p.log.success('✔ Daemon stopped.');
|
|
295
|
+
else p.log.message(pc.yellow(`Daemon ${res.reason || 'not running'}.`));
|
|
296
|
+
} else {
|
|
297
|
+
p.log.message(`Daemon: ${daemonRunning() ? pc.green('running') : pc.yellow('not running')}`);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
/**
|
|
301
|
+
* Main wizard — menu loop. Only Exit / Ctrl+C leaves it; saying "No" to any
|
|
302
|
+
* prompt just returns you to this menu.
|
|
303
|
+
*/
|
|
304
|
+
export async function runWizard() {
|
|
305
|
+
p.intro(pc.bgYellow(pc.black(` 🦜 parrot-blackbox v${pkg.version} `)));
|
|
306
|
+
|
|
307
|
+
if (!process.stdin.isTTY) {
|
|
308
|
+
p.log.warn('No interactive terminal detected — run subcommands directly: `parrot-blackbox help`');
|
|
309
|
+
p.outro('Bye! 👋');
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// Automatic update check (latest always fetched from npm).
|
|
314
|
+
await autoUpdateCheck();
|
|
315
|
+
|
|
316
|
+
for (;;) {
|
|
317
|
+
const action = await p.select({
|
|
318
|
+
message: 'What would you like to do?',
|
|
319
|
+
options: [
|
|
320
|
+
{ value: 'add', label: '➕ Add cloud account (MEGA / Google Drive)', hint: 'sets up rclone for you' },
|
|
321
|
+
{ value: 'accounts', label: '🗂 Storage pool', hint: 'list / add / remove / quota' },
|
|
322
|
+
{ value: 'tools', label: '🛠 Check & install tools', hint: 'rclone, timeshift, git, curl' },
|
|
323
|
+
{ value: 'snapshot', label: '📸 Snapshot now', hint: 'create + upload a weekly snapshot' },
|
|
324
|
+
{ value: 'backup', label: '💾 Run backup now', hint: 'every enabled job (default = snapshot)' },
|
|
325
|
+
{ value: 'list', label: '📋 List backups', hint: 'local + cloud snapshots, file backups' },
|
|
326
|
+
{ value: 'restore', label: '♻️ Restore', hint: 'files or system snapshot' },
|
|
327
|
+
{ value: 'service', label: '⏱ Always-on service', hint: 'install / remove' },
|
|
328
|
+
{ value: 'daemon', label: '🐚 Daemon', hint: 'start / stop / status' },
|
|
329
|
+
{ value: 'setup', label: '🧭 Guided setup', hint: 'walk every setup step' },
|
|
330
|
+
{ value: 'status', label: '📊 Status', hint: 'quick overview' },
|
|
331
|
+
{ value: 'doctor', label: '🩺 Doctor', hint: 'full diagnostics' },
|
|
332
|
+
{ value: 'repair', label: '🛠 Repair broken install', hint: 'check tools, config, service, pool' },
|
|
333
|
+
{ value: 'update', label: '🔄 Update parrot-blackbox', hint: 'check npm & install latest' },
|
|
334
|
+
{ value: 'uninstall', label: '🗑 Uninstall', hint: 'remove everything (cloud kept)' },
|
|
335
|
+
{ value: 'exit', label: '📴 Exit', hint: 'leave the wizard' },
|
|
336
|
+
],
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
if (p.isCancel(action) || action === 'exit') {
|
|
340
|
+
p.outro('Bye! 👋');
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
try {
|
|
345
|
+
switch (action) {
|
|
346
|
+
case 'add': await addAccountAction(); break;
|
|
347
|
+
case 'accounts': await accountsMenu(); break;
|
|
348
|
+
case 'tools': await runToolsCheck(); break;
|
|
349
|
+
case 'snapshot': await snapshotNowAction(); break;
|
|
350
|
+
case 'backup': await backupNowAction(); break;
|
|
351
|
+
case 'list': await listBackupsAction(); break;
|
|
352
|
+
case 'restore': await restoreMenu(); break;
|
|
353
|
+
case 'service': await serviceMenu(); break;
|
|
354
|
+
case 'daemon': await daemonMenu(); break;
|
|
355
|
+
case 'setup': await runSetup(); break;
|
|
356
|
+
case 'status': await runStatus(); break;
|
|
357
|
+
case 'doctor': await runDoctor(); break;
|
|
358
|
+
case 'repair': { const { runRepair } = await import('./manage.js'); await runRepair(); break; }
|
|
359
|
+
case 'update': { const { runSelfUpdate } = await import('../lib/self.js'); await runSelfUpdate(); break; }
|
|
360
|
+
case 'uninstall': await runUninstallWizard(); p.outro('parrot-blackbox removed — cloud backups are safe.'); return;
|
|
361
|
+
default: break;
|
|
362
|
+
}
|
|
363
|
+
} catch (e) {
|
|
364
|
+
p.log.warn(`✖ ${e.message}`);
|
|
365
|
+
}
|
|
366
|
+
p.log.message(pc.dim('──────────────────────────────────────────'));
|
|
367
|
+
}
|
|
368
|
+
}
|
package/src/lib/self.js
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Self-update — mirrors the gitswitch/theamify/warp-wizard pattern.
|
|
3
|
+
*
|
|
4
|
+
* The LATEST version always comes from the npm registry (`npm view`), never
|
|
5
|
+
* from local state, so stale installs are caught and updated on launch.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { execa } from 'execa';
|
|
9
|
+
import pc from 'picocolors';
|
|
10
|
+
import { createRequire } from 'node:module';
|
|
11
|
+
|
|
12
|
+
const require = createRequire(import.meta.url);
|
|
13
|
+
const pkg = require('../../package.json');
|
|
14
|
+
|
|
15
|
+
export const NPM_NAME = pkg.name; // parrot-blackbox
|
|
16
|
+
|
|
17
|
+
export function compareVersions(a, b) {
|
|
18
|
+
const pa = String(a || '').split('.').map((n) => parseInt(n, 10) || 0);
|
|
19
|
+
const pb = String(b || '').split('.').map((n) => parseInt(n, 10) || 0);
|
|
20
|
+
for (let i = 0; i < 3; i++) {
|
|
21
|
+
const d = (pa[i] || 0) - (pb[i] || 0);
|
|
22
|
+
if (d !== 0) return d < 0 ? -1 : 1;
|
|
23
|
+
}
|
|
24
|
+
return 0;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Query npm for the latest published version of this package (network). */
|
|
28
|
+
export async function getLatestVersion() {
|
|
29
|
+
try {
|
|
30
|
+
const res = await execa('npm', ['view', NPM_NAME, 'version'], { reject: false });
|
|
31
|
+
const v = (res.stdout || '').trim();
|
|
32
|
+
return /^\d+\.\d+\.\d+/.test(v) ? v : null;
|
|
33
|
+
} catch {
|
|
34
|
+
return null; // offline / npm missing — never crash the wizard on this
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Compare the running local version to the latest published one. */
|
|
39
|
+
export async function checkForUpdate() {
|
|
40
|
+
const latest = await getLatestVersion();
|
|
41
|
+
if (!latest) return { outdated: false, latest: null, current: pkg.version };
|
|
42
|
+
return { outdated: compareVersions(latest, pkg.version) > 0, latest, current: pkg.version };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Offer to self-update via `npm install -g`. Returns true when updated.
|
|
47
|
+
* Interactive; nothing happens when not on a TTY.
|
|
48
|
+
*/
|
|
49
|
+
export async function promptSelfUpdate() {
|
|
50
|
+
const { outdated, latest, current } = await checkForUpdate();
|
|
51
|
+
if (!outdated || !latest) return false;
|
|
52
|
+
|
|
53
|
+
const p = await import('@clack/prompts');
|
|
54
|
+
if (!process.stdin.isTTY) {
|
|
55
|
+
p.log.message(pc.dim(`Update available: v${latest} (you have v${current}). Run: npm install -g ${NPM_NAME}@latest`));
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
const want = await p.confirm({
|
|
59
|
+
message: `A new version (${pc.cyan('v' + latest)}) is available — you have ${pc.dim('v' + current)}. Update now?`,
|
|
60
|
+
initialValue: true,
|
|
61
|
+
});
|
|
62
|
+
if (p.isCancel(want) || !want) {
|
|
63
|
+
p.log.message(pc.dim(`Keeping v${current} — update later with: npm install -g ${NPM_NAME}@latest`));
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Release the terminal so npm's progress & any prompts are visible/interruptible.
|
|
68
|
+
console.log();
|
|
69
|
+
const res = await execa('npm', ['install', '-g', `${NPM_NAME}@latest`], { stdio: 'inherit', reject: false });
|
|
70
|
+
if (res.exitCode !== 0) {
|
|
71
|
+
p.log.warn('Update failed. You can retry with: npm install -g ' + NPM_NAME + '@latest');
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
p.log.success(`Updated to v${latest}. ` + 'Run `parrot-blackbox` again to use the new version.');
|
|
75
|
+
return true;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* `update` command — always check npm and report, prompt to install when
|
|
80
|
+
* outdated (or when `force` is set even if already current).
|
|
81
|
+
*/
|
|
82
|
+
export async function runSelfUpdate({ force = false } = {}) {
|
|
83
|
+
const p = await import('@clack/prompts');
|
|
84
|
+
const { latest, current } = await checkForUpdate();
|
|
85
|
+
if (!latest) {
|
|
86
|
+
p.log.warn(`Could not reach the npm registry — you are on v${current}. Try: npm install -g ${NPM_NAME}@latest`);
|
|
87
|
+
return false;
|
|
88
|
+
}
|
|
89
|
+
const outdated = compareVersions(latest, current) > 0;
|
|
90
|
+
if (!outdated && !force) p.log.success(`You are on the latest version (v${current}).`);
|
|
91
|
+
if (!outdated && !force) return false;
|
|
92
|
+
if (outdated) p.log.info(`${pc.cyan('v' + latest)} available — you have ${pc.dim('v' + current)}.`);
|
|
93
|
+
if (!process.stdin.isTTY) {
|
|
94
|
+
p.log.message(outdated
|
|
95
|
+
? pc.dim(`Update to v${latest} with: npm install -g ${NPM_NAME}@latest`)
|
|
96
|
+
: pc.dim(`You are on v${current}. Run: npm install -g ${NPM_NAME}@latest --force to reinstall`));
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
99
|
+
const want = await p.confirm({ message: `Update parrot-blackbox to v${latest} now?`, initialValue: true });
|
|
100
|
+
if (p.isCancel(want) || !want) { p.log.message(pc.dim('Update skipped.')); return false; }
|
|
101
|
+
console.log();
|
|
102
|
+
const res = await execa('npm', ['install', '-g', `${NPM_NAME}@latest`], { stdio: 'inherit', reject: false });
|
|
103
|
+
if (res.exitCode !== 0) {
|
|
104
|
+
p.log.warn('Update failed. You can retry with: npm install -g ' + NPM_NAME + '@latest');
|
|
105
|
+
return false;
|
|
106
|
+
}
|
|
107
|
+
p.log.success(`Updated to v${latest}. Restart parrot-blackbox to use the new version.`);
|
|
108
|
+
return true;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Fully remove the npm package (used by uninstall). */
|
|
112
|
+
export async function selfUninstall() {
|
|
113
|
+
const p = await import('@clack/prompts');
|
|
114
|
+
console.log();
|
|
115
|
+
const res = await execa('npm', ['uninstall', '-g', NPM_NAME], { stdio: 'inherit', reject: false });
|
|
116
|
+
if (res.exitCode === 0) {
|
|
117
|
+
p.log.success(`${NPM_NAME} removed. The parrot-blackbox command is no longer available.`);
|
|
118
|
+
return true;
|
|
119
|
+
}
|
|
120
|
+
p.log.warn(`Could not uninstall ${NPM_NAME} automatically. Run: npm uninstall -g ${NPM_NAME}`);
|
|
121
|
+
return false;
|
|
122
|
+
}
|