parrot-blackbox 1.0.2 → 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
 
@@ -403,7 +403,11 @@ y/e> y ⬅ y confirms permanent deletion
403
403
 
404
404
  | Command | What it does |
405
405
  |---|---|
406
- | `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) |
407
411
  | `parrot-blackbox run` | Run any due/pending backups now (safe for cron) |
408
412
  | `parrot-blackbox force` | ⭐ Run every enabled backup NOW (default = weekly snapshot) `[sudo]` |
409
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.2",
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
+ }