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 +15 -11
- 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
|
|
|
@@ -403,7 +403,11 @@ y/e> y ⬅ y confirms permanent deletion
|
|
|
403
403
|
|
|
404
404
|
| Command | What it does |
|
|
405
405
|
|---|---|
|
|
406
|
-
| `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) |
|
|
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.
|
|
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
|
+
}
|