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 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 the setup wizard it does the whole install for you:
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
- **What the wizard does, start to finish:**
77
- 1. **Checks + auto-installs** `rclone`, `timeshift`, `git`, `curl` (interactive
78
- sudo prompt, the gitswitch/theamify way).
79
- 2. **Adds your cloud accounts for you** pick MEGA or Google Drive and enter
80
- your email/password; parrot-blackbox creates the rclone remote and registers
81
- it in the pool. No raw 68-option `rclone config` menu needed.
82
- 3. Confirms the **schedule** (snapshot every Saturday 22:00).
83
- 4. Installs the **always-on service** (systemd / cron).
84
- 5. Offers to run your **first snapshot** right away.
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
- `parrot-blackbox` manages them for you — you never need to use the raw rclone
114
- menu except for advanced edits.
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` | Interactive setup wizard (checks & **auto-installs** needed tools) |
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.1",
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 Interactive setup wizard (checks & installs tools)
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 runSetup();
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 runSetup();
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');
@@ -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
- p.outro(pc.green('Uninstalled. Cloud backups remain safe in your accounts.'));
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
+ }
@@ -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
+ }