bloby-bot 0.66.1 → 0.67.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/cli.js CHANGED
@@ -1,6 +1,29 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- import { spawn, execSync, spawnSync } from 'child_process';
3
+ // ─────────────────────────────────────────────────────────────────────────────
4
+ // Bloby CLI — single entry point for the bloby command.
5
+ //
6
+ // Design rules (load-bearing — do not break):
7
+ // - ZERO npm deps at top level. This file must run even when ~/.bloby's
8
+ // node_modules is broken, because it IS the recovery tool (self-heal below).
9
+ // - Every command daemonizes and returns the terminal. The only foreground
10
+ // paths are `start --foreground` (debug) and platforms with no daemon
11
+ // support (Windows, containers without systemd) — incl. `init --hosted`.
12
+ // - The supervisor writes ~/.bloby/supervisor.json at boot (pid/startedAt/
13
+ // version/port). That pidfile — not launchctl/systemctl alone — is the
14
+ // source of truth for "is Bloby running", so status/logs can never show a
15
+ // process that isn't alive anymore, regardless of how it was launched.
16
+ // - Contracts preserved for external callers:
17
+ // supervisor self-update: `cli.js update` + BLOBY_SELF_UPDATE=1 → exit 0
18
+ // on success-or-already-latest, never touches the daemon, headless-safe.
19
+ // supervisor relaunch: `cli.js daemon restart` detached, no TTY.
20
+ // hosted provisioning: `cli.js init --hosted` → __HOSTED_READY__=<json>.
21
+ // foreground boot parses supervisor stdout markers __READY__,
22
+ // __TUNNEL_URL__=, __RELAY_URL__=, __VITE_WARM__, __TUNNEL_FAILED__.
23
+ // env: BLOBY_NODE_PATH, BLOBY_REAL_HOME, SUDO_USER, BLOBY_SELF_UPDATE.
24
+ // ─────────────────────────────────────────────────────────────────────────────
25
+
26
+ import { spawn, spawnSync, execSync, execFileSync } from 'child_process';
4
27
  import fs from 'fs';
5
28
  import path from 'path';
6
29
  import os from 'os';
@@ -10,12 +33,35 @@ import { fileURLToPath, pathToFileURL } from 'url';
10
33
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
11
34
 
12
35
  const REPO_ROOT = path.resolve(__dirname, '..');
13
- const DATA_DIR = path.join(process.env.BLOBY_REAL_HOME || os.homedir(), '.bloby');
36
+
37
+ // Resolve the real user's home even when invoked as `sudo bloby ...` directly —
38
+ // otherwise DATA_DIR silently becomes /root/.bloby and state forks.
39
+ function resolveRealHome() {
40
+ if (process.env.BLOBY_REAL_HOME) return process.env.BLOBY_REAL_HOME;
41
+ if (process.getuid?.() === 0 && process.env.SUDO_USER) {
42
+ if (os.platform() === 'darwin') {
43
+ // getent doesn't exist on macOS — dscl is the equivalent.
44
+ try {
45
+ const out = execFileSync('dscl', ['.', '-read', `/Users/${process.env.SUDO_USER}`, 'NFSHomeDirectory'], { encoding: 'utf-8' });
46
+ const home = out.split(':').pop()?.trim();
47
+ if (home) return home;
48
+ } catch {}
49
+ return `/Users/${process.env.SUDO_USER}`;
50
+ }
51
+ try {
52
+ return execFileSync('getent', ['passwd', process.env.SUDO_USER], { encoding: 'utf-8' }).split(':')[5].trim();
53
+ } catch {}
54
+ }
55
+ return os.homedir();
56
+ }
57
+
58
+ const DATA_DIR = path.join(resolveRealHome(), '.bloby');
14
59
  const IS_DEV = fs.existsSync(path.join(REPO_ROOT, '.git'));
15
60
  const ROOT = IS_DEV ? REPO_ROOT : DATA_DIR;
16
61
  const CONFIG_PATH = path.join(DATA_DIR, 'config.json');
17
62
  const BIN_DIR = path.join(DATA_DIR, 'bin');
18
63
  const CF_PATH = path.join(BIN_DIR, 'cloudflared');
64
+ const RUNTIME_PATH = path.join(DATA_DIR, 'supervisor.json');
19
65
 
20
66
  // claude-agent-sdk 0.3.x moved @anthropic-ai/sdk + @modelcontextprotocol/sdk to
21
67
  // peerDependencies. Upgrading an existing ~/.bloby in place (the old 0.2.x tree had
@@ -67,18 +113,372 @@ if (!IS_DEV) {
67
113
  }
68
114
 
69
115
  const pkg = JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf-8'));
70
- const args = process.argv.slice(2);
71
- const command = args[0];
72
- const subcommand = args[1];
73
- const flags = new Set(args.filter(a => a.startsWith('--')));
116
+
117
+ // ── Argv parsing (strict — see router at the bottom) ──
118
+
119
+ const argv = process.argv.slice(2);
120
+ // Global flags can appear anywhere (legacy callers do `bloby --hosted`).
121
+ // x402 owns its own flags, so everything after `x402` passes through verbatim.
122
+ const x402Index = argv.indexOf('x402');
123
+ const globalArgs = x402Index === -1 ? argv : argv.slice(0, x402Index + 1);
124
+ const flags = new Set(globalArgs.filter(a => a.startsWith('-')));
74
125
  const HOSTED = flags.has('--hosted');
126
+ const FOREGROUND = flags.has('--foreground');
127
+ const FOLLOW = flags.has('-f') || flags.has('--follow');
128
+ const positional = globalArgs.filter(a => !a.startsWith('-'));
129
+ const command = positional[0];
130
+ const subcommand = positional[1];
131
+
132
+ // Strict parsing covers flags too — a typo like --forground must not silently
133
+ // fall through to a different behavior (x402's own flags pass through verbatim).
134
+ const KNOWN_FLAGS = new Set(['--hosted', '--foreground', '-f', '--follow', '-n', '--lines', '-h', '--help', '-v', '--version']);
135
+ for (const f of flags) {
136
+ const name = f.includes('=') ? f.slice(0, f.indexOf('=')) : f;
137
+ if (!KNOWN_FLAGS.has(name)) {
138
+ console.error(`\n ✗ Unknown flag: ${f}\n Run bloby help for usage.\n`);
139
+ process.exit(2);
140
+ }
141
+ }
142
+
143
+ function flagValue(name) {
144
+ for (let i = 0; i < globalArgs.length; i++) {
145
+ const a = globalArgs[i];
146
+ if (a === name) return globalArgs[i + 1] ?? null;
147
+ if (a.startsWith(name + '=')) return a.slice(name.length + 1);
148
+ }
149
+ return null;
150
+ }
151
+
152
+ // ── UI kit ──
153
+ // Style contract: identical to scripts/install.sh — 2-space indent, ' <glyph> text'
154
+ // rhythm, BLUE for success/commands, deep-blue PINK only for ready-lines and '>',
155
+ // '!' YELLOW for warnings, 29-char DIM dividers, sequential step lines (no
156
+ // full-screen repaints). Colors gate on TTY + NO_COLOR so pipes stay clean.
157
+
158
+ const FORCE_COLOR = process.env.FORCE_COLOR;
159
+ const useColor = FORCE_COLOR && FORCE_COLOR !== '0'
160
+ ? true
161
+ : (!!process.stdout.isTTY && !('NO_COLOR' in process.env) && process.env.TERM !== 'dumb');
162
+ const isTTY = !!process.stdout.isTTY;
163
+
164
+ // Animations + cursor control need a real terminal — TERM=dumb gets plain lines.
165
+ const fancyTTY = isTTY && process.env.TERM !== 'dumb';
166
+
167
+ const A = (s) => (useColor ? s : '');
168
+ const c = {
169
+ reset: A('\x1b[0m'),
170
+ dim: A('\x1b[2m'),
171
+ bold: A('\x1b[1m'),
172
+ green: A('\x1b[32m'),
173
+ cyan: A('\x1b[36m'),
174
+ yellow: A('\x1b[33m'),
175
+ red: A('\x1b[31m'),
176
+ white: A('\x1b[97m'),
177
+ blue: A('\x1b[38;2;0;173;254m'),
178
+ pink: A('\x1b[38;2;1;88;251m'),
179
+ g1: A('\x1b[38;2;0;173;254m'),
180
+ g2: A('\x1b[38;2;0;159;254m'),
181
+ g3: A('\x1b[38;2;0;145;253m'),
182
+ g4: A('\x1b[38;2;1;131;253m'),
183
+ g5: A('\x1b[38;2;1;116;252m'),
184
+ g6: A('\x1b[38;2;1;102;251m'),
185
+ g7: A('\x1b[38;2;1;88;251m'),
186
+ };
187
+
188
+ const GLYPH = {
189
+ ok: `${c.blue}✔${c.reset}`,
190
+ err: `${c.red}✗${c.reset}`,
191
+ warn: `${c.yellow}!${c.reset}`,
192
+ dl: `${c.blue}↓${c.reset}`,
193
+ next: `${c.pink}>${c.reset}`,
194
+ };
195
+ const DIVIDER = ` ${c.dim}${'─'.repeat(29)}${c.reset}`;
196
+ const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
197
+
198
+ function link(url) {
199
+ return isTTY && useColor ? `\x1b]8;;${url}\x07${url}\x1b]8;;\x07` : url;
200
+ }
201
+
202
+ // Single terminal-cleanup path: restore cursor + raw mode, stop any spinner.
203
+ // Registered once; every exit route (normal, SIGINT, SIGTERM, crash) goes through it.
204
+ let activeSpinner = null;
205
+ let foregroundChild = null; // set by bootServer — the global handlers must kill it before exiting
206
+ function restoreTerminal() {
207
+ if (activeSpinner) { try { activeSpinner.stopRaw(); } catch {} activeSpinner = null; }
208
+ if (fancyTTY) process.stdout.write('\x1b[?25h');
209
+ if (process.stdin.isTTY) { try { process.stdin.setRawMode(false); } catch {} }
210
+ }
211
+ process.on('exit', restoreTerminal);
212
+ process.on('SIGINT', () => { restoreTerminal(); try { foregroundChild?.kill('SIGINT'); } catch {} process.exit(130); });
213
+ process.on('SIGTERM', () => { restoreTerminal(); try { foregroundChild?.kill('SIGTERM'); } catch {} process.exit(143); });
214
+ process.on('uncaughtException', (err) => {
215
+ restoreTerminal();
216
+ console.error(`\n ${GLYPH.err} ${err && err.message ? err.message : err}\n`);
217
+ process.exit(1);
218
+ });
219
+ process.on('unhandledRejection', (reason) => {
220
+ restoreTerminal();
221
+ console.error(`\n ${GLYPH.err} ${reason && reason.message ? reason.message : reason}\n`);
222
+ process.exit(1);
223
+ });
224
+
225
+ /** One-line spinner. On a TTY it animates in place; on pipes/logs it prints plain
226
+ * sequential lines so headless callers (self-update → update.log) get clean text. */
227
+ class Spinner {
228
+ constructor() { this.text = ''; this.timer = null; this.frame = 0; this.live = false; }
229
+ start(text) {
230
+ this.text = text;
231
+ if (fancyTTY) {
232
+ activeSpinner = this;
233
+ this.live = true;
234
+ process.stdout.write('\x1b[?25l');
235
+ this.timer = setInterval(() => {
236
+ this.frame = (this.frame + 1) % SPINNER_FRAMES.length;
237
+ this.render();
238
+ }, 80);
239
+ this.render();
240
+ } else {
241
+ console.log(` … ${text}`);
242
+ }
243
+ return this;
244
+ }
245
+ render() {
246
+ if (!this.live) return;
247
+ process.stdout.write(`\r\x1b[2K ${c.pink}${SPINNER_FRAMES[this.frame]}${c.reset} ${this.text}`);
248
+ }
249
+ update(text) {
250
+ this.text = text;
251
+ if (this.live) this.render();
252
+ else console.log(` … ${text}`);
253
+ return this;
254
+ }
255
+ stopRaw() {
256
+ if (this.timer) { clearInterval(this.timer); this.timer = null; }
257
+ if (this.live) {
258
+ process.stdout.write('\r\x1b[2K');
259
+ process.stdout.write('\x1b[?25h');
260
+ this.live = false;
261
+ }
262
+ if (activeSpinner === this) activeSpinner = null;
263
+ }
264
+ /** Pause the animation (e.g. while sudo prompts on the same terminal), then resume(). */
265
+ pause() { const t = this.text; this.stopRaw(); this.text = t; return this; }
266
+ resume() { if (fancyTTY && !this.live) this.start(this.text); return this; }
267
+ succeed(text) { this.stopRaw(); console.log(` ${GLYPH.ok} ${text ?? this.text}`); return this; }
268
+ fail(text) { this.stopRaw(); console.log(` ${GLYPH.err} ${text ?? this.text}`); return this; }
269
+ warn(text) { this.stopRaw(); console.log(` ${GLYPH.warn} ${text ?? this.text}`); return this; }
270
+ info(text) { this.stopRaw(); console.log(` ${c.dim}${text ?? this.text}${c.reset}`); return this; }
271
+ }
272
+
273
+ function banner() {
274
+ if (HOSTED) return;
275
+ console.log(`
276
+ ${c.g1}${c.bold} █▄ ${c.reset}
277
+ ${c.g2}${c.bold} ▄ ▄ ██ ${c.reset}
278
+ ${c.g3}${c.bold} ███▄███▄ ▄███▄ ████▄████▄ ████▄ ██ ██${c.reset}
279
+ ${c.g4}${c.bold} ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██▄██${c.reset}
280
+ ${c.g5}${c.bold} ▄██ ██ ▀█▄▀███▀▄█▀ ▄████▀▄██ ██▄▄▀██▀${c.reset}
281
+ ${c.g6}${c.bold} ██ ██ ${c.reset}
282
+ ${c.g7}${c.bold} ▀ ▀▀▀ ${c.reset}
283
+ ${c.dim}v${pkg.version} · Self-hosted, self-evolving AI agent${c.reset}`);
284
+ }
285
+
286
+ const CORE_COMMANDS = [
287
+ ['start', 'Start Bloby (runs in the background)'],
288
+ ['stop', 'Stop Bloby'],
289
+ ['restart', 'Restart Bloby'],
290
+ ['status', 'Status, URLs and health'],
291
+ ['logs', 'Recent logs (-f to follow)'],
292
+ ['update', 'Update to the latest version'],
293
+ ['help', 'All commands'],
294
+ ];
295
+
296
+ /** Printed after every command (TTY only) — the user asked for the command list
297
+ * to always be visible when a command finishes. */
298
+ function commandsFooter() {
299
+ if (!isTTY || HOSTED) return;
300
+ console.log('');
301
+ console.log(DIVIDER);
302
+ console.log(` ${c.bold}Commands:${c.reset}\n`);
303
+ for (const [cmd, desc] of CORE_COMMANDS) {
304
+ console.log(` ${c.blue}${('bloby ' + cmd).padEnd(16)}${c.reset}${desc}`);
305
+ }
306
+ console.log('');
307
+ }
308
+
309
+ function printHelp() {
310
+ banner();
311
+ console.log(`
312
+ ${c.bold}Usage:${c.reset} bloby <command>
313
+
314
+ ${c.bold}Core${c.reset}
315
+ ${c.blue}${'init'.padEnd(18)}${c.reset}First-time setup (config, wallet, tunnel)
316
+ ${c.blue}${'start'.padEnd(18)}${c.reset}Start Bloby in the background
317
+ ${c.blue}${'stop'.padEnd(18)}${c.reset}Stop Bloby
318
+ ${c.blue}${'restart'.padEnd(18)}${c.reset}Restart Bloby (same as stop + start)
319
+ ${c.blue}${'status'.padEnd(18)}${c.reset}Status, URLs and health
320
+ ${c.blue}${'logs'.padEnd(18)}${c.reset}Show recent logs (${c.dim}-f${c.reset} follow, ${c.dim}-n <num>${c.reset} lines)
321
+ ${c.blue}${'update'.padEnd(18)}${c.reset}Update to the latest version
322
+
323
+ ${c.bold}Advanced${c.reset}
324
+ ${c.blue}${'tunnel'.padEnd(18)}${c.reset}Tunnel config (setup, status, reset)
325
+ ${c.blue}${'daemon <action>'.padEnd(18)}${c.reset}install · start · stop · restart · status · logs · uninstall
326
+ ${c.blue}${'password-reset'.padEnd(18)}${c.reset}Reset the dashboard password
327
+ ${c.blue}${'x402 <url>'.padEnd(18)}${c.reset}Pay an x402-protected endpoint (USDC on Base)
328
+ ${c.blue}${'version'.padEnd(18)}${c.reset}Print the installed version
329
+
330
+ ${c.bold}Flags${c.reset}
331
+ ${c.dim}start --foreground${c.reset} Run attached to this terminal (debugging)
332
+ `);
333
+ }
334
+
335
+ // Zero-dep Levenshtein for "did you mean" (two-row DP).
336
+ function levenshtein(a, b) {
337
+ if (a === b) return 0;
338
+ let prev = Array.from({ length: b.length + 1 }, (_, i) => i);
339
+ for (let i = 1; i <= a.length; i++) {
340
+ const cur = [i];
341
+ for (let j = 1; j <= b.length; j++) {
342
+ cur[j] = Math.min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
343
+ }
344
+ prev = cur;
345
+ }
346
+ return prev[b.length];
347
+ }
348
+
349
+ function suggestCommand(input) {
350
+ const candidates = ['init', 'start', 'stop', 'restart', 'status', 'logs', 'update', 'daemon', 'tunnel', 'password-reset', 'x402', 'help', 'version'];
351
+ const lower = input.toLowerCase();
352
+ const maxDist = lower.length <= 4 ? 1 : 2;
353
+ let best = null;
354
+ let bestDist = Infinity;
355
+ for (const cand of candidates) {
356
+ const d = levenshtein(lower, cand);
357
+ if (d < bestDist) { bestDist = d; best = cand; }
358
+ }
359
+ return bestDist <= maxDist ? best : null;
360
+ }
361
+
362
+ function unknownCommand(input) {
363
+ console.log(`\n ${GLYPH.err} Unknown command: ${c.bold}${input}${c.reset}`);
364
+ const suggestion = suggestCommand(input);
365
+ if (suggestion) {
366
+ console.log(` Did you mean ${c.blue}bloby ${suggestion}${c.reset}?`);
367
+ }
368
+ commandsFooter();
369
+ process.exit(2);
370
+ }
371
+
372
+ // ── Config helpers ──
373
+
374
+ function readConfig() {
375
+ try { return JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8')); } catch { return null; }
376
+ }
377
+
378
+ function tunnelModeOf(config) {
379
+ return config?.tunnel?.mode ?? (config?.tunnel?.enabled === false ? 'off' : 'quick');
380
+ }
381
+
382
+ function writeVersionFile(version) {
383
+ try { fs.writeFileSync(path.join(DATA_DIR, 'VERSION'), version); } catch {}
384
+ }
385
+
386
+ // ── Process / pidfile helpers (the single source of truth for "is Bloby running") ──
387
+
388
+ function procAlive(pid) {
389
+ if (!pid) return false;
390
+ try { process.kill(pid, 0); return true; } catch { return false; }
391
+ }
392
+
393
+ function procCommand(pid) {
394
+ if (PLATFORM === 'win32') return '';
395
+ try {
396
+ return execFileSync('ps', ['-p', String(pid), '-o', 'command='], { encoding: 'utf-8' }).trim();
397
+ } catch { return ''; }
398
+ }
399
+
400
+ /** Match only THIS install's supervisor — other agents on the same machine may
401
+ * run an identical supervisor/index.ts layout from their own directories, and
402
+ * a generic match would make `bloby stop` kill them. */
403
+ function isSupervisorCommand(cmd) {
404
+ if (!/supervisor[\\/]index\.ts/.test(cmd)) return false;
405
+ const ours = [
406
+ path.join(ROOT, 'supervisor'),
407
+ path.join(DATA_DIR, 'supervisor'),
408
+ path.join(REPO_ROOT, 'supervisor'),
409
+ ];
410
+ // Anchor at a path boundary so a sibling dir that merely starts with our path can't match.
411
+ return ours.some(p => cmd.includes(p + path.sep));
412
+ }
413
+
414
+ /** Read ~/.bloby/supervisor.json (written by the supervisor at boot) and validate the
415
+ * pid is alive AND still a supervisor process (defends against PID reuse). */
416
+ function readRuntime() {
417
+ try {
418
+ const data = JSON.parse(fs.readFileSync(RUNTIME_PATH, 'utf-8'));
419
+ if (!data || !data.pid) return null;
420
+ if (!procAlive(data.pid)) return null;
421
+ // Defend against PID reuse — but only when we can actually inspect the
422
+ // command line (win32 / locked-down ps return '': trust liveness alone).
423
+ const cmd = procCommand(data.pid);
424
+ if (cmd && !isSupervisorCommand(cmd)) return null;
425
+ return data;
426
+ } catch { return null; }
427
+ }
428
+
429
+ /** Find every live supervisor process (daemon, foreground, orphans) by scanning ps.
430
+ * This is what lets stop/restart consolidate duplicate instances. */
431
+ function scanSupervisors() {
432
+ if (PLATFORM === 'win32') return [];
433
+ try {
434
+ const out = execFileSync('ps', ['ax', '-o', 'pid=,command='], { encoding: 'utf-8' });
435
+ return out.split('\n')
436
+ .map(l => l.trim())
437
+ .filter(Boolean)
438
+ .map(l => {
439
+ const sp = l.indexOf(' ');
440
+ return { pid: Number(l.slice(0, sp)), command: l.slice(sp + 1).trim() };
441
+ })
442
+ .filter(p => p.pid !== process.pid && isSupervisorCommand(p.command));
443
+ } catch { return []; }
444
+ }
445
+
446
+ function formatUptime(ms) {
447
+ const s = Math.floor(ms / 1000);
448
+ if (s < 60) return `${s}s`;
449
+ const m = Math.floor(s / 60);
450
+ if (m < 60) return `${m}m ${s % 60}s`;
451
+ const h = Math.floor(m / 60);
452
+ if (h < 24) return `${h}h ${m % 60}m`;
453
+ const d = Math.floor(h / 24);
454
+ return `${d}d ${h % 24}h`;
455
+ }
456
+
457
+ function formatAgo(ms) {
458
+ return formatUptime(ms) + ' ago';
459
+ }
75
460
 
76
- // ── Terminal safety: always restore cursor on exit ──
77
- process.on('exit', () => process.stdout.write('\x1b[?25h'));
78
- process.on('SIGINT', () => { process.stdout.write('\x1b[?25h'); process.exit(0); });
79
- process.on('SIGTERM', () => { process.stdout.write('\x1b[?25h'); process.exit(0); });
461
+ /** > 0 when a is newer than b. Plain x.y.z comparison — good enough for npm dist-tags. */
462
+ function compareVersions(a, b) {
463
+ const pa = String(a).split('.').map(n => parseInt(n, 10) || 0);
464
+ const pb = String(b).split('.').map(n => parseInt(n, 10) || 0);
465
+ for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
466
+ if ((pa[i] || 0) !== (pb[i] || 0)) return (pa[i] || 0) - (pb[i] || 0);
467
+ }
468
+ return 0;
469
+ }
470
+
471
+ async function fetchHealth(port, timeoutMs = 2500) {
472
+ try {
473
+ const res = await fetch(`http://127.0.0.1:${port}/api/health?_cb=${Date.now()}`, {
474
+ signal: AbortSignal.timeout(timeoutMs),
475
+ });
476
+ if (!res.ok) return null;
477
+ return await res.json().catch(() => ({}));
478
+ } catch { return null; }
479
+ }
80
480
 
81
- // ── Daemon constants & helpers ──
481
+ // ── Platform daemon adapters ──
82
482
 
83
483
  const PLATFORM = os.platform();
84
484
 
@@ -87,19 +487,30 @@ const SERVICE_NAME = 'bloby';
87
487
  const SERVICE_PATH = `/etc/systemd/system/${SERVICE_NAME}.service`;
88
488
 
89
489
  function needsSudo() {
90
- return process.getuid() !== 0;
490
+ return process.getuid?.() !== 0;
91
491
  }
92
492
 
93
- function sudoReExec() {
94
- const nodePath = process.env.BLOBY_NODE_PATH || process.execPath;
95
- const realHome = getRealHome();
96
- const args = process.argv.slice(1);
97
- const result = spawnSync('sudo', [
98
- `BLOBY_NODE_PATH=${nodePath}`,
99
- `BLOBY_REAL_HOME=${realHome}`,
100
- nodePath, ...args,
101
- ], { stdio: 'inherit' });
102
- process.exit(result.status ?? 1);
493
+ /** Run one privileged command (systemctl, cp into /etc) without re-execing the
494
+ * whole CLI: root runs it directly; otherwise try passwordless `sudo -n` first,
495
+ * fall back to an interactive prompt on a TTY (pausing any spinner), and fail
496
+ * fast with the exact manual command when headless. This is what lets the
497
+ * supervisor's detached relaunch and `bloby update` work on Linux at all —
498
+ * the old whole-CLI sudo re-exec hung headless and re-ran update as root. */
499
+ function runPrivileged(cmdArgs, { spinner } = {}) {
500
+ if (!needsSudo()) {
501
+ const r = spawnSync(cmdArgs[0], cmdArgs.slice(1), { stdio: 'pipe', encoding: 'utf-8' });
502
+ return { ok: r.status === 0, error: (r.stderr || '').trim() || (r.status !== 0 ? `${cmdArgs.join(' ')} failed (exit ${r.status})` : null) };
503
+ }
504
+ let r = spawnSync('sudo', ['-n', ...cmdArgs], { stdio: 'pipe', encoding: 'utf-8' });
505
+ if (r.status === 0) return { ok: true, error: null };
506
+ if (process.stdin.isTTY && process.stdout.isTTY) {
507
+ if (spinner) spinner.pause();
508
+ console.log(` ${c.dim}sudo is required — you may be asked for your password.${c.reset}`);
509
+ r = spawnSync('sudo', cmdArgs, { stdio: 'inherit' });
510
+ if (spinner) spinner.resume();
511
+ return { ok: r.status === 0, error: r.status === 0 ? null : `sudo ${cmdArgs.join(' ')} failed (exit ${r.status})` };
512
+ }
513
+ return { ok: false, error: `needs sudo and there is no terminal to ask for a password — run: sudo ${cmdArgs.join(' ')}` };
103
514
  }
104
515
 
105
516
  function getRealUser() {
@@ -107,25 +518,7 @@ function getRealUser() {
107
518
  }
108
519
 
109
520
  function getRealHome() {
110
- if (process.env.BLOBY_REAL_HOME) return process.env.BLOBY_REAL_HOME;
111
- try {
112
- return execSync(`getent passwd ${getRealUser()}`, { encoding: 'utf-8' }).split(':')[5];
113
- } catch {
114
- return os.homedir();
115
- }
116
- }
117
-
118
- function isServiceInstalled() {
119
- return fs.existsSync(SERVICE_PATH);
120
- }
121
-
122
- function isServiceActive() {
123
- try {
124
- execSync(`systemctl is-active ${SERVICE_NAME}`, { stdio: 'ignore' });
125
- return true;
126
- } catch {
127
- return false;
128
- }
521
+ return resolveRealHome();
129
522
  }
130
523
 
131
524
  function generateUnitFile({ user, home, nodePath, dataDir }) {
@@ -155,32 +548,74 @@ WantedBy=multi-user.target
155
548
  `;
156
549
  }
157
550
 
551
+ function systemdShow() {
552
+ try {
553
+ const out = execFileSync('systemctl', ['show', SERVICE_NAME, '-p', 'MainPID', '-p', 'ActiveState', '-p', 'LoadState'], { encoding: 'utf-8' });
554
+ const get = (key) => (out.match(new RegExp(`^${key}=(.*)$`, 'm')) || [])[1] || '';
555
+ return { mainPid: Number(get('MainPID')) || 0, activeState: get('ActiveState'), loadState: get('LoadState') };
556
+ } catch {
557
+ return { mainPid: 0, activeState: 'unknown', loadState: 'unknown' };
558
+ }
559
+ }
560
+
158
561
  // --- launchd (macOS) ---
562
+ // Modern launchctl only (bootstrap/bootout/kickstart/print with explicit domain
563
+ // targets). The legacy load/unload/list subcommands ALWAYS exit 0 and resolve the
564
+ // domain from the caller's context — which is how SSH/tmux/detached invocations
565
+ // used to boot duplicate instances and strand stale logs. Exit codes from the
566
+ // modern verbs are real: 3=not loaded, 36/37=op (already) in progress,
567
+ // 113=service not found, 119=disabled (Login Items toggle), 125=bad domain.
159
568
  const LAUNCHD_LABEL = 'com.bloby.bot';
160
569
  const LAUNCHD_PLIST_PATH = path.join(os.homedir(), 'Library', 'LaunchAgents', `${LAUNCHD_LABEL}.plist`);
161
570
  const LAUNCHD_LOG_DIR = path.join(os.homedir(), 'Library', 'Logs', 'bloby');
571
+ const LAUNCHD_LOG = path.join(LAUNCHD_LOG_DIR, 'bloby.log');
572
+
573
+ let _launchdDomain = null;
574
+ function launchdDomain() {
575
+ if (_launchdDomain) return _launchdDomain;
576
+ const uid = process.getuid?.() ?? 501;
577
+ // gui/$UID exists when the user has a console (Aqua) session; user/$UID always
578
+ // exists. Probe once — explicit targets behave identically from GUI terminals,
579
+ // SSH, tmux, and detached children.
580
+ const probe = spawnSync('launchctl', ['print', `gui/${uid}`], { stdio: 'ignore' });
581
+ _launchdDomain = probe.status === 0 ? `gui/${uid}` : `user/${uid}`;
582
+ return _launchdDomain;
583
+ }
162
584
 
163
- function isLaunchdInstalled() {
164
- return fs.existsSync(LAUNCHD_PLIST_PATH);
585
+ function launchctl(args) {
586
+ const res = spawnSync('launchctl', args, { encoding: 'utf-8' });
587
+ return { status: res.status ?? 1, stdout: res.stdout || '', stderr: res.stderr || '' };
165
588
  }
166
589
 
167
- function isLaunchdActive() {
168
- try {
169
- const out = execSync(`launchctl list ${LAUNCHD_LABEL} 2>/dev/null`, { encoding: 'utf-8' });
170
- // launchctl list <label> succeeds if the job is loaded; check PID field
171
- const pidLine = out.split('\n').find(l => l.includes('PID'));
172
- if (pidLine) {
173
- const pid = pidLine.split('=')[1]?.trim();
174
- return pid && pid !== '0' && pid !== '-';
175
- }
176
- // Fallback: if the command succeeded, it's loaded. Check if the process is running.
177
- const lines = out.trim().split('\n');
178
- // First line of `launchctl list <label>` output: "PID\tStatus\tLabel" header or direct values
179
- // The format is: { "PID" = <pid>; "Status" = <code>; ... }
180
- return !out.includes('"PID" = 0;');
181
- } catch {
182
- return false;
183
- }
590
+ /** Job state via `launchctl print <domain>/<label>`. Exit 113 = not loaded.
591
+ * Output is "not API" per the man page, so parse minimally and defensively.
592
+ * loaded is three-state: true / false / 'unknown' (launchctl itself failed)
593
+ * callers must NOT treat 'unknown' as "safe to assume stopped" (log rotation
594
+ * and bootout decisions depend on it). Probes both gui/ and user/ domains: a
595
+ * job bootstrapped over SSH pre-login lands in user/$UID while a console
596
+ * session resolves gui/$UID, and targeting the wrong one strands it. */
597
+ function launchdJobIn(domain) {
598
+ const res = launchctl(['print', `${domain}/${LAUNCHD_LABEL}`]);
599
+ if (res.status === 113) return { loaded: false, pid: null, state: 'not-loaded', domain };
600
+ if (res.status !== 0) return { loaded: 'unknown', pid: null, state: `error-${res.status}`, domain };
601
+ const pidMatch = res.stdout.match(/^\s*pid = (\d+)\s*$/m);
602
+ const stateMatch = res.stdout.match(/^\s*state = (.+?)\s*$/m);
603
+ return {
604
+ loaded: true,
605
+ pid: pidMatch ? Number(pidMatch[1]) : null,
606
+ state: stateMatch ? stateMatch[1] : 'unknown',
607
+ domain,
608
+ };
609
+ }
610
+
611
+ function launchdJob() {
612
+ const uid = process.getuid?.() ?? 501;
613
+ const primary = launchdJobIn(launchdDomain());
614
+ if (primary.loaded === true) return primary;
615
+ const alt = launchdDomain() === `gui/${uid}` ? `user/${uid}` : `gui/${uid}`;
616
+ const secondary = launchdJobIn(alt);
617
+ if (secondary.loaded === true) return secondary;
618
+ return primary; // both not-loaded/unknown — report the preferred domain's view
184
619
  }
185
620
 
186
621
  function generateLaunchdPlist({ nodePath, dataDir }) {
@@ -222,9 +657,9 @@ function generateLaunchdPlist({ nodePath, dataDir }) {
222
657
  <key>ThrottleInterval</key>
223
658
  <integer>5</integer>
224
659
  <key>StandardOutPath</key>
225
- <string>${LAUNCHD_LOG_DIR}/bloby.log</string>
660
+ <string>${LAUNCHD_LOG}</string>
226
661
  <key>StandardErrorPath</key>
227
- <string>${LAUNCHD_LOG_DIR}/bloby.log</string>
662
+ <string>${LAUNCHD_LOG}</string>
228
663
  <key>ProcessType</key>
229
664
  <string>Standard</string>
230
665
  </dict>
@@ -232,10 +667,23 @@ function generateLaunchdPlist({ nodePath, dataDir }) {
232
667
  `;
233
668
  }
234
669
 
235
- // --- Platform-agnostic daemon helpers ---
670
+ /** Rotate the launchd log when big. Only safe while the job is stopped — launchd
671
+ * holds an O_APPEND fd to the old inode for the job's whole life, so renaming a
672
+ * live file is exactly what produces "stale logs". Callers must guarantee the
673
+ * job is not running. */
674
+ function rotateLaunchdLogIfBig() {
675
+ try {
676
+ const st = fs.statSync(LAUNCHD_LOG);
677
+ if (st.size > 5 * 1024 * 1024) {
678
+ fs.renameSync(LAUNCHD_LOG, LAUNCHD_LOG + '.1');
679
+ }
680
+ } catch {}
681
+ }
682
+
683
+ // --- Platform-agnostic daemon facade ---
236
684
 
237
685
  function hasDaemonSupport() {
238
- if (PLATFORM === 'darwin') return true; // launchd is always available on macOS
686
+ if (PLATFORM === 'darwin') return true;
239
687
  if (PLATFORM === 'linux') {
240
688
  try { execSync('systemctl --version', { stdio: 'ignore' }); return true; } catch { return false; }
241
689
  }
@@ -243,633 +691,758 @@ function hasDaemonSupport() {
243
691
  }
244
692
 
245
693
  function isDaemonInstalled() {
246
- if (PLATFORM === 'darwin') return isLaunchdInstalled();
247
- if (PLATFORM === 'linux') return isServiceInstalled();
694
+ if (PLATFORM === 'darwin') return fs.existsSync(LAUNCHD_PLIST_PATH);
695
+ if (PLATFORM === 'linux') return fs.existsSync(SERVICE_PATH);
248
696
  return false;
249
697
  }
250
698
 
251
- function isDaemonActive() {
252
- if (PLATFORM === 'darwin') return isLaunchdActive();
253
- if (PLATFORM === 'linux') return isServiceActive();
254
- return false;
699
+ /** Daemon-managed supervisor PID (launchd/systemd view), or null. */
700
+ function daemonPid() {
701
+ if (PLATFORM === 'darwin') {
702
+ const job = launchdJob();
703
+ return job.loaded === true && job.pid && procAlive(job.pid) ? job.pid : null;
704
+ }
705
+ if (PLATFORM === 'linux') {
706
+ const show = systemdShow();
707
+ return show.activeState === 'active' && show.mainPid ? show.mainPid : null;
708
+ }
709
+ return null;
255
710
  }
256
711
 
257
- function killAndWait(child, timeout = 10_000) {
258
- return new Promise((resolve) => {
259
- child.removeAllListeners('exit');
260
- child.on('exit', () => resolve());
261
- child.kill('SIGTERM');
262
- // Force kill after timeout if SIGTERM doesn't work
263
- setTimeout(() => {
264
- try { child.kill('SIGKILL'); } catch {}
265
- setTimeout(resolve, 500);
266
- }, timeout);
267
- });
712
+ function isDaemonActive() {
713
+ return daemonPid() !== null;
268
714
  }
269
715
 
270
- // ── UI helpers ──
271
-
272
- const c = {
273
- reset: '\x1b[0m',
274
- dim: '\x1b[2m',
275
- bold: '\x1b[1m',
276
- green: '\x1b[32m',
277
- cyan: '\x1b[36m',
278
- yellow: '\x1b[33m',
279
- red: '\x1b[31m',
280
- white: '\x1b[97m',
281
- blue: '\x1b[38;2;0;173;254m',
282
- pink: '\x1b[38;2;1;88;251m',
283
- g1: '\x1b[38;2;0;173;254m',
284
- g2: '\x1b[38;2;0;159;254m',
285
- g3: '\x1b[38;2;0;145;253m',
286
- g4: '\x1b[38;2;1;131;253m',
287
- g5: '\x1b[38;2;1;116;252m',
288
- g6: '\x1b[38;2;1;102;251m',
289
- g7: '\x1b[38;2;1;88;251m',
290
- };
291
-
292
- const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
293
- const BAR_WIDTH = 30;
716
+ /** Write the service definition (idempotent; refreshes node path / data dir drift).
717
+ * Returns { ok, error }. */
718
+ function installServiceFiles({ spinner } = {}) {
719
+ const nodePath = process.env.BLOBY_NODE_PATH || process.execPath;
720
+ if (PLATFORM === 'darwin') {
721
+ const dataDir = ROOT; // repo in dev, ~/.bloby in production
722
+ fs.mkdirSync(path.dirname(LAUNCHD_PLIST_PATH), { recursive: true });
723
+ fs.writeFileSync(LAUNCHD_PLIST_PATH, generateLaunchdPlist({ nodePath, dataDir }));
724
+ fs.chmodSync(LAUNCHD_PLIST_PATH, 0o644); // launchd refuses group/world-writable plists (error 122)
725
+ return { ok: true };
726
+ }
727
+ if (PLATFORM === 'linux') {
728
+ const user = getRealUser();
729
+ const home = getRealHome();
730
+ const dataDir = path.join(home, '.bloby');
731
+ const unit = generateUnitFile({ user, home, nodePath, dataDir });
732
+ // Skip the privileged write when the installed unit is already identical —
733
+ // a plain `bloby start` on an installed system then needs no sudo at all
734
+ // beyond `systemctl start`.
735
+ let current = null;
736
+ try { current = fs.readFileSync(SERVICE_PATH, 'utf-8'); } catch {}
737
+ if (current !== unit) {
738
+ const tmp = path.join(os.tmpdir(), `bloby-unit-${process.pid}.service`);
739
+ fs.writeFileSync(tmp, unit);
740
+ const cp = runPrivileged(['cp', tmp, SERVICE_PATH], { spinner });
741
+ fs.rmSync(tmp, { force: true });
742
+ if (!cp.ok) return cp;
743
+ const reload = runPrivileged(['systemctl', 'daemon-reload'], { spinner });
744
+ if (!reload.ok) return reload;
745
+ const enable = runPrivileged(['systemctl', 'enable', SERVICE_NAME], { spinner });
746
+ if (!enable.ok) return enable;
747
+ }
748
+ return { ok: true };
749
+ }
750
+ return { ok: false, error: 'No daemon support on this platform.' };
751
+ }
294
752
 
295
- function gradientChar(i, total) {
296
- const t = total > 1 ? i / (total - 1) : 0;
297
- const r = Math.round(0 + t * (1 - 0));
298
- const g = Math.round(173 + t * (88 - 173));
299
- const b = Math.round(254 + t * (251 - 254));
300
- return `\x1b[38;2;${r};${g};${b}m`;
753
+ /** Start the daemon. Returns { ok, error }. Never throws. */
754
+ function startDaemonService({ spinner } = {}) {
755
+ if (PLATFORM === 'darwin') {
756
+ const job = launchdJob();
757
+ if (job.loaded === true && job.pid && procAlive(job.pid)) return { ok: true };
758
+ if (job.loaded === false) rotateLaunchdLogIfBig(); // only certain-stopped — rotating a live log strands tails
759
+ if (job.loaded === true) {
760
+ // Loaded but not running (crash-throttled "spawn scheduled" etc.) — kickstart in ITS domain.
761
+ const res = launchctl(['kickstart', '-k', `${job.domain}/${LAUNCHD_LABEL}`]);
762
+ if (res.status === 0) return { ok: true };
763
+ return { ok: false, error: launchdError(res) };
764
+ }
765
+ let res = launchctl(['bootstrap', launchdDomain(), LAUNCHD_PLIST_PATH]);
766
+ if (res.status === 0 || res.status === 37) return { ok: true }; // 37 = already bootstrapped
767
+ if (res.status === 5) {
768
+ // 5 is a catch-all on some macOS builds (often an already-bootstrapped race) —
769
+ // re-query before assuming anything.
770
+ if (launchdJob().loaded === true) return { ok: true };
771
+ return { ok: false, error: launchdError(res) };
772
+ }
773
+ if (res.status === 119) {
774
+ // Disabled — likely toggled off in System Settings > Login Items (macOS 13+ BTM)
775
+ // or a stale disable override. Try to re-enable once, then retry.
776
+ launchctl(['enable', `user/${process.getuid?.() ?? 501}/${LAUNCHD_LABEL}`]);
777
+ res = launchctl(['bootstrap', launchdDomain(), LAUNCHD_PLIST_PATH]);
778
+ if (res.status === 0 || res.status === 37) return { ok: true };
779
+ return {
780
+ ok: false,
781
+ error: `Bloby is disabled in System Settings → General → Login Items & Extensions. Enable it there, then run ${c.blue}bloby start${c.reset} again.`,
782
+ };
783
+ }
784
+ return { ok: false, error: launchdError(res) };
785
+ }
786
+ if (PLATFORM === 'linux') {
787
+ return runPrivileged(['systemctl', 'start', SERVICE_NAME], { spinner });
788
+ }
789
+ return { ok: false, error: 'No daemon support on this platform.' };
301
790
  }
302
791
 
303
- function progressBar(ratio, width = BAR_WIDTH) {
304
- const filled = Math.round(ratio * width);
305
- const empty = width - filled;
306
- let bar = '';
307
- for (let i = 0; i < filled; i++) bar += `${gradientChar(i, width)}█`;
308
- bar += `${c.dim}${''.repeat(empty)}${c.reset}`;
309
- return bar;
792
+ /** Stop the daemon. Returns { ok, error }. Waits for the job to actually unload. */
793
+ async function stopDaemonService({ spinner } = {}) {
794
+ if (PLATFORM === 'darwin') {
795
+ const job = launchdJob();
796
+ if (job.loaded === false) return { ok: true };
797
+ if (job.loaded === 'unknown') {
798
+ return { ok: false, error: `launchctl could not be queried (${job.state}) — not safe to assume Bloby is stopped.` };
799
+ }
800
+ const res = launchctl(['bootout', `${job.domain}/${LAUNCHD_LABEL}`]);
801
+ // 3 = no such process (already gone), 36 = termination in flight — poll below.
802
+ if (res.status !== 0 && res.status !== 3 && res.status !== 36) {
803
+ return { ok: false, error: launchdError(res) };
804
+ }
805
+ for (let i = 0; i < 40; i++) { // ≤20s for graceful supervisor shutdown
806
+ if (launchdJob().loaded === false) return { ok: true };
807
+ await new Promise(r => setTimeout(r, 500));
808
+ }
809
+ return { ok: false, error: 'Daemon did not stop within 20s.' };
810
+ }
811
+ if (PLATFORM === 'linux') {
812
+ const res = runPrivileged(['systemctl', 'stop', SERVICE_NAME], { spinner });
813
+ if (!res.ok) {
814
+ // "Unit not loaded/found" means there is nothing to stop — success, so the
815
+ // caller still runs the stray-process sweep (macOS behaves the same way).
816
+ const show = systemdShow();
817
+ if (show.loadState !== 'loaded' || /not loaded|could not be found|not-found/i.test(res.error || '')) {
818
+ return { ok: true };
819
+ }
820
+ }
821
+ return res;
822
+ }
823
+ return { ok: true };
310
824
  }
311
825
 
312
- function link(url) {
313
- return `\x1b]8;;${url}\x07${url}\x1b]8;;\x07`;
826
+ function launchdError(res) {
827
+ const msg = (res.stderr || res.stdout).trim().split('\n').pop() || '';
828
+ let described = '';
829
+ try { described = execFileSync('launchctl', ['error', String(res.status)], { encoding: 'utf-8' }).trim(); } catch {}
830
+ return msg || described || `launchctl failed (exit ${res.status})`;
314
831
  }
315
832
 
316
- function chooseTunnelMode() {
317
- return new Promise((resolve) => {
318
- const options = [
319
- {
320
- label: 'Quick Tunnel',
321
- mode: 'quick',
322
- tag: 'Easy and Fast',
323
- tagColor: c.green,
324
- desc: [
325
- 'Random CloudFlare tunnel URL on every start/update',
326
- `Optional: Use Bloby Relay Server and access your bot at ${c.reset}${c.pink}open.bloby.bot/YOURBOT${c.reset}${c.dim} (Free)`,
327
- `Or use a premium handle like ${c.reset}${c.pink}bloby.bot/YOURBOT${c.reset}${c.dim} ($5 one-time fee)`,
328
- ],
329
- },
330
- {
331
- label: 'Named Tunnel',
332
- mode: 'named',
333
- tag: 'Advanced',
334
- tagColor: c.yellow,
335
- desc: [
336
- 'Persistent URL with your own domain',
337
- 'Requires a CloudFlare account + domain',
338
- `Use a subdomain like ${c.reset}${c.white}bot.YOURDOMAIN.COM${c.reset}${c.dim} or the root domain`,
339
- ],
340
- },
341
- {
342
- label: 'Private Network',
343
- mode: 'off',
344
- tag: 'Secure',
345
- tagColor: c.cyan,
346
- desc: [
347
- 'No public URL — access via local network or VPN only',
348
- `Use with ${c.reset}${c.white}Tailscale${c.reset}${c.dim}, WireGuard, or any private network`,
349
- `Your bot stays invisible to the internet`,
350
- ],
351
- },
352
- ];
833
+ /** SIGTERM (then SIGKILL) every supervisor process not owned by the daemon —
834
+ * forecloses the duplicate-instance / stale-log class for good. */
835
+ async function killStraySupervisors({ exceptPid = null } = {}) {
836
+ let strays = scanSupervisors().filter(p => p.pid !== exceptPid);
837
+ if (strays.length === 0) return 0;
838
+ for (const p of strays) { try { process.kill(p.pid, 'SIGTERM'); } catch {} }
839
+ for (let i = 0; i < 16; i++) { // ≤8s graceful
840
+ strays = strays.filter(p => procAlive(p.pid));
841
+ if (strays.length === 0) return 0;
842
+ await new Promise(r => setTimeout(r, 500));
843
+ }
844
+ for (const p of strays) { try { process.kill(p.pid, 'SIGKILL'); } catch {} }
845
+ return strays.length;
846
+ }
353
847
 
354
- let selected = 0;
355
- let lineCount = 0;
848
+ // ── Readiness wait ──
849
+ // Replaces the old launchd-log-offset __READY__ grep with portable signals that
850
+ // work identically on macOS/Linux: the supervisor pidfile, /api/health, and the
851
+ // tunnelUrl the supervisor writes back into config.json.
356
852
 
357
- function writeLine(text = '') {
358
- process.stdout.write(`\x1b[2K${text}\n`);
359
- lineCount++;
853
+ async function waitForReady({ sinceMs, oldTunnelUrl, wantTunnel, tunnelMode, spinner, timeoutMs = 90_000 }) {
854
+ const config = readConfig() || {};
855
+ const port = config.port || 7400;
856
+ const relayUrl = config.relay?.url || null;
857
+ let healthy = false;
858
+ let tunnelUrl = null;
859
+ const deadline = Date.now() + timeoutMs;
860
+ // Interactive callers stop waiting on a slow tunnel after 60s (don't hold the
861
+ // terminal hostage); callers that passed a bigger budget (hosted provisioning)
862
+ // get the full window — a slow tunnel is not a failed tunnel.
863
+ const tunnelDeadline = Date.now() + (timeoutMs > 90_000 ? timeoutMs : Math.min(timeoutMs, 60_000));
864
+
865
+ while (Date.now() < deadline) {
866
+ if (!healthy) {
867
+ const health = await fetchHealth(port, 2000);
868
+ if (health) {
869
+ healthy = true;
870
+ if (spinner && wantTunnel) spinner.update('Connecting tunnel...');
871
+ }
360
872
  }
361
-
362
- function render() {
363
- // Move cursor up to overwrite previous render
364
- if (lineCount > 0) {
365
- process.stdout.write(`\x1b[${lineCount}A`);
873
+ if (wantTunnel && !tunnelUrl) {
874
+ const cfg = readConfig();
875
+ // Named tunnels keep a stable URL, so "changed since last run" never fires —
876
+ // accept any URL the supervisor wrote once it is healthy.
877
+ if (cfg?.tunnelUrl && (cfg.tunnelUrl !== oldTunnelUrl || (tunnelMode === 'named' && healthy))) {
878
+ tunnelUrl = cfg.tunnelUrl;
366
879
  }
367
- lineCount = 0;
368
-
369
- writeLine(` ${c.bold}${c.white}How do you want to connect your bot?${c.reset}`);
370
- writeLine();
880
+ }
881
+ if (healthy && (!wantTunnel || tunnelUrl)) break;
882
+ if (healthy && wantTunnel && Date.now() > tunnelDeadline) break; // healthy but tunnel slow — don't hold the terminal hostage
883
+ await new Promise(r => setTimeout(r, 1000));
884
+ }
371
885
 
372
- for (let i = 0; i < options.length; i++) {
373
- const opt = options[i];
374
- const isSelected = i === selected;
375
- const bullet = isSelected ? `${c.pink}❯` : `${c.dim} `;
376
- const label = isSelected ? `${c.bold}${c.white}${opt.label}` : `${c.dim}${opt.label}`;
377
- const tag = `${opt.tagColor}[${opt.tag}]${c.reset}`;
886
+ // Re-read for any relay URL the supervisor registered during startup.
887
+ const cfg = readConfig() || {};
888
+ return {
889
+ healthy,
890
+ tunnelUrl: tunnelUrl || (wantTunnel && cfg.tunnelUrl !== oldTunnelUrl ? cfg.tunnelUrl : tunnelUrl),
891
+ relayUrl: cfg.relay?.url || relayUrl,
892
+ runtime: readRuntime(),
893
+ sinceMs,
894
+ };
895
+ }
378
896
 
379
- writeLine(` ${bullet} ${label}${c.reset} ${tag}`);
380
- for (const line of opt.desc) {
381
- writeLine(` ${c.dim}${line}${c.reset}`);
382
- }
383
- if (i < options.length - 1) writeLine();
897
+ function getNetworkUrls(port) {
898
+ const urls = [];
899
+ const nets = os.networkInterfaces();
900
+ for (const addrs of Object.values(nets)) {
901
+ for (const addr of addrs || []) {
902
+ if (addr.family !== 'IPv4' || addr.internal) continue;
903
+ const ip = addr.address;
904
+ // Tailscale uses 100.64.0.0/10 (CGNAT range)
905
+ if (ip.startsWith('100.')) {
906
+ urls.push({ type: 'tailscale', url: `http://${ip}:${port}` });
907
+ } else if (ip.startsWith('192.168.') || ip.startsWith('10.') || ip.match(/^172\.(1[6-9]|2\d|3[01])\./)) {
908
+ urls.push({ type: 'lan', url: `http://${ip}:${port}` });
384
909
  }
385
910
  }
911
+ }
912
+ return urls;
913
+ }
386
914
 
387
- process.stdout.write('\x1b[?25l'); // Hide cursor
388
- render();
389
-
390
- // Enable raw mode for arrow key input
391
- process.stdin.setRawMode(true);
392
- process.stdin.resume();
393
- process.stdin.setEncoding('utf-8');
394
-
395
- const onKey = (key) => {
396
- if (key === '\x1b[A' || key === 'k') { // Up
397
- selected = (selected - 1 + options.length) % options.length;
398
- render();
399
- } else if (key === '\x1b[B' || key === 'j') { // Down
400
- selected = (selected + 1) % options.length;
401
- render();
402
- } else if (key === '\r' || key === '\n') { // Enter
403
- process.stdout.write('\x1b[?25h'); // Show cursor
404
- process.stdin.setRawMode(false);
405
- process.stdin.pause();
406
- process.stdin.removeListener('data', onKey);
407
- resolve(options[selected].mode);
408
- } else if (key === '\x03') { // Ctrl+C
409
- process.stdout.write('\x1b[?25h\n');
410
- process.exit(0);
411
- }
412
- };
413
-
414
- process.stdin.on('data', onKey);
415
- });
416
- }
417
-
418
- async function runNamedTunnelSetup() {
419
- // Ensure cloudflared is installed
420
- console.log(`\n ${c.blue}⠋${c.reset} Checking cloudflared...`);
421
- await installCloudflared();
422
- console.log(` ${c.blue}✔${c.reset} cloudflared ready\n`);
423
-
424
- // Login to Cloudflare
425
- console.log(` ${c.bold}${c.white}Step 1:${c.reset} Log in to Cloudflare\n`);
426
- console.log(` ${c.dim}This will open a browser window. Authorize the domain you want to use.${c.reset}\n`);
427
- try {
428
- spawnSync('cloudflared', ['tunnel', 'login'], { stdio: 'inherit' });
429
- } catch {
430
- if (fs.existsSync(CF_PATH)) {
431
- spawnSync(CF_PATH, ['tunnel', 'login'], { stdio: 'inherit' });
432
- } else {
433
- console.log(`\n ${c.red}✗${c.reset} cloudflared login failed.\n`);
434
- process.exit(1);
435
- }
436
- }
915
+ function printReadyBlock({ healthy, tunnelUrl, relayUrl, port, tunnelMode }) {
437
916
  console.log('');
438
-
439
- // Ask for tunnel name
440
- const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
441
- const askQ = (q) => new Promise((r) => rl.question(q, (a) => r(a.trim())));
442
-
443
- const tunnelName = (await askQ(` ${c.bold}Tunnel name${c.reset} ${c.dim}(default: bloby)${c.reset}: `)) || 'bloby';
444
-
445
- // Create tunnel
446
- console.log(`\n ${c.blue}⠋${c.reset} Creating tunnel "${tunnelName}"...`);
447
- let createOutput;
448
- try {
449
- createOutput = execSync(`cloudflared tunnel create ${tunnelName}`, { encoding: 'utf-8' });
450
- } catch {
451
- try {
452
- createOutput = execSync(`${CF_PATH} tunnel create ${tunnelName}`, { encoding: 'utf-8' });
453
- } catch {
454
- console.log(`\n ${c.red}✗${c.reset} Failed to create tunnel. It may already exist.`);
455
- console.log(` ${c.dim}Try: cloudflared tunnel list${c.reset}\n`);
456
- process.exit(1);
917
+ if (healthy) {
918
+ console.log(` ${c.pink}${c.bold}✔ Bloby is running${c.reset}\n`);
919
+ } else {
920
+ console.log(` ${GLYPH.warn} Bloby is starting not healthy yet. Check ${c.blue}bloby status${c.reset} in a moment.\n`);
921
+ }
922
+ console.log(` ${c.dim}${'Local'.padEnd(9)}${c.reset}${c.blue}${link(`http://localhost:${port}`)}${c.reset}`);
923
+ if (tunnelMode === 'off') {
924
+ for (const u of getNetworkUrls(port)) {
925
+ console.log(` ${c.dim}${(u.type === 'tailscale' ? 'Tailscale' : 'LAN').padEnd(9)}${c.reset}${c.cyan}${link(u.url)}${c.reset}`);
457
926
  }
458
927
  }
459
-
460
- const uuidMatch = createOutput.match(/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/i);
461
- if (!uuidMatch) {
462
- console.log(`\n ${c.red}✗${c.reset} Could not parse tunnel UUID from output.`);
463
- console.log(` ${c.dim}${createOutput}${c.reset}\n`);
464
- process.exit(1);
928
+ if (tunnelUrl) {
929
+ console.log(` ${c.dim}${'Tunnel'.padEnd(9)}${c.reset}${c.blue}${link(tunnelUrl)}${c.reset}`);
930
+ } else if (tunnelMode !== 'off') {
931
+ console.log(` ${c.dim}${'Tunnel'.padEnd(9)}still connecting check ${c.reset}${c.blue}bloby status${c.reset}${c.dim} shortly${c.reset}`);
465
932
  }
466
- const tunnelUuid = uuidMatch[1];
467
- console.log(` ${c.blue}✔${c.reset} Tunnel created: ${c.dim}${tunnelUuid}${c.reset}\n`);
468
-
469
- // Ask for domain
470
- const domain = await askQ(` ${c.bold}Your domain${c.reset} ${c.dim}(e.g. bot.mydomain.com)${c.reset}: `);
471
- rl.close();
472
-
473
- if (!domain) {
474
- console.log(`\n ${c.red}✗${c.reset} Domain is required for named tunnels.\n`);
475
- process.exit(1);
933
+ if (relayUrl) {
934
+ console.log(` ${c.dim}${'Relay'.padEnd(9)}${c.reset}${c.pink}${link(relayUrl)}${c.reset}`);
476
935
  }
936
+ }
477
937
 
478
- // Generate cloudflared config
479
- const config = fs.existsSync(CONFIG_PATH) ? JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8')) : {};
480
- const port = config.port || 7400;
481
- const cfHome = path.join(os.homedir(), '.cloudflared');
482
- const cfConfigPath = path.join(DATA_DIR, 'cloudflared-config.yml');
483
-
484
- const yamlContent = `tunnel: ${tunnelUuid}
485
- credentials-file: ${path.join(cfHome, `${tunnelUuid}.json`)}
486
- ingress:
487
- - service: http://localhost:${port}
488
- `;
489
- fs.mkdirSync(DATA_DIR, { recursive: true });
490
- fs.writeFileSync(cfConfigPath, yamlContent);
491
- console.log(`\n ${c.blue}✔${c.reset} Config written to ${c.dim}${cfConfigPath}${c.reset}`);
492
-
493
- // Print DNS instructions
494
- console.log(`\n ${c.dim}─────────────────────────────────${c.reset}\n`);
495
- console.log(` ${c.bold}${c.white}Tunnel created!${c.reset}`);
496
- console.log(` ${c.white}Add a CNAME record pointing to:${c.reset}\n`);
497
- console.log(` ${c.pink}${c.bold}${tunnelUuid}.cfargotunnel.com${c.reset}\n`);
498
- console.log(` ${c.dim}Or run: cloudflared tunnel route dns ${tunnelName} ${domain}${c.reset}\n`);
938
+ // ── Cloudflared install (shared by init/start/tunnel) ──
499
939
 
500
- return { tunnelName, domain, cfConfigPath };
501
- }
940
+ const MIN_CF_SIZE = 10 * 1024 * 1024; // 10 MB — valid cloudflared is ~30-50 MB
502
941
 
503
- class Stepper {
504
- constructor(steps) {
505
- this.steps = steps;
506
- this.current = 0;
507
- this.frame = 0;
508
- this.interval = null;
509
- this.done = false;
510
- this.infoLines = []; // extra lines shown below the progress bar
511
- this._totalLines = 0; // total lines rendered last frame (for cursor rewind)
942
+ function hasCloudflared() {
943
+ const which = PLATFORM === 'win32' ? 'where cloudflared' : 'which cloudflared';
944
+ try { execSync(which, { stdio: 'ignore' }); return true; } catch {}
945
+ const cfExe = PLATFORM === 'win32' ? CF_PATH + '.exe' : CF_PATH;
946
+ if (!fs.existsSync(cfExe)) return false;
947
+ if (fs.statSync(cfExe).size < MIN_CF_SIZE) {
948
+ fs.unlinkSync(cfExe);
949
+ return false;
512
950
  }
951
+ return true;
952
+ }
513
953
 
514
- start() {
515
- console.log('');
516
- // Hide cursor and suppress user input during animation
517
- process.stdout.write('\x1b[?25l');
518
- if (process.stdin.isTTY) {
519
- process.stdin.setRawMode(true);
520
- process.stdin.resume();
521
- this._stdinHandler = (data) => {
522
- // Allow Ctrl+C
523
- if (data[0] === 0x03) {
524
- this._restoreTerminal();
525
- process.exit(0);
526
- }
527
- // Discard everything else
528
- };
529
- process.stdin.on('data', this._stdinHandler);
530
- }
531
- this.interval = setInterval(() => {
532
- this.frame = (this.frame + 1) % SPINNER.length;
533
- this.render();
534
- }, 80);
535
- this.render();
536
- }
954
+ async function installCloudflared() {
955
+ if (hasCloudflared()) return;
956
+ fs.mkdirSync(BIN_DIR, { recursive: true });
537
957
 
538
- _restoreTerminal() {
539
- process.stdout.write('\x1b[?25h');
540
- if (process.stdin.isTTY) {
541
- if (this._stdinHandler) {
542
- process.stdin.removeListener('data', this._stdinHandler);
543
- this._stdinHandler = null;
544
- }
545
- try { process.stdin.setRawMode(false); } catch {}
546
- process.stdin.pause();
958
+ // os.arch() returns Node's arch, not the OS. Use PROCESSOR_ARCHITECTURE on Windows for real OS arch.
959
+ const arch = PLATFORM === 'win32'
960
+ ? (process.env.PROCESSOR_ARCHITECTURE || os.arch()).toLowerCase()
961
+ : os.arch();
962
+ let url;
963
+ if (PLATFORM === 'win32') {
964
+ url = arch.includes('arm')
965
+ ? 'https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-windows-arm64.exe'
966
+ : 'https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-windows-amd64.exe';
967
+ } else if (PLATFORM === 'darwin') {
968
+ url = arch === 'arm64'
969
+ ? 'https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-darwin-arm64.tgz'
970
+ : 'https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-darwin-amd64.tgz';
971
+ } else if (PLATFORM === 'linux') {
972
+ if (arch === 'arm64' || arch === 'aarch64') {
973
+ url = 'https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-arm64';
974
+ } else if (arch === 'arm' || arch === 'armv7l') {
975
+ url = 'https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-arm';
976
+ } else {
977
+ url = 'https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64';
547
978
  }
979
+ } else {
980
+ throw new Error(`Unsupported platform: ${PLATFORM}/${arch}`);
548
981
  }
549
982
 
550
- setInfo(lines) {
551
- this.infoLines = lines || [];
552
- this.render();
983
+ // Bounded: an unresponsive download must never hang a headless restart
984
+ // (the supervisor's detached relaunch runs through this path).
985
+ const DL_TIMEOUT = 180_000;
986
+ if (PLATFORM === 'win32') {
987
+ execSync(`curl.exe -fsSL -o "${CF_PATH}.exe" "${url}"`, { stdio: 'ignore', timeout: DL_TIMEOUT });
988
+ } else if (url.endsWith('.tgz')) {
989
+ execSync(`curl -fsSL "${url}" | tar xz -C "${BIN_DIR}"`, { stdio: 'ignore', timeout: DL_TIMEOUT });
990
+ } else {
991
+ execSync(`curl -fsSL -o "${CF_PATH}" "${url}"`, { stdio: 'ignore', timeout: DL_TIMEOUT });
992
+ fs.chmodSync(CF_PATH, 0o755);
553
993
  }
994
+ }
554
995
 
555
- render() {
556
- if (this.done) return;
996
+ async function ensureCloudflared(spinner) {
997
+ if (hasCloudflared()) return;
998
+ if (spinner) spinner.update('Installing cloudflared...');
999
+ await installCloudflared();
1000
+ }
557
1001
 
558
- if (this._totalLines > 0) {
559
- process.stdout.write(`\x1b[${this._totalLines}A`);
560
- }
1002
+ // ── Foreground boot (debug / no-daemon platforms / hosted containers) ──
1003
+ // Parses the supervisor's stdout protocol markers. Keep regexes in sync with the
1004
+ // emitters in supervisor/index.ts + supervisor/vite-dev.ts.
561
1005
 
562
- const ratio = this.current / this.steps.length;
1006
+ function bootServer({ onTunnelUp, onReady } = {}) {
1007
+ return new Promise((resolve, reject) => {
1008
+ const child = spawn(
1009
+ process.execPath,
1010
+ ['--import', 'tsx/esm', path.join(ROOT, 'supervisor/index.ts')],
1011
+ { cwd: ROOT, stdio: ['ignore', 'pipe', 'pipe'], env: { ...process.env } },
1012
+ );
563
1013
 
564
- for (let i = 0; i < this.steps.length; i++) {
565
- if (i < this.current) {
566
- console.log(` ${c.blue}✔${c.reset} ${this.steps[i]}`);
567
- } else if (i === this.current) {
568
- console.log(` ${c.pink}${SPINNER[this.frame]}${c.reset} ${this.steps[i]}${c.dim}...${c.reset}`);
569
- } else {
570
- console.log(` ${c.dim}○ ${this.steps[i]}${c.reset}`);
571
- }
572
- }
1014
+ let tunnelUrl = null;
1015
+ let relayUrl = null;
1016
+ let resolved = false;
1017
+ let stderrBuf = '';
1018
+ let tunnelFired = false;
1019
+ let tunnelFailed = false;
573
1020
 
574
- console.log(`\n ${progressBar(ratio)} ${c.dim}${Math.round(ratio * 100)}%${c.reset}`);
1021
+ let viteWarmResolve;
1022
+ const viteWarm = new Promise((r) => { viteWarmResolve = r; });
575
1023
 
576
- // steps + blank + progress = steps.length + 2, plus info lines
577
- let lineCount = this.steps.length + 2;
1024
+ const doResolve = () => {
1025
+ if (resolved) return;
1026
+ resolved = true;
1027
+ if (!tunnelFired && onTunnelUp) onTunnelUp();
1028
+ if (onReady) onReady();
1029
+ const config = readConfig() || {};
1030
+ resolve({
1031
+ child,
1032
+ tunnelUrl: tunnelUrl || `http://localhost:${config.port || 7400}`,
1033
+ relayUrl: relayUrl || config.relay?.url || null,
1034
+ tunnelFailed,
1035
+ viteWarm,
1036
+ });
1037
+ };
578
1038
 
579
- if (this.infoLines.length) {
580
- console.log('');
581
- for (const line of this.infoLines) {
582
- console.log(line);
583
- }
584
- lineCount += 1 + this.infoLines.length;
585
- }
1039
+ const handleData = (data) => {
1040
+ const text = data.toString();
586
1041
 
587
- // Clear leftover lines when content shrinks (e.g. info lines removed)
588
- if (lineCount < this._totalLines) {
589
- const extra = this._totalLines - lineCount;
590
- for (let i = 0; i < extra; i++) {
591
- process.stdout.write('\x1b[2K\n');
1042
+ const tunnelMatch = text.match(/__TUNNEL_URL__=(\S+)/);
1043
+ if (tunnelMatch) {
1044
+ tunnelUrl = tunnelMatch[1];
1045
+ if (!tunnelFired && onTunnelUp) { tunnelFired = true; onTunnelUp(tunnelUrl); }
592
1046
  }
593
- process.stdout.write(`\x1b[${extra}A`);
594
- }
595
1047
 
596
- this._totalLines = lineCount;
597
- }
1048
+ const relayMatch = text.match(/__RELAY_URL__=(\S+)/);
1049
+ if (relayMatch) relayUrl = relayMatch[1];
598
1050
 
599
- advance() {
600
- this.current++;
601
- this.render();
602
- }
1051
+ if (text.includes('__VITE_WARM__')) viteWarmResolve();
1052
+ if (text.includes('__READY__')) { doResolve(); return; }
1053
+ if (text.includes('__TUNNEL_FAILED__')) { tunnelFailed = true; doResolve(); }
1054
+ };
603
1055
 
604
- finish() {
605
- this.done = true;
606
- if (this.interval) clearInterval(this.interval);
607
- this._restoreTerminal();
1056
+ // Safety-net timeout: resolve after 45s even if __READY__ never arrives
1057
+ setTimeout(doResolve, 45_000);
608
1058
 
609
- if (this._totalLines > 0) {
610
- process.stdout.write(`\x1b[${this._totalLines}A`);
611
- }
612
- // Clear all previously rendered lines
613
- for (let i = 0; i < this._totalLines; i++) {
614
- process.stdout.write('\x1b[2K\n');
615
- }
616
- process.stdout.write(`\x1b[${this._totalLines}A`);
1059
+ child.stdout.on('data', handleData);
1060
+ child.stderr.on('data', (data) => {
1061
+ stderrBuf += data.toString();
1062
+ handleData(data);
1063
+ });
617
1064
 
618
- for (const step of this.steps) {
619
- console.log(` ${c.blue}✔${c.reset} ${step}`);
620
- }
621
- console.log(`\n ${progressBar(1)} ${c.pink}Done${c.reset}`);
622
- }
623
- }
1065
+ // The global SIGINT/SIGTERM handlers (registered first, so they run first
1066
+ // and exit immediately) kill foregroundChild — per-call listeners here
1067
+ // would never run.
1068
+ foregroundChild = child;
624
1069
 
625
- function banner() {
626
- console.log(`
627
- ${c.g1}${c.bold} █▄ ${c.reset}
628
- ${c.g2}${c.bold} ▄ ▄ ██ ${c.reset}
629
- ${c.g3}${c.bold} ███▄███▄ ▄███▄ ████▄████▄ ████▄ ██ ██${c.reset}
630
- ${c.g4}${c.bold} ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██▄██${c.reset}
631
- ${c.g5}${c.bold} ▄██ ██ ▀█▄▀███▀▄█▀ ▄████▀▄██ ██▄▄▀██▀${c.reset}
632
- ${c.g6}${c.bold} ██ ██ ${c.reset}
633
- ${c.g7}${c.bold} ▀ ▀▀▀ ${c.reset}
634
- ${c.dim}v${pkg.version} · Self-hosted AI agent${c.reset}`);
1070
+ child.on('exit', (code) => {
1071
+ if (!resolved) {
1072
+ reject(new Error(stderrBuf.trim() || `Server exited with code ${code}`));
1073
+ } else {
1074
+ process.exit(code ?? 1);
1075
+ }
1076
+ });
1077
+ });
635
1078
  }
636
1079
 
637
- function getNetworkUrls(port) {
638
- const urls = [];
639
- const nets = os.networkInterfaces();
640
- for (const [name, addrs] of Object.entries(nets)) {
641
- for (const addr of addrs) {
642
- if (addr.family !== 'IPv4' || addr.internal) continue;
643
- const ip = addr.address;
644
- // Tailscale uses 100.64.0.0/10 (CGNAT range)
645
- if (ip.startsWith('100.')) {
646
- urls.push({ type: 'tailscale', url: `http://${ip}:${port}`, ip });
647
- } else if (ip.startsWith('192.168.') || ip.startsWith('10.') || ip.match(/^172\.(1[6-9]|2\d|3[01])\./)) {
648
- urls.push({ type: 'lan', url: `http://${ip}:${port}`, ip });
649
- }
650
- }
1080
+ async function runForeground() {
1081
+ const config = readConfig() || {};
1082
+ const tunnelMode = tunnelModeOf(config);
1083
+ const s = new Spinner().start('Starting Bloby (foreground)...');
1084
+ let result;
1085
+ try {
1086
+ if (tunnelMode !== 'off' && tunnelMode !== 'named') await ensureCloudflared(s);
1087
+ result = await bootServer({
1088
+ onTunnelUp: (url) => { if (url) s.update(`Tunnel up at ${url}`); },
1089
+ });
1090
+ } catch (err) {
1091
+ s.fail(`Server failed to start: ${err.message}`);
1092
+ process.exit(1);
651
1093
  }
652
- return urls;
1094
+ s.succeed('Bloby is running (foreground — Ctrl+C stops it)');
1095
+ printReadyBlock({
1096
+ healthy: true,
1097
+ tunnelUrl: result.tunnelFailed ? null : result.tunnelUrl,
1098
+ relayUrl: result.relayUrl,
1099
+ port: config.port || 7400,
1100
+ tunnelMode,
1101
+ });
1102
+ console.log(`\n ${c.dim}Press Ctrl+C to stop${c.reset}\n`);
1103
+ result.child.stdout.on('data', (d) => process.stdout.write(` ${c.dim}${d.toString().trim()}${c.reset}\n`));
1104
+ result.child.stderr.on('data', (d) => {
1105
+ const line = d.toString().trim();
1106
+ if (!line || line.includes('AssignProcessToJobObject')) return;
1107
+ process.stderr.write(` ${c.dim}${line}${c.reset}\n`);
1108
+ });
1109
+ // Stays attached; bootServer's child exit handler ends the process.
653
1110
  }
654
1111
 
655
- function privateNetworkMessage(port) {
656
- const urls = getNetworkUrls(port);
657
- const tailscale = urls.find(u => u.type === 'tailscale');
658
- const lan = urls.find(u => u.type === 'lan');
1112
+ // ── start / stop / restart cores (shared so restart ≡ stop + start, exactly) ──
659
1113
 
660
- console.log(`
661
- ${c.dim}─────────────────────────────────${c.reset}
1114
+ async function startCore({ spinner, timeoutMs }) {
1115
+ const config = readConfig() || {};
1116
+ const tunnelMode = tunnelModeOf(config);
1117
+ const wantTunnel = tunnelMode !== 'off';
662
1118
 
663
- ${c.bold}${c.white}Open your dashboard to finish setup:${c.reset}
664
- `);
1119
+ if (wantTunnel && tunnelMode !== 'named') await ensureCloudflared(spinner);
665
1120
 
666
- if (tailscale) {
667
- console.log(` ${c.cyan}${c.bold}${link(tailscale.url)}${c.reset} ${c.dim}(Tailscale)${c.reset}`);
668
- }
669
- if (lan) {
670
- console.log(` ${c.blue}${c.bold}${link(lan.url)}${c.reset} ${c.dim}(Local network)${c.reset}`);
671
- }
672
- console.log(` ${c.dim}${link(`http://localhost:${port}`)}${c.reset} ${c.dim}(This machine)${c.reset}`);
1121
+ const installed = installServiceFiles({ spinner });
1122
+ if (!installed.ok) return { ok: false, error: installed.error };
1123
+ const sinceMs = Date.now();
1124
+ const oldTunnelUrl = config.tunnelUrl || null;
673
1125
 
674
- if (!tailscale && !lan) {
675
- console.log(`
676
- ${c.dim}Tip: Install Tailscale to access from other devices:${c.reset}
677
- ${c.dim}https://tailscale.com/download${c.reset}`);
678
- }
1126
+ spinner.update('Starting Bloby...');
1127
+ const started = startDaemonService({ spinner });
1128
+ if (!started.ok) return { ok: false, error: started.error };
679
1129
 
680
- if (hasDaemonSupport()) {
681
- console.log(`
682
- ${c.dim}─────────────────────────────────${c.reset}
683
-
684
- ${c.bold}${c.white}Commands:${c.reset}
685
- ${c.dim}Status${c.reset} ${c.pink}bloby status${c.reset}
686
- ${c.dim}Logs${c.reset} ${c.pink}bloby logs${c.reset}
687
- ${c.dim}Stop${c.reset} ${c.pink}bloby stop${c.reset}
688
- ${c.dim}Restart${c.reset} ${c.pink}bloby daemon restart${c.reset}
689
- ${c.dim}Update${c.reset} ${c.pink}bloby update${c.reset}
690
- `);
691
- } else {
692
- console.log(`
693
- ${c.dim}─────────────────────────────────${c.reset}
1130
+ spinner.update('Waiting for the server...');
1131
+ const ready = await waitForReady({ sinceMs, oldTunnelUrl, wantTunnel, tunnelMode, spinner, ...(timeoutMs ? { timeoutMs } : {}) });
1132
+ return { ok: true, ...ready, port: config.port || 7400, tunnelMode };
1133
+ }
694
1134
 
695
- ${c.bold}${c.white}Commands:${c.reset}
696
- ${c.dim}Status${c.reset} ${c.pink}bloby status${c.reset}
697
- ${c.dim}Update${c.reset} ${c.pink}bloby update${c.reset}
1135
+ async function stopCore({ spinner }) {
1136
+ const stopped = await stopDaemonService({ spinner });
1137
+ if (!stopped.ok) return stopped;
1138
+ spinner.update('Waiting for processes to exit...');
1139
+ await killStraySupervisors();
1140
+ return { ok: true };
1141
+ }
698
1142
 
699
- ${c.dim}Press Ctrl+C to stop${c.reset}
700
- `);
1143
+ function describeRunning(config) {
1144
+ const runtime = readRuntime();
1145
+ const dPid = daemonPid();
1146
+ if (config?.relay?.url) {
1147
+ console.log(` ${c.dim}${'URL'.padEnd(9)}${c.reset}${c.pink}${link(config.relay.url)}${c.reset}`);
1148
+ } else if (config?.tunnelUrl) {
1149
+ console.log(` ${c.dim}${'URL'.padEnd(9)}${c.reset}${c.blue}${link(config.tunnelUrl)}${c.reset}`);
1150
+ }
1151
+ if (runtime || dPid) {
1152
+ const pid = runtime?.pid || dPid;
1153
+ const up = runtime?.startedAt ? ` · up ${formatUptime(Date.now() - runtime.startedAt)}` : '';
1154
+ console.log(` ${c.dim}${'PID'.padEnd(9)}${pid}${up}${c.reset}`);
701
1155
  }
702
1156
  }
703
1157
 
704
- function tunnelFailedMessage(localUrl) {
705
- console.log(`
706
- ${c.dim}─────────────────────────────────${c.reset}
1158
+ // ── Commands ──
707
1159
 
708
- ${c.yellow}⚠${c.reset} ${c.bold}${c.white}Tunnel failed to connect${c.reset}
1160
+ async function cmdStart() {
1161
+ if (!fs.existsSync(CONFIG_PATH)) return cmdInit();
1162
+ banner();
709
1163
 
710
- ${c.dim}CloudFlare quick tunnels are rate-limited.${c.reset}
711
- ${c.dim}This usually resolves itself after a few minutes.${c.reset}
1164
+ const config = readConfig();
1165
+ const runtime = readRuntime();
1166
+ const dPid = hasDaemonSupport() ? daemonPid() : null;
712
1167
 
713
- ${c.bold}${c.white}Your dashboard is available locally:${c.reset}
1168
+ if (!hasDaemonSupport() || FOREGROUND) {
1169
+ // Same already-running guard as the daemon path — otherwise a second
1170
+ // foreground boot just dies on the occupied port with EADDRINUSE noise.
1171
+ const live = runtime?.pid || dPid || scanSupervisors()[0]?.pid;
1172
+ if (live) {
1173
+ console.log(`\n ${GLYPH.warn} Bloby is already running ${c.dim}(PID ${live})${c.reset}. Stop it first: ${c.blue}bloby stop${c.reset}\n`);
1174
+ process.exit(1);
1175
+ }
1176
+ if (!FOREGROUND) {
1177
+ console.log(`\n ${GLYPH.warn} No service manager on this platform — running in the foreground.\n`);
1178
+ }
1179
+ return runForeground();
1180
+ }
714
1181
 
715
- ${c.blue}${c.bold}${link(localUrl)}${c.reset}
716
- ${c.dim}(cmd+click or ctrl+click to open)${c.reset}
1182
+ if (dPid || runtime) {
1183
+ if (dPid) {
1184
+ console.log(`\n ${c.blue}●${c.reset} Bloby is already running. Use ${c.blue}bloby restart${c.reset} if you want to restart it.\n`);
1185
+ describeRunning(config);
1186
+ } else {
1187
+ console.log(`\n ${GLYPH.warn} Bloby is already running ${c.dim}(PID ${runtime.pid}, outside the daemon — a foreground or dev instance)${c.reset}.`);
1188
+ console.log(` Use ${c.blue}bloby restart${c.reset} to consolidate it under the background service.\n`);
1189
+ }
1190
+ commandsFooter();
1191
+ return;
1192
+ }
717
1193
 
718
- ${c.bold}${c.white}To retry the tunnel:${c.reset}
719
- ${c.pink}bloby start${c.reset}
1194
+ // Belt and suspenders: a supervisor that predates the pidfile won't have
1195
+ // written supervisor.json — catch it with a process scan.
1196
+ const strays = scanSupervisors();
1197
+ if (strays.length > 0) {
1198
+ console.log(`\n ${GLYPH.warn} Bloby is already running ${c.dim}(PID ${strays[0].pid}, outside the daemon)${c.reset}.`);
1199
+ console.log(` Use ${c.blue}bloby restart${c.reset} to consolidate it under the background service.\n`);
1200
+ commandsFooter();
1201
+ return;
1202
+ }
720
1203
 
721
- ${c.bold}${c.white}For a persistent tunnel, use a named tunnel:${c.reset}
722
- ${c.pink}bloby tunnel setup${c.reset}
723
- `);
1204
+ console.log('');
1205
+ const s = new Spinner().start('Starting Bloby...');
1206
+ const result = await startCore({ spinner: s });
1207
+ if (!result.ok) {
1208
+ s.fail(`Failed to start: ${result.error}`);
1209
+ console.log(` Check ${c.blue}bloby logs${c.reset} for details.`);
1210
+ commandsFooter();
1211
+ process.exit(1);
1212
+ }
1213
+ s.stopRaw();
1214
+ printReadyBlock(result);
1215
+ commandsFooter();
724
1216
  }
725
1217
 
726
- function finalMessage(tunnelUrl, relayUrl) {
727
- console.log(`
728
- ${c.dim}─────────────────────────────────${c.reset}
729
-
730
- ${c.bold}${c.white}Open your dashboard to finish setup:${c.reset}
731
-
732
- ${c.blue}${c.bold}${link(tunnelUrl)}${c.reset}
733
- ${c.dim}(cmd+click or ctrl+click to open)${c.reset}`);
734
-
735
- if (relayUrl && relayUrl !== tunnelUrl) {
736
- console.log(`
737
- ${c.bold}${c.white}Your permanent URL:${c.reset}
738
-
739
- ${c.pink}${c.bold}${link(relayUrl)}${c.reset}`);
1218
+ async function cmdStop() {
1219
+ banner();
1220
+ if (!hasDaemonSupport()) {
1221
+ console.log(`\n ${GLYPH.warn} No service manager on this platform — stop the foreground process with Ctrl+C.\n`);
1222
+ process.exit(1);
740
1223
  }
741
1224
 
742
- if (hasDaemonSupport()) {
743
- console.log(`
744
- ${c.dim}─────────────────────────────────${c.reset}
745
-
746
- ${c.bold}${c.white}Commands:${c.reset}
747
- ${c.dim}Status${c.reset} ${c.pink}bloby status${c.reset}
748
- ${c.dim}Logs${c.reset} ${c.pink}bloby logs${c.reset}
749
- ${c.dim}Stop${c.reset} ${c.pink}bloby stop${c.reset}
750
- ${c.dim}Restart${c.reset} ${c.pink}bloby daemon restart${c.reset}
751
- ${c.dim}Update${c.reset} ${c.pink}bloby update${c.reset}
752
- `);
753
- } else {
754
- console.log(`
755
- ${c.dim}─────────────────────────────────${c.reset}
756
-
757
- ${c.bold}${c.white}Commands:${c.reset}
758
- ${c.dim}Status${c.reset} ${c.pink}bloby status${c.reset}
759
- ${c.dim}Update${c.reset} ${c.pink}bloby update${c.reset}
1225
+ // "Alive" includes a loaded-but-currently-dead service: during a crash loop
1226
+ // the pid is dead between respawns, but launchd/systemd will resurrect it —
1227
+ // stop must still boot the job out.
1228
+ const jobLoaded = PLATFORM === 'darwin'
1229
+ ? launchdJob().loaded !== false
1230
+ : PLATFORM === 'linux' && ['active', 'activating'].includes(systemdShow().activeState);
1231
+ const anythingAlive = jobLoaded || isDaemonActive() || readRuntime() || scanSupervisors().length > 0;
1232
+ if (!anythingAlive) {
1233
+ console.log(`\n ${c.dim}● Bloby is not running.${c.reset}`);
1234
+ commandsFooter();
1235
+ return;
1236
+ }
760
1237
 
761
- ${c.dim}Press Ctrl+C to stop${c.reset}
762
- `);
1238
+ console.log('');
1239
+ const s = new Spinner().start('Stopping Bloby...');
1240
+ const result = await stopCore({ spinner: s });
1241
+ if (!result.ok) {
1242
+ s.fail(`Failed to stop: ${result.error}`);
1243
+ commandsFooter();
1244
+ process.exit(1);
763
1245
  }
1246
+ s.succeed('Bloby stopped.');
1247
+ commandsFooter();
764
1248
  }
765
1249
 
766
- function readyMessage(tunnelUrl, relayUrl) {
767
- if (relayUrl) {
768
- console.log(`\n ${c.pink}${c.bold}${link(relayUrl)}${c.reset}`);
769
- console.log(` ${c.dim}(cmd+click or ctrl+click to open)${c.reset}`);
1250
+ async function cmdRestart() {
1251
+ if (!fs.existsSync(CONFIG_PATH)) {
1252
+ console.log(`\n ${GLYPH.err} No config found. Run ${c.blue}bloby init${c.reset} first.\n`);
1253
+ process.exit(1);
770
1254
  }
771
- if (tunnelUrl && tunnelUrl !== relayUrl) {
772
- console.log(` ${c.dim}Tunnel:${c.reset} ${c.dim}${link(tunnelUrl)}${c.reset}`);
1255
+ banner();
1256
+ if (!hasDaemonSupport()) {
1257
+ console.log(`\n ${GLYPH.warn} No service manager on this platform — restart the foreground process manually.\n`);
1258
+ process.exit(1);
773
1259
  }
1260
+
774
1261
  console.log('');
775
- }
1262
+ // Anything that can block (cloudflared download) happens BEFORE the stop —
1263
+ // never leave Bloby down while waiting on the network.
1264
+ const config = readConfig() || {};
1265
+ const preMode = tunnelModeOf(config);
1266
+ if (preMode !== 'off' && preMode !== 'named' && !hasCloudflared()) {
1267
+ const sCf = new Spinner().start('Installing cloudflared...');
1268
+ try { await installCloudflared(); sCf.succeed('cloudflared ready'); }
1269
+ catch (e) { sCf.fail(`cloudflared install failed: ${e.message}`); process.exit(1); }
1270
+ }
1271
+
1272
+ const s = new Spinner().start('Stopping Bloby...');
1273
+ const stopped = await stopCore({ spinner: s });
1274
+ if (!stopped.ok) {
1275
+ s.fail(`Failed to stop: ${stopped.error}`);
1276
+ commandsFooter();
1277
+ process.exit(1);
1278
+ }
1279
+ s.succeed('Stopped.');
776
1280
 
777
- function writeVersionFile(version) {
778
- try { fs.writeFileSync(path.join(DATA_DIR, 'VERSION'), version); } catch {}
1281
+ const s2 = new Spinner().start('Starting Bloby...');
1282
+ const result = await startCore({ spinner: s2 });
1283
+ if (!result.ok) {
1284
+ s2.fail(`Failed to start: ${result.error}`);
1285
+ console.log(` Check ${c.blue}bloby logs${c.reset} for details.`);
1286
+ commandsFooter();
1287
+ process.exit(1);
1288
+ }
1289
+ s2.stopRaw();
1290
+ printReadyBlock(result);
1291
+ commandsFooter();
779
1292
  }
780
1293
 
781
- /**
782
- * Poll daemon log for __READY__ and config.json for tunnel URL until the daemon
783
- * is fully ready (tunnel registered with relay). logOffset should be captured
784
- * BETWEEN stopping and starting the daemon so only new-session output is checked.
785
- *
786
- * Advances the stepper through tunnel + verification steps.
787
- */
788
- async function waitForDaemonHealth(stepper, config, hasTunnel, logOffset) {
789
- const port = config.port || 7400;
790
- const relayUrl = config.relay?.url || null;
791
- let tunnelUrl = null;
792
- let tunnelShown = false;
793
- let ready = false;
794
-
795
- const oldTunnelUrl = config.tunnelUrl || null;
796
-
797
- // Daemon log file the supervisor writes __READY__ to stdout which goes here
798
- const logFile = PLATFORM === 'darwin'
799
- ? path.join(LAUNCHD_LOG_DIR, 'bloby.log')
800
- : null;
1294
+ async function cmdStatus() {
1295
+ const config = readConfig();
1296
+ const port = config?.port || 7400;
1297
+ const runtime = readRuntime();
1298
+ const dPid = daemonPid();
1299
+ const installed = isDaemonInstalled();
1300
+ const strays = scanSupervisors();
1301
+ const health = await fetchHealth(port, 2000);
1302
+
1303
+ const pid = runtime?.pid || dPid || (strays[0]?.pid ?? null);
1304
+ const running = pid !== null || !!health;
1305
+ const version = runtime?.version || pkg.version;
1306
+
1307
+ // A loaded service with no live pid is crash-looping or throttled — launchd/
1308
+ // systemd will respawn it, so calling that "stopped" would mislead.
1309
+ const jobLoadedNoPid = !running && (
1310
+ (PLATFORM === 'darwin' && installed && launchdJob().loaded === true) ||
1311
+ (PLATFORM === 'linux' && ['activating', 'active'].includes(systemdShow().activeState))
1312
+ );
801
1313
 
802
- for (let i = 0; i < 120; i++) {
803
- await new Promise(r => setTimeout(r, 1000));
1314
+ console.log('');
1315
+ if (running && health) {
1316
+ const up = runtime?.startedAt ? ` · up ${formatUptime(Date.now() - runtime.startedAt)}` : (health.uptime != null ? ` · up ${formatUptime(health.uptime * 1000)}` : '');
1317
+ const mode = dPid && pid === dPid ? 'daemon' : 'foreground';
1318
+ const pidPart = pid ? ` · PID ${pid}` : '';
1319
+ console.log(` ${c.blue}●${c.reset} ${c.bold}Bloby is running${c.reset} ${c.dim}v${version}${pidPart}${up} · ${mode}${c.reset}`);
1320
+ } else if (running) {
1321
+ console.log(` ${c.yellow}●${c.reset} ${c.bold}Bloby is starting${c.reset} ${c.dim}PID ${pid} alive, not answering yet — give it a moment${c.reset}`);
1322
+ } else if (jobLoadedNoPid) {
1323
+ console.log(` ${c.yellow}●${c.reset} ${c.bold}Bloby is restarting${c.reset} ${c.dim}service loaded but the process is down (starting or crash-looping) — check ${c.reset}${c.blue}bloby logs${c.reset}`);
1324
+ } else if (installed) {
1325
+ console.log(` ${c.dim}● Bloby is stopped${c.reset} ${c.dim}run ${c.reset}${c.blue}bloby start${c.reset}`);
1326
+ } else {
1327
+ console.log(` ${c.dim}● Bloby is not set up${c.reset} ${c.dim}run ${c.reset}${c.blue}bloby init${c.reset}`);
1328
+ }
804
1329
 
805
- // Check for new tunnel URL in config
806
- if (hasTunnel && !tunnelShown) {
1330
+ if (config) {
1331
+ console.log('');
1332
+ console.log(` ${c.dim}${'Local'.padEnd(9)}${c.reset}${c.blue}${link(`http://localhost:${port}`)}${c.reset}`);
1333
+ const mode = tunnelModeOf(config);
1334
+ if (running && config.tunnelUrl) {
1335
+ console.log(` ${c.dim}${'Tunnel'.padEnd(9)}${c.reset}${c.blue}${link(config.tunnelUrl)}${c.reset} ${c.dim}(${mode})${c.reset}`);
1336
+ } else {
1337
+ console.log(` ${c.dim}${'Tunnel'.padEnd(9)}${mode}${c.reset}`);
1338
+ }
1339
+ if (config.relay?.url) {
1340
+ console.log(` ${c.dim}${'Relay'.padEnd(9)}${c.reset}${c.pink}${link(config.relay.url)}${c.reset}`);
1341
+ }
1342
+ if (PLATFORM === 'darwin') {
1343
+ let logNote = '';
807
1344
  try {
808
- const cfg = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8'));
809
- if (cfg.tunnelUrl && cfg.tunnelUrl !== oldTunnelUrl) {
810
- tunnelUrl = cfg.tunnelUrl;
811
- stepper.advance(); // Connecting tunnel done
812
- if (relayUrl) {
813
- stepper.setInfo([
814
- ` ${c.dim}Waiting for ${c.reset}${c.white}${relayUrl.replace('https://', '')}${c.reset}${c.dim} to become reachable (can take up to 2 min)${c.reset}`,
815
- ` ${c.dim}In the meanwhile you can access:${c.reset} ${c.blue}${link(tunnelUrl)}${c.reset}`,
816
- ]);
817
- }
818
- tunnelShown = true;
819
- }
1345
+ const st = fs.statSync(LAUNCHD_LOG);
1346
+ logNote = ` ${c.dim}· updated ${formatAgo(Date.now() - st.mtimeMs)}${c.reset}`;
820
1347
  } catch {}
1348
+ console.log(` ${c.dim}${'Logs'.padEnd(9)}${LAUNCHD_LOG.replace(os.homedir(), '~')}${c.reset}${logNote}`);
1349
+ } else if (PLATFORM === 'linux') {
1350
+ console.log(` ${c.dim}${'Logs'.padEnd(9)}journalctl -u ${SERVICE_NAME}${c.reset}`);
821
1351
  }
1352
+ console.log(` ${c.dim}${'Config'.padEnd(9)}${CONFIG_PATH.replace(os.homedir(), '~')}${c.reset}`);
1353
+ }
822
1354
 
823
- // Check for __READY__ in daemon log (emitted after relay registration)
824
- if (!ready) {
825
- if (logFile && fs.existsSync(logFile)) {
826
- try {
827
- const currentSize = fs.statSync(logFile).size;
828
- if (currentSize > logOffset) {
829
- const fd = fs.openSync(logFile, 'r');
830
- const buf = Buffer.alloc(currentSize - logOffset);
831
- fs.readSync(fd, buf, 0, buf.length, logOffset);
832
- fs.closeSync(fd);
833
- const newContent = buf.toString('utf-8');
834
- if (newContent.includes('__READY__')) {
835
- ready = true;
836
- }
837
- }
838
- } catch {}
839
- } else {
840
- // Linux / no log file fallback: check local health
841
- try {
842
- const res = await fetch(`http://127.0.0.1:${port}/api/health`);
843
- if (res.ok) ready = true;
844
- } catch {}
845
- }
846
- }
847
-
848
- if (ready && (!hasTunnel || tunnelShown)) break;
1355
+ // Trust warnings never let status lie about which process is which.
1356
+ if (running && dPid === null && pid) {
1357
+ console.log(`\n ${GLYPH.warn} Running outside the daemon ${c.dim}(foreground or dev instance — logs/auto-restart don't apply)${c.reset}.`);
1358
+ console.log(` ${c.dim}Run ${c.reset}${c.blue}bloby restart${c.reset}${c.dim} to bring it back under the background service.${c.reset}`);
1359
+ }
1360
+ if (dPid && strays.some(p => p.pid !== dPid)) {
1361
+ console.log(`\n ${GLYPH.warn} Found extra Bloby processes besides the daemon: ${strays.filter(p => p.pid !== dPid).map(p => p.pid).join(', ')}.`);
1362
+ console.log(` ${c.dim}Run ${c.reset}${c.blue}bloby restart${c.reset}${c.dim} to clean them up.${c.reset}`);
849
1363
  }
850
1364
 
851
- // If tunnel never came up, advance past tunnel step anyway
852
- if (hasTunnel && !tunnelShown) {
853
- stepper.advance();
1365
+ // Best-effort update check — never slows status down more than ~1.5s.
1366
+ if (isTTY) {
1367
+ try {
1368
+ const res = await fetch(`https://registry.npmjs.org/bloby-bot/latest?_t=${Date.now()}`, {
1369
+ headers: { Accept: 'application/json' },
1370
+ signal: AbortSignal.timeout(1500),
1371
+ });
1372
+ if (res.ok) {
1373
+ const latest = await res.json();
1374
+ if (latest.version && compareVersions(latest.version, pkg.version) > 0) {
1375
+ console.log(`\n ${GLYPH.dl} Update available: ${c.dim}v${pkg.version} →${c.reset} ${c.bold}v${latest.version}${c.reset} ${c.dim}— run ${c.reset}${c.blue}bloby update${c.reset}`);
1376
+ }
1377
+ }
1378
+ } catch {}
854
1379
  }
855
1380
 
856
- if (tunnelShown) {
857
- stepper.setInfo([]);
1381
+ commandsFooter();
1382
+ }
1383
+
1384
+ function cmdLogs() {
1385
+ const lines = Math.max(1, Number(flagValue('-n') || flagValue('--lines') || 80) || 80);
1386
+
1387
+ // Provenance header — `bloby logs` must never silently show a dead process's
1388
+ // output. Establish who is running and whether these logs belong to it.
1389
+ const runtime = readRuntime();
1390
+ const dPid = daemonPid();
1391
+ const strays = scanSupervisors();
1392
+ const livePid = runtime?.pid || dPid || strays[0]?.pid || null;
1393
+
1394
+ if (PLATFORM === 'win32') {
1395
+ console.log(`\n ${GLYPH.warn} Logs are shown in the foreground terminal on Windows.\n`);
1396
+ process.exit(1);
858
1397
  }
859
1398
 
860
- // Advance past "Verifying connection"
861
- stepper.advance();
1399
+ if (!livePid) {
1400
+ console.log(`\n ${c.dim}● Bloby is not running — showing logs from the last run.${c.reset}\n`);
1401
+ } else if (dPid === null) {
1402
+ // Daemon logs (file or journal) can only contain daemon output — a live
1403
+ // non-daemon instance means everything below is from a previous run.
1404
+ const sink = PLATFORM === 'linux' ? 'the journal' : 'this log file';
1405
+ console.log(`\n ${GLYPH.warn} Bloby is running ${c.bold}outside the daemon${c.reset} (PID ${livePid}) — its output is NOT in ${sink}.`);
1406
+ console.log(` ${c.dim}These lines are from the last daemon run. Run ${c.reset}${c.blue}bloby restart${c.reset}${c.dim} to fix.${c.reset}\n`);
1407
+ } else if (runtime?.startedAt) {
1408
+ console.log(`\n ${c.blue}●${c.reset} ${c.dim}PID ${livePid} · started ${formatAgo(Date.now() - runtime.startedAt)}${c.reset}\n`);
1409
+ } else {
1410
+ console.log('');
1411
+ }
862
1412
 
863
- // Re-read config for final URLs
864
- try {
865
- const cfg = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8'));
866
- if (cfg.tunnelUrl) tunnelUrl = cfg.tunnelUrl;
867
- } catch {}
1413
+ if (PLATFORM === 'darwin') {
1414
+ if (!fs.existsSync(LAUNCHD_LOG)) {
1415
+ console.log(` ${c.dim}No logs yet at ${LAUNCHD_LOG.replace(os.homedir(), '~')}${c.reset}`);
1416
+ commandsFooter();
1417
+ return;
1418
+ }
1419
+ if (FOLLOW) {
1420
+ // -F (not -f): follows by NAME, so it survives rotation — no more silent stale tails.
1421
+ spawnSync('tail', ['-F', '-n', String(lines), LAUNCHD_LOG], { stdio: 'inherit' });
1422
+ return;
1423
+ }
1424
+ spawnSync('tail', ['-n', String(lines), LAUNCHD_LOG], { stdio: 'inherit' });
1425
+ commandsFooter();
1426
+ return;
1427
+ }
868
1428
 
869
- return { tunnelUrl, relayUrl, healthOk: ready };
1429
+ // Linux: journald
1430
+ const jArgs = ['-u', SERVICE_NAME, '-n', String(lines), '--no-pager', '-q'];
1431
+ if (FOLLOW) {
1432
+ spawnSync('journalctl', [...jArgs, '-f'], { stdio: 'inherit' });
1433
+ return;
1434
+ }
1435
+ const res = spawnSync('journalctl', jArgs, { stdio: ['ignore', 'pipe', 'pipe'], encoding: 'utf-8' });
1436
+ if (res.stdout && res.stdout.trim()) {
1437
+ process.stdout.write(res.stdout);
1438
+ } else {
1439
+ console.log(` ${c.dim}No journal entries readable. If Bloby has run before, your user may lack journal access:${c.reset}`);
1440
+ console.log(` ${c.blue}sudo journalctl -u ${SERVICE_NAME} -n ${lines}${c.reset}`);
1441
+ }
1442
+ commandsFooter();
870
1443
  }
871
1444
 
872
- // ── Steps ──
1445
+ // ── init ──
873
1446
 
874
1447
  function createConfig() {
875
1448
  fs.mkdirSync(DATA_DIR, { recursive: true });
@@ -885,161 +1458,213 @@ function createConfig() {
885
1458
  }
886
1459
  }
887
1460
 
888
- const MIN_CF_SIZE = 10 * 1024 * 1024; // 10 MB — valid cloudflared is ~30-50 MB
889
-
890
- function hasCloudflared() {
891
- // Check system-wide install
892
- const which = process.platform === 'win32' ? 'where cloudflared' : 'which cloudflared';
893
- try { execSync(which, { stdio: 'ignore' }); return true; } catch {}
894
-
895
- // Check local install (validate by file size, never execute — avoids Windows popup)
896
- const cfExe = process.platform === 'win32' ? CF_PATH + '.exe' : CF_PATH;
897
- if (!fs.existsSync(cfExe)) return false;
898
- const size = fs.statSync(cfExe).size;
899
- if (size < MIN_CF_SIZE) {
900
- fs.unlinkSync(cfExe);
901
- return false;
1461
+ function chooseTunnelMode() {
1462
+ // Headless init (pipes, CI, provisioning without --hosted) can't drive an
1463
+ // arrow-key menu — default to quick instead of crashing on setRawMode.
1464
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
1465
+ console.log(` ${c.dim}No interactive terminal defaulting to the quick tunnel. Change later with ${c.reset}${c.blue}bloby tunnel setup${c.reset}${c.dim}.${c.reset}`);
1466
+ return Promise.resolve('quick');
902
1467
  }
903
- return true;
904
- }
905
-
906
- async function installCloudflared() {
907
- if (hasCloudflared()) return;
908
1468
 
909
- fs.mkdirSync(BIN_DIR, { recursive: true });
1469
+ return new Promise((resolve) => {
1470
+ const options = [
1471
+ {
1472
+ label: 'Quick Tunnel',
1473
+ mode: 'quick',
1474
+ tag: 'Easy and Fast',
1475
+ tagColor: c.green,
1476
+ desc: [
1477
+ 'Random CloudFlare tunnel URL on every start/update',
1478
+ `Optional: Use Bloby Relay Server and access your bot at ${c.reset}${c.pink}open.bloby.bot/YOURBOT${c.reset}${c.dim} (Free)`,
1479
+ `Or use a premium handle like ${c.reset}${c.pink}bloby.bot/YOURBOT${c.reset}${c.dim} ($5 one-time fee)`,
1480
+ ],
1481
+ },
1482
+ {
1483
+ label: 'Named Tunnel',
1484
+ mode: 'named',
1485
+ tag: 'Advanced',
1486
+ tagColor: c.yellow,
1487
+ desc: [
1488
+ 'Persistent URL with your own domain',
1489
+ 'Requires a CloudFlare account + domain',
1490
+ `Use a subdomain like ${c.reset}${c.white}bot.YOURDOMAIN.COM${c.reset}${c.dim} or the root domain`,
1491
+ ],
1492
+ },
1493
+ {
1494
+ label: 'Private Network',
1495
+ mode: 'off',
1496
+ tag: 'Secure',
1497
+ tagColor: c.cyan,
1498
+ desc: [
1499
+ 'No public URL — access via local network or VPN only',
1500
+ `Use with ${c.reset}${c.white}Tailscale${c.reset}${c.dim}, WireGuard, or any private network`,
1501
+ 'Your bot stays invisible to the internet',
1502
+ ],
1503
+ },
1504
+ ];
910
1505
 
911
- const platform = os.platform();
912
- // os.arch() returns Node's arch, not the OS. Use PROCESSOR_ARCHITECTURE on Windows for real OS arch.
913
- const arch = platform === 'win32'
914
- ? (process.env.PROCESSOR_ARCHITECTURE || os.arch()).toLowerCase()
915
- : os.arch();
916
- let url;
1506
+ let selected = 0;
1507
+ let lineCount = 0;
917
1508
 
918
- if (platform === 'win32') {
919
- url = arch.includes('arm')
920
- ? 'https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-windows-arm64.exe'
921
- : 'https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-windows-amd64.exe';
922
- } else if (platform === 'darwin') {
923
- url = arch === 'arm64'
924
- ? 'https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-darwin-arm64.tgz'
925
- : 'https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-darwin-amd64.tgz';
926
- } else if (platform === 'linux') {
927
- if (arch === 'arm64' || arch === 'aarch64') {
928
- url = 'https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-arm64';
929
- } else if (arch === 'arm' || arch === 'armv7l') {
930
- url = 'https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-arm';
931
- } else {
932
- url = 'https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64';
1509
+ function writeLine(text = '') {
1510
+ process.stdout.write(`\x1b[2K${text}\n`);
1511
+ lineCount++;
933
1512
  }
934
- } else {
935
- throw new Error(`Unsupported platform: ${platform}/${arch}`);
936
- }
937
1513
 
938
- if (platform === 'win32') {
939
- const dest = CF_PATH + '.exe';
940
- execSync(`curl.exe -fsSL -o "${dest}" "${url}"`, { stdio: 'ignore' });
941
- } else if (url.endsWith('.tgz')) {
942
- execSync(`curl -fsSL "${url}" | tar xz -C "${BIN_DIR}"`, { stdio: 'ignore' });
943
- } else {
944
- execSync(`curl -fsSL -o "${CF_PATH}" "${url}"`, { stdio: 'ignore' });
945
- fs.chmodSync(CF_PATH, 0o755);
946
- }
947
- }
1514
+ function render() {
1515
+ if (lineCount > 0) process.stdout.write(`\x1b[${lineCount}A`);
1516
+ lineCount = 0;
948
1517
 
949
- // ── Boot server ──
1518
+ writeLine(` ${c.bold}${c.white}How do you want to connect your bot?${c.reset}`);
1519
+ writeLine();
950
1520
 
951
- function bootServer({ onTunnelUp, onReady } = {}) {
952
- return new Promise((resolve, reject) => {
953
- const child = spawn(
954
- process.execPath,
955
- ['--import', 'tsx/esm', path.join(ROOT, 'supervisor/index.ts')],
956
- { cwd: ROOT, stdio: ['ignore', 'pipe', 'pipe'], env: { ...process.env } },
957
- );
1521
+ for (let i = 0; i < options.length; i++) {
1522
+ const opt = options[i];
1523
+ const isSelected = i === selected;
1524
+ const bullet = isSelected ? `${c.pink}❯` : `${c.dim} `;
1525
+ const label = isSelected ? `${c.bold}${c.white}${opt.label}` : `${c.dim}${opt.label}`;
1526
+ const tag = `${opt.tagColor}[${opt.tag}]${c.reset}`;
958
1527
 
959
- let tunnelUrl = null;
960
- let relayUrl = null;
961
- let resolved = false;
962
- let stderrBuf = '';
963
- let tunnelFired = false;
964
- let tunnelFailed = false;
1528
+ writeLine(` ${bullet} ${label}${c.reset} ${tag}`);
1529
+ for (const line of opt.desc) {
1530
+ writeLine(` ${c.dim}${line}${c.reset}`);
1531
+ }
1532
+ if (i < options.length - 1) writeLine();
1533
+ }
1534
+ }
965
1535
 
966
- // Vite warmup tracking
967
- let viteWarmResolve;
968
- const viteWarm = new Promise((r) => { viteWarmResolve = r; });
1536
+ process.stdout.write('\x1b[?25l');
1537
+ render();
969
1538
 
970
- const doResolve = () => {
971
- if (resolved) return;
972
- resolved = true;
973
- if (!tunnelFired && onTunnelUp) onTunnelUp();
974
- if (onReady) onReady();
975
- const config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8'));
976
- resolve({
977
- child,
978
- tunnelUrl: tunnelUrl || `http://localhost:${config.port}`,
979
- relayUrl: relayUrl || config.relay?.url || null,
980
- tunnelFailed,
981
- viteWarm,
982
- });
1539
+ process.stdin.setRawMode(true);
1540
+ process.stdin.resume();
1541
+ process.stdin.setEncoding('utf-8');
1542
+
1543
+ const onKey = (key) => {
1544
+ if (key === '\x1b[A' || key === 'k') {
1545
+ selected = (selected - 1 + options.length) % options.length;
1546
+ render();
1547
+ } else if (key === '\x1b[B' || key === 'j') {
1548
+ selected = (selected + 1) % options.length;
1549
+ render();
1550
+ } else if (key === '\r' || key === '\n') {
1551
+ process.stdout.write('\x1b[?25h');
1552
+ process.stdin.setRawMode(false);
1553
+ process.stdin.pause();
1554
+ process.stdin.removeListener('data', onKey);
1555
+ resolve(options[selected].mode);
1556
+ } else if (key === '\x03') {
1557
+ restoreTerminal();
1558
+ process.stdout.write('\n');
1559
+ process.exit(130);
1560
+ }
983
1561
  };
984
1562
 
985
- const handleData = (data) => {
986
- const text = data.toString();
1563
+ process.stdin.on('data', onKey);
1564
+ });
1565
+ }
987
1566
 
988
- const tunnelMatch = text.match(/__TUNNEL_URL__=(\S+)/);
989
- if (tunnelMatch) {
990
- tunnelUrl = tunnelMatch[1];
991
- if (!tunnelFired && onTunnelUp) { tunnelFired = true; onTunnelUp(tunnelUrl); }
992
- }
1567
+ function ask(question) {
1568
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
1569
+ return new Promise((resolve) => rl.question(question, (answer) => { rl.close(); resolve(answer.trim()); }));
1570
+ }
993
1571
 
994
- const relayMatch = text.match(/__RELAY_URL__=(\S+)/);
995
- if (relayMatch) relayUrl = relayMatch[1];
1572
+ async function runNamedTunnelSetup() {
1573
+ const s = new Spinner().start('Checking cloudflared...');
1574
+ await installCloudflared();
1575
+ s.succeed('cloudflared ready');
1576
+
1577
+ // Resolve the binary ONCE. Note: spawnSync does not throw on ENOENT (it returns
1578
+ // {error}), so a try/catch fallback around it is dead code — the old CLI
1579
+ // silently skipped `tunnel login` whenever cloudflared wasn't on PATH.
1580
+ const cf = (() => {
1581
+ try { execSync(PLATFORM === 'win32' ? 'where cloudflared' : 'which cloudflared', { stdio: 'ignore' }); return 'cloudflared'; } catch {}
1582
+ return PLATFORM === 'win32' ? CF_PATH + '.exe' : CF_PATH;
1583
+ })();
996
1584
 
997
- if (text.includes('__VITE_WARM__')) {
998
- viteWarmResolve();
999
- }
1585
+ console.log(`\n ${c.bold}${c.white}Step 1:${c.reset} Log in to Cloudflare\n`);
1586
+ console.log(` ${c.dim}This will open a browser window. Authorize the domain you want to use.${c.reset}\n`);
1587
+ const login = spawnSync(cf, ['tunnel', 'login'], { stdio: 'inherit' });
1588
+ if (login.error || login.status !== 0) {
1589
+ console.log(`\n ${GLYPH.err} cloudflared login failed${login.error ? `: ${login.error.message}` : ''}.\n`);
1590
+ process.exit(1);
1591
+ }
1592
+ console.log('');
1000
1593
 
1001
- if (text.includes('__READY__')) {
1002
- doResolve();
1003
- return;
1004
- }
1594
+ const tunnelName = (await ask(` ${c.bold}Tunnel name${c.reset} ${c.dim}(default: bloby)${c.reset}: `)) || 'bloby';
1005
1595
 
1006
- if (text.includes('__TUNNEL_FAILED__')) {
1007
- tunnelFailed = true;
1008
- doResolve();
1009
- }
1010
- };
1596
+ const s2 = new Spinner().start(`Creating tunnel "${tunnelName}"...`);
1597
+ const create = spawnSync(cf, ['tunnel', 'create', tunnelName], { encoding: 'utf-8' });
1598
+ if (create.error || create.status !== 0) {
1599
+ s2.fail('Failed to create tunnel. It may already exist.');
1600
+ console.log(` ${c.dim}Try: cloudflared tunnel list${c.reset}\n`);
1601
+ process.exit(1);
1602
+ }
1603
+ const createOutput = (create.stdout || '') + (create.stderr || '');
1011
1604
 
1012
- // Safety-net timeout: resolve after 45s even if __READY__ never arrives
1013
- setTimeout(doResolve, 45_000);
1605
+ const uuidMatch = createOutput.match(/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/i);
1606
+ if (!uuidMatch) {
1607
+ s2.fail('Could not parse tunnel UUID from output.');
1608
+ console.log(` ${c.dim}${createOutput}${c.reset}\n`);
1609
+ process.exit(1);
1610
+ }
1611
+ const tunnelUuid = uuidMatch[1];
1612
+ s2.succeed(`Tunnel created: ${c.dim}${tunnelUuid}${c.reset}`);
1014
1613
 
1015
- child.stdout.on('data', handleData);
1016
- child.stderr.on('data', (data) => {
1017
- stderrBuf += data.toString();
1018
- handleData(data);
1019
- });
1614
+ const domain = await ask(` ${c.bold}Your domain${c.reset} ${c.dim}(e.g. bot.mydomain.com)${c.reset}: `);
1615
+ if (!domain) {
1616
+ console.log(`\n ${GLYPH.err} Domain is required for named tunnels.\n`);
1617
+ process.exit(1);
1618
+ }
1619
+
1620
+ const config = readConfig() || {};
1621
+ const port = config.port || 7400;
1622
+ const cfHome = path.join(os.homedir(), '.cloudflared');
1623
+ const cfConfigPath = path.join(DATA_DIR, 'cloudflared-config.yml');
1624
+
1625
+ const yamlContent = `tunnel: ${tunnelUuid}
1626
+ credentials-file: ${path.join(cfHome, `${tunnelUuid}.json`)}
1627
+ ingress:
1628
+ - service: http://localhost:${port}
1629
+ `;
1630
+ fs.mkdirSync(DATA_DIR, { recursive: true });
1631
+ fs.writeFileSync(cfConfigPath, yamlContent);
1632
+ console.log(`\n ${GLYPH.ok} Config written to ${c.dim}${cfConfigPath}${c.reset}`);
1020
1633
 
1021
- process.on('SIGINT', () => child.kill('SIGINT'));
1022
- process.on('SIGTERM', () => child.kill('SIGTERM'));
1634
+ console.log(`\n${DIVIDER}\n`);
1635
+ console.log(` ${c.bold}${c.white}Tunnel created!${c.reset}`);
1636
+ console.log(` ${c.white}Add a CNAME record pointing to:${c.reset}\n`);
1637
+ console.log(` ${c.pink}${c.bold}${tunnelUuid}.cfargotunnel.com${c.reset}\n`);
1638
+ console.log(` ${c.dim}Or run: cloudflared tunnel route dns ${tunnelName} ${domain}${c.reset}\n`);
1023
1639
 
1024
- child.on('exit', (code) => {
1025
- if (!resolved) {
1026
- reject(new Error(stderrBuf.trim() || `Server exited with code ${code}`));
1027
- } else {
1028
- process.exit(code ?? 1);
1029
- }
1030
- });
1031
- });
1640
+ return { tunnelName, domain, cfConfigPath };
1032
1641
  }
1033
1642
 
1034
- // ── Main flows ──
1643
+ async function cmdInit() {
1644
+ banner();
1035
1645
 
1036
- async function init() {
1037
- if (!HOSTED) banner();
1646
+ // Re-running init against a live install must not re-bootstrap anything (the
1647
+ // health wait would just sit out its tunnel deadline against an unchanged URL).
1648
+ if (fs.existsSync(CONFIG_PATH) && hasDaemonSupport()) {
1649
+ const runtime = readRuntime();
1650
+ const dPid = daemonPid();
1651
+ if (dPid || runtime) {
1652
+ const config = readConfig();
1653
+ if (HOSTED) {
1654
+ console.log(`__HOSTED_READY__=${JSON.stringify({ tunnelUrl: config?.tunnelUrl || `http://localhost:${config?.port || 7400}`, status: config?.tunnelUrl ? 'ok' : 'tunnel_failed', daemon: true })}`);
1655
+ process.exit(0);
1656
+ }
1657
+ console.log(`\n ${c.blue}●${c.reset} Bloby is already set up and running. Use ${c.blue}bloby restart${c.reset} to restart it, or ${c.blue}bloby tunnel setup${c.reset} to change the tunnel.\n`);
1658
+ describeRunning(config);
1659
+ commandsFooter();
1660
+ return;
1661
+ }
1662
+ }
1038
1663
 
1039
1664
  createConfig();
1040
1665
  writeVersionFile(pkg.version);
1041
1666
 
1042
- // --hosted: non-interactive, quick tunnel, no prompts
1667
+ // --hosted: non-interactive, quick tunnel, machine-readable output
1043
1668
  const tunnelMode = HOSTED ? 'quick' : await (async () => {
1044
1669
  console.log('');
1045
1670
  const mode = await chooseTunnelMode();
@@ -1047,8 +1672,7 @@ async function init() {
1047
1672
  return mode;
1048
1673
  })();
1049
1674
 
1050
- // Update config with chosen mode + generate wallet if needed
1051
- const config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8'));
1675
+ const config = readConfig() || {};
1052
1676
 
1053
1677
  // Generate USDC wallet (skip if one already exists)
1054
1678
  if (!config.wallet?.privateKey) {
@@ -1058,7 +1682,6 @@ async function init() {
1058
1682
  config.wallet = { privateKey, address: account.address };
1059
1683
  }
1060
1684
 
1061
- // Handle named tunnel setup before starting
1062
1685
  if (tunnelMode === 'named') {
1063
1686
  const setup = await runNamedTunnelSetup();
1064
1687
  config.tunnel = {
@@ -1072,433 +1695,143 @@ async function init() {
1072
1695
  }
1073
1696
  fs.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2));
1074
1697
 
1075
- const canDaemon = hasDaemonSupport();
1076
- const hasTunnelInit = tunnelMode !== 'off';
1077
-
1078
- // Hosted mode: simple log lines instead of animated stepper
1079
- const log = HOSTED
1080
- ? (msg) => console.log(`[bloby] ${msg}`)
1081
- : null;
1082
-
1083
- const steps = [
1084
- 'Creating config',
1085
- ...(hasTunnelInit ? ['Installing cloudflared'] : []),
1086
- 'Starting server',
1087
- ...(hasTunnelInit ? ['Connecting tunnel', 'Verifying connection'] : []),
1088
- 'Preparing dashboard',
1089
- ...(canDaemon ? ['Setting up auto-start daemon'] : []),
1090
- ];
1091
-
1092
- const stepper = HOSTED ? null : new Stepper(steps);
1093
- if (stepper) stepper.start();
1094
-
1095
- // Config already created
1096
- if (log) log('Config created');
1097
- if (stepper) stepper.advance();
1098
-
1099
- // Cloudflared (skip for named — already installed during setup, skip for off — no tunnel)
1100
- if (hasTunnelInit && tunnelMode !== 'named') {
1101
- if (log) log('Installing cloudflared...');
1102
- await installCloudflared();
1103
- if (log) log('Cloudflared ready');
1104
- }
1105
- if (hasTunnelInit && stepper) stepper.advance();
1698
+ const log = HOSTED ? (msg) => console.log(`[bloby] ${msg}`) : null;
1106
1699
 
1107
- // Server + Tunnel
1108
- if (log) log('Starting server...');
1109
- if (stepper) stepper.advance();
1110
- let result;
1111
- try {
1112
- result = await bootServer({
1113
- onTunnelUp: hasTunnelInit ? (url) => {
1114
- if (log) log(`Tunnel up: ${url}`);
1115
- if (stepper) {
1116
- stepper.advance(); // Connecting tunnel done
1117
- if (config.relay?.url) {
1118
- stepper.setInfo([
1119
- ` ${c.dim}Waiting for ${c.reset}${c.white}${config.relay.url.replace('https://', '')}${c.reset}${c.dim} to become reachable (can take up to 2 min)${c.reset}`,
1120
- ` ${c.dim}In the meanwhile you can access:${c.reset} ${c.blue}${link(url)}${c.reset}`,
1121
- ]);
1122
- }
1123
- }
1124
- } : undefined,
1125
- onReady: hasTunnelInit ? () => {
1126
- if (log) log('Connection verified');
1127
- if (stepper) {
1128
- stepper.setInfo([]);
1129
- stepper.advance(); // Verifying connection done
1130
- }
1131
- } : undefined,
1132
- });
1133
- } catch (err) {
1134
- if (stepper) stepper.finish();
1700
+ // Hosted containers / Windows: no service manager — run in the foreground and
1701
+ // keep this process alive as the supervisor's parent (the hosted provisioner
1702
+ // depends on this).
1703
+ if (!hasDaemonSupport()) {
1135
1704
  if (HOSTED) {
1136
- console.error(JSON.stringify({ error: err.message }));
1137
- process.exit(1);
1138
- }
1139
- console.error(`\n ${c.red}Server failed to start:${c.reset}`);
1140
- console.error(` ${c.dim}${err.message}${c.reset}\n`);
1141
- process.exit(1);
1142
- }
1143
- let { child, tunnelUrl, relayUrl, tunnelFailed, viteWarm } = result;
1144
-
1145
- // Wait for Vite to finish pre-transforming all modules (with timeout)
1146
- if (log) log('Preparing dashboard...');
1147
- await Promise.race([viteWarm, new Promise(r => setTimeout(r, 30_000))]);
1148
- if (stepper) stepper.advance();
1149
-
1150
- // Install daemon (systemd on Linux, launchd on macOS)
1151
- if (canDaemon) {
1152
- if (log) log('Installing daemon...');
1153
- await killAndWait(child);
1154
- const nodePath = process.execPath;
1155
- const realHome = os.homedir();
1156
- const res = spawnSync(process.execPath, [process.argv[1], 'daemon', 'install'], {
1157
- stdio: 'pipe', // Suppress subprocess output — stepper handles UI
1158
- env: { ...process.env, BLOBY_NODE_PATH: nodePath, BLOBY_REAL_HOME: realHome },
1159
- });
1160
-
1161
- // Wait for the daemon's supervisor to get a new tunnel URL
1162
- // (the old one died with the temp server we just killed)
1163
- if (res.status === 0 && hasTunnelInit) {
1164
- if (log) log('Waiting for daemon tunnel URL...');
1165
- let daemonTunnelUrl = null;
1166
- for (let i = 0; i < 30; i++) {
1167
- await new Promise((r) => setTimeout(r, 1000));
1168
- try {
1169
- const cfg = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8'));
1170
- if (cfg.tunnelUrl && cfg.tunnelUrl !== tunnelUrl) {
1171
- daemonTunnelUrl = cfg.tunnelUrl;
1172
- break;
1173
- }
1174
- } catch {}
1175
- }
1176
- if (daemonTunnelUrl) tunnelUrl = daemonTunnelUrl;
1177
- // Also pick up relay URL that may have been saved earlier
1705
+ if (log) log('Starting server (foreground, no daemon support)...');
1706
+ let result;
1178
1707
  try {
1179
- const cfg = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8'));
1180
- if (cfg.relay?.url) relayUrl = cfg.relay.url;
1181
- } catch {}
1182
- }
1183
-
1184
- if (stepper) {
1185
- stepper.advance();
1186
- stepper.finish();
1187
- }
1188
-
1189
- if (HOSTED) {
1190
- // Machine-readable output for the provisioning script
1191
- const result = { tunnelUrl, status: tunnelFailed ? 'tunnel_failed' : 'ok' };
1192
- if (res.status === 0) result.daemon = true;
1193
- console.log(`__HOSTED_READY__=${JSON.stringify(result)}`);
1194
- process.exit(res.status ?? 0);
1195
- }
1196
-
1197
- if (!hasTunnelInit) {
1198
- privateNetworkMessage(config.port);
1199
- } else if (tunnelFailed) {
1200
- tunnelFailedMessage(tunnelUrl);
1201
- } else {
1202
- finalMessage(tunnelUrl, relayUrl);
1203
- }
1204
- if (res.status === 0) {
1205
- console.log(` ${c.blue}✔${c.reset} Daemon installed — Bloby will auto-start on ${PLATFORM === 'darwin' ? 'login' : 'boot'}.`);
1206
- } else {
1207
- console.log(` ${c.yellow}⚠${c.reset} Daemon install failed. Run ${c.pink}bloby daemon install${c.reset} manually.`);
1708
+ await installCloudflared();
1709
+ result = await bootServer({
1710
+ onTunnelUp: (url) => { if (log && url) log(`Tunnel up: ${url}`); },
1711
+ });
1712
+ } catch (err) {
1713
+ console.error(JSON.stringify({ error: err.message }));
1714
+ process.exit(1);
1715
+ }
1716
+ await Promise.race([result.viteWarm, new Promise(r => setTimeout(r, 30_000))]);
1717
+ console.log(`__HOSTED_READY__=${JSON.stringify({ tunnelUrl: result.tunnelUrl, status: result.tunnelFailed ? 'tunnel_failed' : 'ok' })}`);
1718
+ result.child.stdout.on('data', (d) => process.stdout.write(d));
1719
+ result.child.stderr.on('data', (d) => process.stderr.write(d));
1720
+ return; // stays attached
1208
1721
  }
1209
- console.log('');
1210
- process.exit(res.status ?? 0);
1211
- }
1212
-
1213
- if (stepper) stepper.finish();
1214
- if (HOSTED) {
1215
- const result = { tunnelUrl, status: tunnelFailed ? 'tunnel_failed' : 'ok' };
1216
- console.log(`__HOSTED_READY__=${JSON.stringify(result)}`);
1217
- } else if (!hasTunnelInit) {
1218
- privateNetworkMessage(config.port);
1219
- } else if (tunnelFailed) {
1220
- tunnelFailedMessage(tunnelUrl);
1221
- } else {
1222
- finalMessage(tunnelUrl, relayUrl);
1223
- }
1224
-
1225
- child.stdout.on('data', (d) => {
1226
- process.stdout.write(` ${c.dim}${d.toString().trim()}${c.reset}\n`);
1227
- });
1228
- child.stderr.on('data', (d) => {
1229
- const line = d.toString().trim();
1230
- if (!line || line.includes('AssignProcessToJobObject')) return;
1231
- process.stderr.write(` ${c.dim}${line}${c.reset}\n`);
1232
- });
1233
- }
1234
-
1235
- async function start() {
1236
- if (!fs.existsSync(CONFIG_PATH)) {
1237
- return init();
1722
+ console.log(`\n ${GLYPH.warn} No service manager on this platform — Bloby will run in the foreground.\n`);
1723
+ return runForeground();
1238
1724
  }
1239
1725
 
1240
- // If daemon is already running, don't start a second instance
1241
- if (isDaemonInstalled() && isDaemonActive()) {
1242
- banner();
1243
- const config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8'));
1244
- console.log(`\n ${c.blue}●${c.reset} Bloby is already running as a daemon.\n`);
1245
- if (config.relay?.url) {
1246
- console.log(` ${c.dim}URL:${c.reset} ${c.pink}${link(config.relay.url)}${c.reset}`);
1726
+ if (log) log('Starting daemon...');
1727
+ console.log('');
1728
+ const s = new Spinner().start('Setting up Bloby...');
1729
+ // Hosted provisioning treats tunnel_failed as meaningful — give cold cloud
1730
+ // boxes a longer window before declaring the tunnel down.
1731
+ const result = await startCore({ spinner: s, ...(HOSTED ? { timeoutMs: 150_000 } : {}) });
1732
+ if (!result.ok) {
1733
+ s.fail(`Failed to start: ${result.error}`);
1734
+ if (HOSTED) {
1735
+ console.error(JSON.stringify({ error: result.error }));
1247
1736
  }
1248
- console.log(` ${c.dim}Status:${c.reset} ${c.pink}bloby daemon status${c.reset}`);
1249
- console.log(` ${c.dim}Logs:${c.reset} ${c.pink}bloby daemon logs${c.reset}`);
1250
- console.log(` ${c.dim}Restart:${c.reset} ${c.pink}bloby daemon restart${c.reset}`);
1251
- console.log(` ${c.dim}Stop:${c.reset} ${c.pink}bloby daemon stop${c.reset}\n`);
1252
- return;
1253
- }
1254
-
1255
- const canDaemon = hasDaemonSupport();
1256
- const needsDaemon = canDaemon && !isDaemonInstalled();
1257
-
1258
- banner();
1259
-
1260
- const config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8'));
1261
- const tunnelMode = config.tunnel?.mode ?? (config.tunnel?.enabled === false ? 'off' : 'quick');
1262
- const hasTunnel = tunnelMode !== 'off';
1263
-
1264
- const steps = [
1265
- 'Loading config',
1266
- 'Starting server',
1267
- ...(hasTunnel ? ['Connecting tunnel', 'Verifying connection'] : []),
1268
- 'Preparing dashboard',
1269
- ...(needsDaemon ? ['Setting up auto-start daemon'] : []),
1270
- ];
1271
- const stepper = new Stepper(steps);
1272
- stepper.start();
1273
-
1274
- stepper.advance(); // config exists
1275
- stepper.advance(); // starting
1276
-
1277
- let result;
1278
- try {
1279
- result = await bootServer({
1280
- onTunnelUp: (url) => {
1281
- if (!hasTunnel) return;
1282
- stepper.advance(); // Connecting tunnel done
1283
- if (config.relay?.url) {
1284
- stepper.setInfo([
1285
- ` ${c.dim}Waiting for ${c.reset}${c.white}${config.relay.url.replace('https://', '')}${c.reset}${c.dim} to become reachable (can take up to 2 min)${c.reset}`,
1286
- ` ${c.dim}In the meanwhile you can access:${c.reset} ${c.blue}${link(url)}${c.reset}`,
1287
- ]);
1288
- }
1289
- },
1290
- onReady: () => {
1291
- if (!hasTunnel) return;
1292
- stepper.setInfo([]);
1293
- stepper.advance(); // Verifying connection done
1294
- },
1295
- });
1296
- } catch (err) {
1297
- stepper.finish();
1298
- console.error(`\n ${c.red}Server failed to start:${c.reset}`);
1299
- console.error(` ${c.dim}${err.message}${c.reset}\n`);
1300
1737
  process.exit(1);
1301
1738
  }
1302
- let { child, tunnelUrl, relayUrl, tunnelFailed, viteWarm } = result;
1303
-
1304
- // Wait for Vite to finish pre-transforming all modules (with timeout)
1305
- await Promise.race([viteWarm, new Promise(r => setTimeout(r, 30_000))]);
1306
- stepper.advance();
1307
-
1308
- // Install daemon (systemd on Linux, launchd on macOS) if not already installed
1309
- if (needsDaemon) {
1310
- await killAndWait(child);
1311
- const nodePath = process.execPath;
1312
- const realHome = os.homedir();
1313
- const res = spawnSync(process.execPath, [process.argv[1], 'daemon', 'install'], {
1314
- stdio: 'pipe', // Suppress subprocess output — stepper handles UI
1315
- env: { ...process.env, BLOBY_NODE_PATH: nodePath, BLOBY_REAL_HOME: realHome },
1316
- });
1317
-
1318
- // Wait for the daemon's supervisor to get a new tunnel URL
1319
- if (res.status === 0 && hasTunnel) {
1320
- let daemonTunnelUrl = null;
1321
- for (let i = 0; i < 30; i++) {
1322
- await new Promise((r) => setTimeout(r, 1000));
1323
- try {
1324
- const cfg = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8'));
1325
- if (cfg.tunnelUrl && cfg.tunnelUrl !== tunnelUrl) {
1326
- daemonTunnelUrl = cfg.tunnelUrl;
1327
- break;
1328
- }
1329
- } catch {}
1330
- }
1331
- if (daemonTunnelUrl) tunnelUrl = daemonTunnelUrl;
1332
- try {
1333
- const cfg = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8'));
1334
- if (cfg.relay?.url) relayUrl = cfg.relay.url;
1335
- } catch {}
1336
- }
1337
-
1338
- stepper.advance();
1339
- stepper.finish();
1340
- if (!hasTunnel) {
1341
- privateNetworkMessage(config.port);
1342
- } else if (tunnelFailed) {
1343
- tunnelFailedMessage(tunnelUrl);
1344
- } else {
1345
- finalMessage(tunnelUrl, relayUrl);
1346
- }
1347
- if (res.status === 0) {
1348
- console.log(` ${c.blue}✔${c.reset} Daemon installed — Bloby will auto-start on ${PLATFORM === 'darwin' ? 'login' : 'boot'}.`);
1349
- } else {
1350
- console.log(` ${c.yellow}⚠${c.reset} Daemon install failed. Run ${c.pink}bloby daemon install${c.reset} manually.`);
1351
- }
1352
- console.log('');
1353
- process.exit(res.status ?? 0);
1354
- }
1739
+ s.stopRaw();
1355
1740
 
1356
- stepper.finish();
1357
- if (!hasTunnel) {
1358
- privateNetworkMessage(config.port);
1359
- } else if (tunnelFailed) {
1360
- tunnelFailedMessage(tunnelUrl);
1361
- } else {
1362
- finalMessage(tunnelUrl, relayUrl);
1741
+ if (HOSTED) {
1742
+ if (log) log(result.healthy ? 'Daemon running' : 'Daemon starting');
1743
+ console.log(`__HOSTED_READY__=${JSON.stringify({ tunnelUrl: result.tunnelUrl || `http://localhost:${result.port}`, status: result.tunnelUrl ? 'ok' : 'tunnel_failed', daemon: true })}`);
1744
+ process.exit(0);
1363
1745
  }
1364
1746
 
1365
- child.stdout.on('data', (d) => {
1366
- process.stdout.write(` ${c.dim}${d.toString().trim()}${c.reset}\n`);
1367
- });
1368
- child.stderr.on('data', (d) => {
1369
- const line = d.toString().trim();
1370
- if (!line || line.includes('AssignProcessToJobObject')) return;
1371
- process.stderr.write(` ${c.dim}${line}${c.reset}\n`);
1372
- });
1747
+ printReadyBlock(result);
1748
+ console.log(`\n ${GLYPH.ok} Bloby auto-starts on ${PLATFORM === 'darwin' ? 'login' : 'boot'}.`);
1749
+ console.log(` ${GLYPH.next} Open the dashboard above to finish setup.`);
1750
+ commandsFooter();
1373
1751
  }
1374
1752
 
1375
- async function status() {
1376
- const config = fs.existsSync(CONFIG_PATH) ? JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8')) : null;
1377
- const daemonRunning = isDaemonInstalled() && isDaemonActive();
1378
-
1379
- // Try health endpoint
1380
- let healthOk = false;
1381
- let uptime = null;
1382
- if (config) {
1383
- try {
1384
- const res = await fetch(`http://localhost:${config.port}/api/health`);
1385
- const data = await res.json();
1386
- healthOk = true;
1387
- uptime = data.uptime;
1388
- } catch {}
1389
- }
1753
+ // ── update ──
1390
1754
 
1391
- if (healthOk) {
1392
- console.log(`\n ${c.blue}●${c.reset} Bloby is running${daemonRunning ? ` ${c.dim}(daemon)${c.reset}` : ''}`);
1393
- if (uptime != null) console.log(` ${c.dim}Uptime: ${uptime}s${c.reset}`);
1394
- if (config?.tunnelUrl) {
1395
- console.log(` ${c.dim}Tunnel: ${c.reset}${c.blue}${link(config.tunnelUrl)}${c.reset}`);
1396
- }
1397
- if (config?.relay?.url) {
1398
- console.log(` ${c.dim}Relay: ${c.reset}${c.pink}${link(config.relay.url)}${c.reset}`);
1399
- }
1400
- console.log(` ${c.dim}Config: ${CONFIG_PATH}${c.reset}\n`);
1401
- } else if (daemonRunning) {
1402
- console.log(`\n ${c.yellow}●${c.reset} Bloby daemon is running but not responding yet.`);
1403
- console.log(` ${c.dim}It may still be starting up. Check logs:${c.reset}`);
1404
- console.log(` ${c.pink}bloby daemon logs${c.reset}\n`);
1405
- } else {
1406
- console.log(`\n ${c.dim}●${c.reset} Bloby is not running.\n`);
1407
- }
1408
- }
1755
+ async function cmdUpdate() {
1756
+ // When triggered by the supervisor (BLOBY_SELF_UPDATE=1), skip every daemon
1757
+ // touch the supervisor relaunches itself after we exit 0.
1758
+ const selfUpdate = !!process.env.BLOBY_SELF_UPDATE;
1409
1759
 
1410
- async function update() {
1411
- banner();
1760
+ if (!selfUpdate) banner();
1412
1761
 
1413
1762
  // Refuse to run the update as root — file ownership would get poisoned
1414
- if (os.platform() !== 'win32' && process.getuid?.() === 0) {
1415
- console.log(`\n ${c.red}✗${c.reset} Do not run ${c.bold}bloby update${c.reset} with sudo.`);
1416
- console.log(` The update manages sudo internally for daemon commands.\n`);
1763
+ if (PLATFORM !== 'win32' && process.getuid?.() === 0) {
1764
+ console.log(`\n ${GLYPH.err} Do not run ${c.bold}bloby update${c.reset} with sudo.`);
1765
+ console.log(' The update manages sudo internally for daemon commands.\n');
1417
1766
  process.exit(1);
1418
1767
  }
1419
1768
 
1420
1769
  const currentVersion = pkg.version;
1421
1770
  console.log(`\n ${c.dim}Current version: v${currentVersion}${c.reset}`);
1422
- console.log(` ${c.blue}⠋${c.reset} Checking for updates...\n`);
1423
1771
 
1424
- // Fetch latest package info from npm registry
1772
+ const s = new Spinner().start('Checking for updates...');
1773
+
1425
1774
  let latest;
1426
1775
  try {
1427
- const res = await fetch(`https://registry.npmjs.org/bloby-bot/latest?_t=${Date.now()}`, { headers: { 'Accept': 'application/json' } });
1428
- if (!res.ok) throw new Error();
1776
+ const res = await fetch(`https://registry.npmjs.org/bloby-bot/latest?_t=${Date.now()}`, {
1777
+ headers: { Accept: 'application/json' },
1778
+ signal: AbortSignal.timeout(15_000),
1779
+ });
1780
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
1429
1781
  latest = await res.json();
1430
- } catch {
1431
- console.log(` ${c.red}✗${c.reset} Failed to check for updates\n`);
1782
+ } catch (e) {
1783
+ s.fail(`Failed to check for updates: ${e.message}`);
1432
1784
  process.exit(1);
1433
1785
  }
1434
1786
 
1435
- if (currentVersion === latest.version) {
1436
- console.log(` ${c.blue}✔${c.reset} Already up to date (v${currentVersion})\n`);
1787
+ // <= covers registry lag too: never silently downgrade a locally-newer install.
1788
+ if (compareVersions(latest.version, currentVersion) <= 0) {
1789
+ s.succeed(`Already up to date (v${currentVersion})`);
1790
+ commandsFooter();
1437
1791
  return;
1438
1792
  }
1439
1793
 
1440
- console.log(` ${c.dim}v${currentVersion} → v${latest.version}${c.reset}\n`);
1441
-
1442
- // When triggered by the supervisor (BLOBY_SELF_UPDATE=1), skip daemon stop/restart —
1443
- // the supervisor will exit after we finish, and the daemon manager restarts it.
1444
- const selfUpdate = !!process.env.BLOBY_SELF_UPDATE;
1445
- const daemonWasRunning = !selfUpdate && isDaemonInstalled() && isDaemonActive();
1446
-
1447
- const updateConfig = fs.existsSync(CONFIG_PATH) ? JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8')) : {};
1448
- const updateTunnelMode = updateConfig.tunnel?.mode ?? (updateConfig.tunnel?.enabled === false ? 'off' : 'quick');
1449
- const updateHasTunnel = updateTunnelMode !== 'off';
1450
-
1451
- const steps = [
1452
- 'Downloading update',
1453
- ...(daemonWasRunning ? ['Stopping daemon'] : []),
1454
- 'Updating files',
1455
- 'Installing dependencies',
1456
- 'Building interface',
1457
- ...(daemonWasRunning
1458
- ? ['Restarting daemon', ...(updateHasTunnel ? ['Connecting tunnel', 'Verifying connection'] : ['Verifying connection'])]
1459
- : []),
1460
- ];
1794
+ s.succeed(`Found v${latest.version} ${c.dim}(current: v${currentVersion})${c.reset}`);
1461
1795
 
1462
- const stepper = new Stepper(steps);
1463
- stepper.start();
1796
+ const daemonWasRunning = !selfUpdate && isDaemonActive();
1464
1797
 
1465
- // Download tarball FIRST — before stopping daemon, so failure doesn't leave bot offline
1466
- const tarballUrl = latest.dist.tarball;
1798
+ // Download tarball FIRST — before stopping the daemon, so failure doesn't leave the bot offline
1799
+ const s2 = new Spinner().start('Downloading update...');
1467
1800
  const tmpDir = path.join(os.tmpdir(), `bloby-update-${Date.now()}`);
1468
1801
  fs.mkdirSync(tmpDir, { recursive: true });
1469
1802
  const tarball = path.join(tmpDir, 'bloby.tgz');
1470
1803
 
1471
1804
  try {
1472
- const res = await fetch(tarballUrl);
1805
+ const res = await fetch(latest.dist.tarball, { signal: AbortSignal.timeout(300_000) });
1473
1806
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
1474
1807
  const buf = Buffer.from(await res.arrayBuffer());
1475
1808
  fs.writeFileSync(tarball, buf);
1476
1809
  execSync(`tar xzf "${tarball}" -C "${tmpDir}"`, { stdio: 'ignore' });
1477
1810
  } catch (e) {
1478
- stepper.finish();
1479
- console.log(`\n ${c.red}✗${c.reset} Download failed: ${e.message}\n`);
1811
+ s2.fail(`Download failed: ${e.message}`);
1480
1812
  fs.rmSync(tmpDir, { recursive: true, force: true });
1481
1813
  process.exit(1);
1482
1814
  }
1483
- stepper.advance();
1815
+ s2.succeed('Downloaded.');
1484
1816
 
1485
- // Stop daemon AFTER download succeeds — minimizes downtime
1486
- // Skipped during self-update (supervisor handles restart via process exit)
1817
+ // Stop the daemon AFTER the download succeeds — minimizes downtime.
1818
+ // Skipped during self-update (the supervisor handles its own relaunch).
1819
+ // A failed stop is fatal: copying new files under a running old version
1820
+ // leaves a half-upgraded install serving live traffic.
1487
1821
  if (daemonWasRunning) {
1488
- try {
1489
- if (PLATFORM === 'darwin') {
1490
- execSync(`launchctl unload "${LAUNCHD_PLIST_PATH}"`, { stdio: 'ignore' });
1491
- } else {
1492
- const cmd = needsSudo() ? `sudo systemctl stop ${SERVICE_NAME}` : `systemctl stop ${SERVICE_NAME}`;
1493
- execSync(cmd, { stdio: 'ignore' });
1494
- }
1495
- } catch (e) {
1496
- console.log(` ${c.yellow}⚠${c.reset} Could not stop daemon: ${e.message}`);
1822
+ const s3 = new Spinner().start('Stopping Bloby...');
1823
+ const stopped = await stopCore({ spinner: s3 }); // also sweeps stray supervisors so an old instance can't answer the post-update health check
1824
+ if (!stopped.ok) {
1825
+ s3.fail(`Could not stop Bloby: ${stopped.error}`);
1826
+ console.log(` ${c.dim}Stop it manually, then re-run ${c.reset}${c.blue}bloby update${c.reset}${c.dim}.${c.reset}\n`);
1827
+ fs.rmSync(tmpDir, { recursive: true, force: true });
1828
+ process.exit(1);
1497
1829
  }
1498
- stepper.advance();
1830
+ s3.succeed('Stopped.');
1499
1831
  }
1500
1832
 
1501
1833
  const extracted = path.join(tmpDir, 'package');
1834
+ const s4 = new Spinner().start('Updating files...');
1502
1835
 
1503
1836
  // Update code directories (preserve workspace/ user data)
1504
1837
  try {
@@ -1517,6 +1850,32 @@ async function update() {
1517
1850
  }
1518
1851
  }
1519
1852
 
1853
+ // Always update framework files that ship with each version.
1854
+ // These are not user-editable — user code lives in src/components/, src/pages/, backend/, etc.
1855
+ const frameworkFiles = [
1856
+ 'workspace/client/index.html', // splash screen, SW registration, meta tags
1857
+ 'workspace/client/src/main.tsx', // React entry point, app-ready signal
1858
+ ];
1859
+ for (const rel of frameworkFiles) {
1860
+ const src = path.join(extracted, rel);
1861
+ const dst = path.join(DATA_DIR, rel);
1862
+ if (fs.existsSync(src)) {
1863
+ fs.cpSync(src, dst, { force: true });
1864
+ }
1865
+ }
1866
+
1867
+ // Always update public assets that ship with the framework (animation spritesheet, icons).
1868
+ // Only copy specific files — never overwrite user-added public assets.
1869
+ const frameworkAssets = ['spritesheet.webp', 'headphones_spritesheet.webp'];
1870
+ const publicSrc = path.join(extracted, 'workspace', 'client', 'public');
1871
+ const publicDst = path.join(DATA_DIR, 'workspace', 'client', 'public');
1872
+ for (const asset of frameworkAssets) {
1873
+ const src = path.join(publicSrc, asset);
1874
+ if (fs.existsSync(src)) {
1875
+ fs.cpSync(src, path.join(publicDst, asset), { force: true });
1876
+ }
1877
+ }
1878
+
1520
1879
  // Update code files (never touches config.json, memory.db, etc.)
1521
1880
  for (const file of ['package.json', 'vite.config.ts', 'vite.bloby.config.ts', 'tsconfig.json', 'postcss.config.js', 'components.json']) {
1522
1881
  const src = path.join(extracted, file);
@@ -1533,12 +1892,11 @@ async function update() {
1533
1892
  fs.cpSync(distSrc, dst, { recursive: true });
1534
1893
  }
1535
1894
  } catch (e) {
1536
- console.log(` ${c.red}✗${c.reset} File copy failed: ${e.message}`);
1895
+ s4.fail(`File copy failed: ${e.message}`);
1537
1896
  fs.rmSync(tmpDir, { recursive: true, force: true });
1538
1897
  process.exit(1);
1539
1898
  }
1540
-
1541
- stepper.advance();
1899
+ s4.succeed('Files updated.');
1542
1900
 
1543
1901
  const distDst = path.join(DATA_DIR, 'dist-bloby');
1544
1902
 
@@ -1546,34 +1904,40 @@ async function update() {
1546
1904
  // A failed install while new source files are already in place leaves the
1547
1905
  // app permanently broken (e.g. crash loop on a new import). Treat as fatal,
1548
1906
  // surface the npm output so the cause is debuggable, and don't claim success.
1907
+ const s5 = new Spinner().start('Installing dependencies...');
1549
1908
  try {
1550
1909
  ensureNpmrc(DATA_DIR);
1551
1910
  execSync('npm install --omit=dev', { cwd: DATA_DIR, stdio: 'pipe', timeout: 300_000 });
1552
1911
  } catch (e) {
1912
+ s5.stopRaw();
1553
1913
  if (e.stdout) process.stderr.write(e.stdout);
1554
1914
  if (e.stderr) process.stderr.write(e.stderr);
1555
- console.log(`\n ${c.red}✗${c.reset} npm install failed during update: ${e.message}`);
1556
- console.log(` Your install is now partially upgraded. To recover:\n cd ~/.bloby && npm install --omit=dev\n`);
1915
+ console.log(`\n ${GLYPH.err} npm install failed during update: ${e.message}`);
1916
+ console.log(' Your install is now partially upgraded. To recover:\n cd ~/.bloby && npm install --omit=dev\n');
1557
1917
  fs.rmSync(tmpDir, { recursive: true, force: true });
1558
1918
  process.exit(1);
1559
1919
  }
1560
1920
  const stillMissing = missingDeps(DATA_DIR);
1561
1921
  if (stillMissing.length > 0) {
1562
- console.log(`\n ${c.red}✗${c.reset} npm install reported success but these deps are missing: ${stillMissing.join(', ')}`);
1563
- console.log(` Try: cd ~/.bloby && rm -rf node_modules package-lock.json && npm install --omit=dev\n`);
1922
+ s5.stopRaw();
1923
+ console.log(`\n ${GLYPH.err} npm install reported success but these deps are missing: ${stillMissing.join(', ')}`);
1924
+ console.log(' Try: cd ~/.bloby && rm -rf node_modules package-lock.json && npm install --omit=dev\n');
1564
1925
  fs.rmSync(tmpDir, { recursive: true, force: true });
1565
1926
  process.exit(1);
1566
1927
  }
1567
- stepper.advance();
1928
+ s5.succeed('Dependencies installed.');
1568
1929
 
1569
1930
  // Rebuild UI if not in tarball
1570
1931
  if (!fs.existsSync(path.join(distDst, 'onboard.html'))) {
1932
+ const s6 = new Spinner().start('Building interface...');
1571
1933
  try {
1572
1934
  if (fs.existsSync(distDst)) fs.rmSync(distDst, { recursive: true });
1573
1935
  execSync('npm run build:bloby', { cwd: DATA_DIR, stdio: 'ignore', timeout: 300_000 });
1574
- } catch {}
1936
+ s6.succeed('Interface built.');
1937
+ } catch {
1938
+ s6.warn('Interface build skipped — will build on first start.');
1939
+ }
1575
1940
  }
1576
- stepper.advance();
1577
1941
 
1578
1942
  // Read release notes and write version before cleanup
1579
1943
  let releaseNotes = '';
@@ -1582,373 +1946,152 @@ async function update() {
1582
1946
  releaseNotes = newPkg.releaseNotes || '';
1583
1947
  } catch {}
1584
1948
  writeVersionFile(latest.version);
1585
-
1586
- // Clean up
1587
1949
  fs.rmSync(tmpDir, { recursive: true, force: true });
1588
1950
 
1589
- // Restart daemon if it was running (skipped during self-update)
1590
- let updateResult = null;
1591
- if (daemonWasRunning) {
1592
- // Capture log offset before starting so we only detect new-session __READY__
1593
- const updateLogFile = path.join(LAUNCHD_LOG_DIR, 'bloby.log');
1594
- let updateLogOffset = 0;
1595
- if (PLATFORM === 'darwin' && fs.existsSync(updateLogFile)) {
1596
- try { updateLogOffset = fs.statSync(updateLogFile).size; } catch {}
1597
- }
1598
-
1599
- try {
1600
- if (PLATFORM === 'darwin') {
1601
- execSync(`launchctl load "${LAUNCHD_PLIST_PATH}"`, { stdio: 'ignore' });
1602
- } else {
1603
- const cmd = needsSudo() ? `sudo systemctl start ${SERVICE_NAME}` : `systemctl start ${SERVICE_NAME}`;
1604
- execSync(cmd, { stdio: 'ignore' });
1605
- }
1606
- } catch {}
1607
- stepper.advance(); // Restarting daemon done
1608
-
1609
- // Wait for daemon to become healthy and tunnel to connect
1610
- updateResult = await waitForDaemonHealth(stepper, updateConfig, updateHasTunnel, updateLogOffset);
1611
- }
1612
-
1613
- stepper.finish();
1614
-
1615
- console.log(`\n ${c.blue}${c.bold}✔ Updated to v${latest.version}${c.reset}\n`);
1951
+ console.log(`\n ${c.pink}${c.bold}✔ Updated to v${latest.version}${c.reset}\n`);
1616
1952
 
1617
1953
  if (releaseNotes) {
1618
1954
  console.log(` ${c.bold}${c.white}What's new:${c.reset}`);
1619
1955
  const notes = Array.isArray(releaseNotes) ? releaseNotes : [releaseNotes];
1620
- notes.forEach((note, i) => {
1621
- console.log(` ${c.dim}${i + 1}.${c.reset} ${note}`);
1622
- });
1956
+ notes.forEach((note, i) => console.log(` ${c.dim}${i + 1}.${c.reset} ${note}`));
1623
1957
  console.log('');
1624
1958
  }
1625
1959
 
1626
- // During self-update, supervisor handles restart — just exit cleanly
1960
+ // During self-update the supervisor handles its own relaunch — just exit 0.
1627
1961
  if (selfUpdate) {
1628
- console.log(` Files updated — supervisor will restart with new version.\n`);
1962
+ console.log(' Files updated — supervisor will restart onto the new version.\n');
1629
1963
  return;
1630
1964
  }
1631
1965
 
1966
+ // Restart only if it was running before — update does not start a stopped bot.
1632
1967
  if (daemonWasRunning) {
1633
- if (updateResult && updateResult.healthOk) {
1634
- console.log(` ${c.blue}✔${c.reset} Daemon restarted with new version.`);
1635
- readyMessage(updateResult.tunnelUrl, updateResult.relayUrl);
1968
+ const s7 = new Spinner().start('Restarting Bloby...');
1969
+ const result = await startCore({ spinner: s7 });
1970
+ if (result.ok && result.healthy) {
1971
+ s7.succeed('Bloby restarted on the new version.');
1972
+ printReadyBlock(result);
1636
1973
  } else {
1637
- console.log(` ${c.yellow}⚠${c.reset} Daemon may still be starting. Check ${c.pink}bloby daemon status${c.reset}\n`);
1638
- }
1639
- } else if (isDaemonInstalled()) {
1640
- try {
1641
- if (PLATFORM === 'darwin') {
1642
- execSync(`launchctl unload "${LAUNCHD_PLIST_PATH}" 2>/dev/null; launchctl load "${LAUNCHD_PLIST_PATH}"`, { stdio: 'ignore' });
1643
- } else {
1644
- const cmd = needsSudo() ? `sudo systemctl start ${SERVICE_NAME}` : `systemctl start ${SERVICE_NAME}`;
1645
- execSync(cmd, { stdio: 'ignore' });
1646
- }
1647
- console.log(` ${c.blue}✔${c.reset} Daemon started with new version.\n`);
1648
- } catch {
1649
- console.log(` ${c.dim}Run ${c.reset}${c.pink}bloby daemon start${c.reset}${c.dim} to launch.${c.reset}\n`);
1974
+ s7.warn(`Bloby may still be starting check ${c.blue}bloby status${c.reset}.`);
1650
1975
  }
1651
1976
  } else {
1652
- console.log(` ${c.dim}Run ${c.reset}${c.pink}bloby start${c.reset}${c.dim} to launch.${c.reset}\n`);
1977
+ console.log(` ${c.dim}Bloby was not running. Start it with ${c.reset}${c.blue}bloby start${c.reset}${c.dim} when ready.${c.reset}`);
1653
1978
  }
1979
+ commandsFooter();
1654
1980
  }
1655
1981
 
1656
- // ── Daemon ──
1982
+ // ── daemon (advanced; the useful actions alias the top-level commands) ──
1983
+
1984
+ function printDaemonHelp() {
1985
+ console.log(`\n ${c.bold}bloby daemon <action>${c.reset}\n`);
1986
+ console.log(` ${c.blue}${'install'.padEnd(12)}${c.reset}Install the background service and start it`);
1987
+ console.log(` ${c.blue}${'start'.padEnd(12)}${c.reset}Same as ${c.dim}bloby start${c.reset}`);
1988
+ console.log(` ${c.blue}${'stop'.padEnd(12)}${c.reset}Same as ${c.dim}bloby stop${c.reset}`);
1989
+ console.log(` ${c.blue}${'restart'.padEnd(12)}${c.reset}Same as ${c.dim}bloby restart${c.reset}`);
1990
+ console.log(` ${c.blue}${'status'.padEnd(12)}${c.reset}Same as ${c.dim}bloby status${c.reset}`);
1991
+ console.log(` ${c.blue}${'logs'.padEnd(12)}${c.reset}Same as ${c.dim}bloby logs${c.reset}`);
1992
+ console.log(` ${c.blue}${'uninstall'.padEnd(12)}${c.reset}Remove the background service\n`);
1993
+ }
1657
1994
 
1658
- async function daemon(sub) {
1659
- // Platform guard
1995
+ async function cmdDaemon(sub) {
1660
1996
  if (!hasDaemonSupport()) {
1661
1997
  const hint = PLATFORM === 'win32'
1662
1998
  ? 'Use Task Scheduler to keep Bloby running in the background.'
1663
1999
  : 'No supported daemon system found.';
1664
- console.log(`\n ${c.yellow}⚠${c.reset} Daemon mode is not supported on this platform.`);
2000
+ console.log(`\n ${GLYPH.warn} Daemon mode is not supported on this platform.`);
1665
2001
  console.log(` ${c.dim}${hint}${c.reset}\n`);
1666
2002
  process.exit(1);
1667
2003
  }
1668
2004
 
1669
- const action = sub || 'install';
1670
-
1671
- // ── macOS (launchd) ──
1672
- if (PLATFORM === 'darwin') {
1673
- switch (action) {
1674
- case 'install': {
1675
- const dataDir = ROOT; // Uses REPO_ROOT in dev, DATA_DIR (~/.bloby) in production
1676
- if (!fs.existsSync(path.join(dataDir, 'supervisor', 'index.ts'))) {
1677
- console.log(`\n ${c.red}✗${c.reset} Run ${c.pink}bloby init${c.reset} first.\n`);
1678
- process.exit(1);
1679
- }
1680
-
1681
- // Unload existing plist if loaded
1682
- if (isLaunchdInstalled()) {
1683
- try { execSync(`launchctl unload "${LAUNCHD_PLIST_PATH}" 2>/dev/null`, { stdio: 'ignore' }); } catch {}
1684
- }
1685
-
1686
- const nodePath = process.env.BLOBY_NODE_PATH || process.execPath;
1687
- const plist = generateLaunchdPlist({ nodePath, dataDir });
1688
-
1689
- // Ensure LaunchAgents directory exists
1690
- fs.mkdirSync(path.dirname(LAUNCHD_PLIST_PATH), { recursive: true });
1691
- fs.writeFileSync(LAUNCHD_PLIST_PATH, plist);
1692
- execSync(`launchctl load "${LAUNCHD_PLIST_PATH}"`, { stdio: 'ignore' });
1693
-
1694
- // Verify it started
1695
- await new Promise((r) => setTimeout(r, 2000));
1696
- if (isLaunchdActive()) {
1697
- console.log(`\n ${c.blue}✔${c.reset} Bloby daemon installed and running.`);
1698
- console.log(` ${c.dim}It will auto-start on login.${c.reset}`);
1699
- console.log(`\n ${c.dim}View logs:${c.reset} ${c.pink}bloby daemon logs${c.reset}`);
1700
- console.log(` ${c.dim}Stop:${c.reset} ${c.pink}bloby daemon stop${c.reset}`);
1701
- console.log(` ${c.dim}Uninstall:${c.reset} ${c.pink}bloby daemon uninstall${c.reset}\n`);
1702
- } else {
1703
- console.log(`\n ${c.yellow}⚠${c.reset} Plist installed but process may not be running.`);
1704
- console.log(` ${c.dim}Check with: ${c.reset}${c.pink}bloby daemon status${c.reset}\n`);
1705
- }
1706
- break;
1707
- }
1708
-
1709
- case 'stop': {
1710
- if (!isLaunchdInstalled()) {
1711
- console.log(`\n ${c.yellow}⚠${c.reset} Daemon not installed. Run ${c.pink}bloby daemon install${c.reset} first.\n`);
1712
- process.exit(1);
1713
- }
1714
- execSync(`launchctl unload "${LAUNCHD_PLIST_PATH}"`, { stdio: 'ignore' });
1715
- console.log(`\n ${c.blue}✔${c.reset} Bloby daemon stopped.\n`);
1716
- break;
1717
- }
1718
-
1719
- case 'start': {
1720
- if (!isLaunchdInstalled()) {
1721
- console.log(`\n ${c.yellow}⚠${c.reset} Daemon not installed. Run ${c.pink}bloby daemon install${c.reset} first.\n`);
1722
- process.exit(1);
1723
- }
1724
- // Reload: unload first in case it's already loaded, then load
1725
- try { execSync(`launchctl unload "${LAUNCHD_PLIST_PATH}" 2>/dev/null`, { stdio: 'ignore' }); } catch {}
1726
- execSync(`launchctl load "${LAUNCHD_PLIST_PATH}"`, { stdio: 'ignore' });
1727
- console.log(`\n ${c.blue}✔${c.reset} Bloby daemon started.\n`);
1728
- break;
1729
- }
1730
-
1731
- case 'restart': {
1732
- if (!isLaunchdInstalled()) {
1733
- console.log(`\n ${c.yellow}⚠${c.reset} Daemon not installed. Run ${c.pink}bloby daemon install${c.reset} first.\n`);
1734
- process.exit(1);
1735
- }
1736
-
1737
- banner();
1738
-
1739
- const restartConfig = fs.existsSync(CONFIG_PATH) ? JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8')) : {};
1740
- const restartTunnelMode = restartConfig.tunnel?.mode ?? (restartConfig.tunnel?.enabled === false ? 'off' : 'quick');
1741
- const restartHasTunnel = restartTunnelMode !== 'off';
1742
-
1743
- const restartSteps = [
1744
- 'Stopping daemon',
1745
- 'Starting daemon',
1746
- ...(restartHasTunnel ? ['Connecting tunnel', 'Verifying connection'] : ['Verifying connection']),
1747
- ];
1748
- const restartStepper = new Stepper(restartSteps);
1749
- restartStepper.start();
1750
-
1751
- try { execSync(`launchctl unload "${LAUNCHD_PLIST_PATH}" 2>/dev/null`, { stdio: 'ignore' }); } catch {}
1752
- restartStepper.advance(); // Stopping daemon done
1753
-
1754
- // Capture log offset between stop and start so we only see new session output
1755
- const restartLogFile = path.join(LAUNCHD_LOG_DIR, 'bloby.log');
1756
- let restartLogOffset = 0;
1757
- if (fs.existsSync(restartLogFile)) {
1758
- try { restartLogOffset = fs.statSync(restartLogFile).size; } catch {}
1759
- }
1760
-
1761
- execSync(`launchctl load "${LAUNCHD_PLIST_PATH}"`, { stdio: 'ignore' });
1762
- restartStepper.advance(); // Starting daemon done
2005
+ switch (sub) {
2006
+ case undefined:
2007
+ case 'help':
2008
+ printDaemonHelp();
2009
+ commandsFooter();
2010
+ return;
1763
2011
 
1764
- const restartResult = await waitForDaemonHealth(restartStepper, restartConfig, restartHasTunnel, restartLogOffset);
1765
- restartStepper.finish();
1766
-
1767
- console.log(`\n ${c.blue}✔${c.reset} Bloby daemon restarted.`);
1768
- readyMessage(restartResult.tunnelUrl, restartResult.relayUrl);
1769
- break;
1770
- }
1771
-
1772
- case 'status': {
1773
- if (!isLaunchdInstalled()) {
1774
- console.log(`\n ${c.dim}●${c.reset} Daemon not installed.\n`);
1775
- break;
1776
- }
1777
- const active = isLaunchdActive();
1778
- if (active) {
1779
- console.log(`\n ${c.blue}●${c.reset} Bloby daemon is running.`);
1780
- } else {
1781
- console.log(`\n ${c.dim}●${c.reset} Bloby daemon is stopped.`);
1782
- }
1783
- console.log(` ${c.dim}Plist:${c.reset} ${LAUNCHD_PLIST_PATH}`);
1784
- console.log(` ${c.dim}Logs:${c.reset} ${LAUNCHD_LOG_DIR}/bloby.log\n`);
1785
- break;
1786
- }
1787
-
1788
- case 'logs': {
1789
- const logFile = path.join(LAUNCHD_LOG_DIR, 'bloby.log');
1790
- if (!fs.existsSync(logFile)) {
1791
- console.log(`\n ${c.dim}No logs found at ${logFile}${c.reset}\n`);
1792
- break;
1793
- }
1794
- spawnSync('tail', ['-f', '-n', '50', logFile], { stdio: 'inherit' });
1795
- break;
1796
- }
1797
-
1798
- case 'uninstall': {
1799
- try { execSync(`launchctl unload "${LAUNCHD_PLIST_PATH}" 2>/dev/null`, { stdio: 'ignore' }); } catch {}
1800
- if (fs.existsSync(LAUNCHD_PLIST_PATH)) fs.unlinkSync(LAUNCHD_PLIST_PATH);
1801
- console.log(`\n ${c.blue}✔${c.reset} Bloby daemon uninstalled.\n`);
1802
- break;
2012
+ case 'install': {
2013
+ if (!fs.existsSync(path.join(ROOT, 'supervisor', 'index.ts'))) {
2014
+ console.log(`\n ${GLYPH.err} Run ${c.blue}bloby init${c.reset} first.\n`);
2015
+ process.exit(1);
1803
2016
  }
1804
-
1805
- default:
1806
- console.log(`\n ${c.red}✗${c.reset} Unknown daemon command: ${action}`);
1807
- console.log(` ${c.dim}Available: install, start, stop, restart, status, logs, uninstall${c.reset}\n`);
2017
+ console.log('');
2018
+ const s = new Spinner().start('Installing service...');
2019
+ await ensureCloudflared(s);
2020
+ const installed = installServiceFiles({ spinner: s });
2021
+ if (!installed.ok) {
2022
+ s.fail(`Failed to install service: ${installed.error}`);
2023
+ commandsFooter();
1808
2024
  process.exit(1);
1809
- }
1810
- return;
1811
- }
1812
-
1813
- // ── Linux (systemd) ──
1814
-
1815
- // Check systemd is available
1816
- try {
1817
- execSync('systemctl --version', { stdio: 'ignore' });
1818
- } catch {
1819
- console.log(`\n ${c.red}✗${c.reset} systemd not found. Daemon mode requires systemd.\n`);
1820
- process.exit(1);
1821
- }
1822
-
1823
- switch (action) {
1824
- case 'install': {
1825
- if (!fs.existsSync(path.join(getRealHome(), '.bloby', 'supervisor', 'index.ts'))) {
1826
- console.log(`\n ${c.red}✗${c.reset} Run ${c.pink}bloby init${c.reset} first.\n`);
2025
+ }
2026
+ const started = startDaemonService({ spinner: s });
2027
+ if (!started.ok) {
2028
+ s.fail(`Service installed but failed to start: ${started.error}`);
2029
+ commandsFooter();
1827
2030
  process.exit(1);
1828
2031
  }
1829
-
1830
- // Re-exec with sudo if needed
1831
- if (needsSudo()) sudoReExec();
1832
-
1833
- const user = getRealUser();
1834
- const home = getRealHome();
1835
- const nodePath = process.env.BLOBY_NODE_PATH || process.execPath;
1836
- const dataDir = path.join(home, '.bloby');
1837
-
1838
- const unit = generateUnitFile({ user, home, nodePath, dataDir });
1839
- fs.writeFileSync(SERVICE_PATH, unit);
1840
-
1841
- execSync('systemctl daemon-reload', { stdio: 'ignore' });
1842
- execSync(`systemctl enable ${SERVICE_NAME}`, { stdio: 'ignore' });
1843
- execSync(`systemctl start ${SERVICE_NAME}`, { stdio: 'ignore' });
1844
-
1845
- // Verify it started
1846
- await new Promise((r) => setTimeout(r, 2000));
1847
- if (isServiceActive()) {
1848
- console.log(`\n ${c.blue}✔${c.reset} Bloby daemon installed and running.`);
1849
- console.log(` ${c.dim}It will auto-start on boot.${c.reset}`);
1850
- console.log(`\n ${c.dim}View logs:${c.reset} ${c.pink}bloby daemon logs${c.reset}`);
1851
- console.log(` ${c.dim}Stop:${c.reset} ${c.pink}bloby daemon stop${c.reset}`);
1852
- console.log(` ${c.dim}Uninstall:${c.reset} ${c.pink}bloby daemon uninstall${c.reset}\n`);
2032
+ // Verify the process actually appears (no fixed sleep — poll briefly).
2033
+ let pid = null;
2034
+ for (let i = 0; i < 20; i++) {
2035
+ pid = daemonPid();
2036
+ if (pid) break;
2037
+ await new Promise(r => setTimeout(r, 500));
2038
+ }
2039
+ if (pid) {
2040
+ s.succeed(`Service installed and running ${c.dim}(PID ${pid})${c.reset}. Auto-starts on ${PLATFORM === 'darwin' ? 'login' : 'boot'}.`);
1853
2041
  } else {
1854
- console.log(`\n ${c.yellow}⚠${c.reset} Service installed but may not be running.`);
1855
- console.log(` ${c.dim}Check with: ${c.reset}${c.pink}bloby daemon status${c.reset}\n`);
2042
+ s.warn(`Service installed but the process has not appeared yet — check ${c.blue}bloby status${c.reset}.`);
1856
2043
  }
1857
- break;
1858
- }
1859
-
1860
- case 'stop': {
1861
- if (needsSudo()) sudoReExec();
1862
- execSync(`systemctl stop ${SERVICE_NAME}`, { stdio: 'inherit' });
1863
- console.log(`\n ${c.blue}✔${c.reset} Bloby daemon stopped.\n`);
1864
- break;
2044
+ commandsFooter();
2045
+ return;
1865
2046
  }
1866
2047
 
1867
- case 'start': {
1868
- if (needsSudo()) sudoReExec();
1869
- execSync(`systemctl start ${SERVICE_NAME}`, { stdio: 'inherit' });
1870
- console.log(`\n ${c.blue}✔${c.reset} Bloby daemon started.\n`);
1871
- break;
1872
- }
1873
-
1874
- case 'restart': {
1875
- if (needsSudo()) sudoReExec();
1876
-
1877
- banner();
1878
-
1879
- const sysRestartConfig = fs.existsSync(CONFIG_PATH) ? JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8')) : {};
1880
- const sysRestartTunnelMode = sysRestartConfig.tunnel?.mode ?? (sysRestartConfig.tunnel?.enabled === false ? 'off' : 'quick');
1881
- const sysRestartHasTunnel = sysRestartTunnelMode !== 'off';
1882
-
1883
- const sysRestartSteps = [
1884
- 'Stopping daemon',
1885
- 'Starting daemon',
1886
- ...(sysRestartHasTunnel ? ['Connecting tunnel', 'Verifying connection'] : ['Verifying connection']),
1887
- ];
1888
- const sysRestartStepper = new Stepper(sysRestartSteps);
1889
- sysRestartStepper.start();
1890
-
1891
- execSync(`systemctl stop ${SERVICE_NAME}`, { stdio: 'ignore' });
1892
- sysRestartStepper.advance(); // Stopping daemon done
1893
-
1894
- execSync(`systemctl start ${SERVICE_NAME}`, { stdio: 'ignore' });
1895
- sysRestartStepper.advance(); // Starting daemon done
1896
-
1897
- // Linux: no log file offset, falls back to local health check
1898
- const sysRestartResult = await waitForDaemonHealth(sysRestartStepper, sysRestartConfig, sysRestartHasTunnel, 0);
1899
- sysRestartStepper.finish();
1900
-
1901
- console.log(`\n ${c.blue}✔${c.reset} Bloby daemon restarted.`);
1902
- readyMessage(sysRestartResult.tunnelUrl, sysRestartResult.relayUrl);
1903
- break;
1904
- }
1905
-
1906
- case 'status': {
1907
- spawnSync('systemctl', ['status', SERVICE_NAME], { stdio: 'inherit' });
1908
- break;
1909
- }
1910
-
1911
- case 'logs': {
1912
- spawnSync('journalctl', ['-u', SERVICE_NAME, '-f', '-n', '50'], { stdio: 'inherit' });
1913
- break;
1914
- }
2048
+ case 'start': return cmdStart();
2049
+ case 'stop': return cmdStop();
2050
+ case 'restart': return cmdRestart();
2051
+ case 'status': return cmdStatus();
2052
+ case 'logs': return cmdLogs();
1915
2053
 
1916
2054
  case 'uninstall': {
1917
- if (needsSudo()) sudoReExec();
1918
- try { execSync(`systemctl stop ${SERVICE_NAME}`, { stdio: 'ignore' }); } catch {}
1919
- try { execSync(`systemctl disable ${SERVICE_NAME}`, { stdio: 'ignore' }); } catch {}
1920
- if (fs.existsSync(SERVICE_PATH)) fs.unlinkSync(SERVICE_PATH);
1921
- execSync('systemctl daemon-reload', { stdio: 'ignore' });
1922
- console.log(`\n ${c.blue}✔${c.reset} Bloby daemon uninstalled.\n`);
1923
- break;
2055
+ console.log('');
2056
+ const s = new Spinner().start('Uninstalling service...');
2057
+ await stopDaemonService();
2058
+ await killStraySupervisors();
2059
+ if (PLATFORM === 'darwin') {
2060
+ if (fs.existsSync(LAUNCHD_PLIST_PATH)) fs.unlinkSync(LAUNCHD_PLIST_PATH);
2061
+ } else if (PLATFORM === 'linux') {
2062
+ runPrivileged(['systemctl', 'disable', SERVICE_NAME], { spinner: s });
2063
+ runPrivileged(['rm', '-f', SERVICE_PATH], { spinner: s });
2064
+ runPrivileged(['systemctl', 'daemon-reload'], { spinner: s });
2065
+ }
2066
+ s.succeed('Service uninstalled.');
2067
+ commandsFooter();
2068
+ return;
1924
2069
  }
1925
2070
 
1926
2071
  default:
1927
- console.log(`\n ${c.red}✗${c.reset} Unknown daemon command: ${action}`);
1928
- console.log(` ${c.dim}Available: install, start, stop, restart, status, logs, uninstall${c.reset}\n`);
1929
- process.exit(1);
2072
+ console.log(`\n ${GLYPH.err} Unknown daemon action: ${c.bold}${sub}${c.reset}`);
2073
+ printDaemonHelp();
2074
+ process.exit(2);
1930
2075
  }
1931
2076
  }
1932
2077
 
1933
- // ── Tunnel management ──
1934
-
1935
- function ask(question) {
1936
- const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
1937
- return new Promise((resolve) => rl.question(question, (answer) => { rl.close(); resolve(answer.trim()); }));
1938
- }
2078
+ // ── tunnel ──
1939
2079
 
1940
- async function tunnel(sub) {
2080
+ async function cmdTunnel(sub) {
1941
2081
  const action = sub || 'status';
1942
2082
 
1943
2083
  switch (action) {
1944
2084
  case 'setup': {
1945
2085
  banner();
2086
+ if (!process.stdin.isTTY) {
2087
+ console.log(`\n ${GLYPH.err} Tunnel setup is interactive — run it from a terminal.\n`);
2088
+ process.exit(1);
2089
+ }
1946
2090
  console.log(`\n ${c.bold}${c.white}Named Tunnel Setup${c.reset}`);
1947
2091
 
1948
2092
  const setup = await runNamedTunnelSetup();
1949
2093
 
1950
- // Update bloby config
1951
- const config = fs.existsSync(CONFIG_PATH) ? JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8')) : {};
2094
+ const config = readConfig() || {};
1952
2095
  config.tunnel = {
1953
2096
  mode: 'named',
1954
2097
  name: setup.tunnelName,
@@ -1956,83 +2099,75 @@ async function tunnel(sub) {
1956
2099
  configPath: setup.cfConfigPath,
1957
2100
  };
1958
2101
  fs.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2));
1959
- console.log(` ${c.blue}✔${c.reset} Bloby config updated\n`);
2102
+ console.log(` ${GLYPH.ok} Bloby config updated\n`);
1960
2103
 
1961
- // Offer restart if daemon is running
1962
- if (isDaemonInstalled() && isDaemonActive()) {
1963
- const restart = await ask(` ${c.bold}Restart daemon now?${c.reset} ${c.dim}(Y/n)${c.reset}: `);
2104
+ if (isDaemonActive()) {
2105
+ const restart = await ask(` ${c.bold}Restart Bloby now?${c.reset} ${c.dim}(Y/n)${c.reset}: `);
1964
2106
  if (!restart || restart.toLowerCase() === 'y') {
1965
- try {
1966
- if (PLATFORM === 'darwin') {
1967
- execSync(`launchctl unload "${LAUNCHD_PLIST_PATH}" 2>/dev/null; launchctl load "${LAUNCHD_PLIST_PATH}"`, { stdio: 'ignore' });
1968
- } else {
1969
- const cmd = needsSudo() ? `sudo systemctl restart ${SERVICE_NAME}` : `systemctl restart ${SERVICE_NAME}`;
1970
- execSync(cmd, { stdio: 'ignore' });
1971
- }
1972
- console.log(`\n ${c.blue}✔${c.reset} Daemon restarted.\n`);
1973
- } catch {
1974
- console.log(`\n ${c.yellow}⚠${c.reset} Restart failed. Try ${c.pink}bloby daemon restart${c.reset}\n`);
1975
- }
2107
+ return cmdRestart();
1976
2108
  }
1977
2109
  } else {
1978
- console.log(` ${c.dim}Run ${c.reset}${c.pink}bloby start${c.reset}${c.dim} to launch with the named tunnel.${c.reset}\n`);
2110
+ console.log(` ${c.dim}Run ${c.reset}${c.blue}bloby start${c.reset}${c.dim} to launch with the named tunnel.${c.reset}\n`);
1979
2111
  }
1980
- break;
2112
+ commandsFooter();
2113
+ return;
1981
2114
  }
1982
2115
 
1983
2116
  case 'status': {
1984
2117
  if (!fs.existsSync(CONFIG_PATH)) {
1985
- console.log(`\n ${c.dim}No config found. Run ${c.reset}${c.pink}bloby init${c.reset}${c.dim} first.${c.reset}\n`);
2118
+ console.log(`\n ${c.dim}No config found. Run ${c.reset}${c.blue}bloby init${c.reset}${c.dim} first.${c.reset}`);
2119
+ commandsFooter();
1986
2120
  return;
1987
2121
  }
1988
- const config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8'));
1989
- const mode = config.tunnel?.mode ?? (config.tunnel?.enabled === false ? 'off' : 'quick');
2122
+ const config = readConfig() || {};
2123
+ const mode = tunnelModeOf(config);
1990
2124
 
1991
2125
  console.log(`\n ${c.bold}${c.white}Tunnel Configuration${c.reset}\n`);
1992
- console.log(` ${c.dim}Mode:${c.reset} ${c.bold}${mode}${c.reset}`);
2126
+ console.log(` ${c.dim}${'Mode'.padEnd(9)}${c.reset}${c.bold}${mode}${c.reset}`);
1993
2127
  if (mode === 'named') {
1994
- if (config.tunnel?.name) console.log(` ${c.dim}Name:${c.reset} ${config.tunnel.name}`);
1995
- if (config.tunnel?.domain) console.log(` ${c.dim}Domain:${c.reset} ${c.pink}${config.tunnel.domain}${c.reset}`);
1996
- if (config.tunnel?.configPath) console.log(` ${c.dim}Config:${c.reset} ${config.tunnel.configPath}`);
2128
+ if (config.tunnel?.name) console.log(` ${c.dim}${'Name'.padEnd(9)}${c.reset}${config.tunnel.name}`);
2129
+ if (config.tunnel?.domain) console.log(` ${c.dim}${'Domain'.padEnd(9)}${c.reset}${c.pink}${config.tunnel.domain}${c.reset}`);
2130
+ if (config.tunnel?.configPath) console.log(` ${c.dim}${'Config'.padEnd(9)}${c.reset}${config.tunnel.configPath}`);
1997
2131
  }
1998
2132
  if (config.tunnelUrl) {
1999
- console.log(` ${c.dim}URL:${c.reset} ${c.blue}${link(config.tunnelUrl)}${c.reset}`);
2133
+ console.log(` ${c.dim}${'URL'.padEnd(9)}${c.reset}${c.blue}${link(config.tunnelUrl)}${c.reset}`);
2000
2134
  }
2001
- console.log('');
2002
- break;
2135
+ commandsFooter();
2136
+ return;
2003
2137
  }
2004
2138
 
2005
2139
  case 'reset': {
2006
2140
  if (!fs.existsSync(CONFIG_PATH)) {
2007
- console.log(`\n ${c.dim}No config found. Run ${c.reset}${c.pink}bloby init${c.reset}${c.dim} first.${c.reset}\n`);
2141
+ console.log(`\n ${c.dim}No config found. Run ${c.reset}${c.blue}bloby init${c.reset}${c.dim} first.${c.reset}`);
2142
+ commandsFooter();
2008
2143
  return;
2009
2144
  }
2010
- const config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8'));
2145
+ const config = readConfig() || {};
2011
2146
  config.tunnel = { mode: 'quick' };
2012
2147
  delete config.tunnelUrl;
2013
2148
  fs.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2));
2014
- console.log(`\n ${c.blue}✔${c.reset} Tunnel mode reset to ${c.bold}quick${c.reset} (random trycloudflare.com URL).\n`);
2015
-
2016
- if (isDaemonInstalled() && isDaemonActive()) {
2017
- console.log(` ${c.dim}Restart the daemon to apply: ${c.reset}${c.pink}bloby daemon restart${c.reset}\n`);
2149
+ console.log(`\n ${GLYPH.ok} Tunnel mode reset to ${c.bold}quick${c.reset} (random trycloudflare.com URL).`);
2150
+ if (isDaemonActive()) {
2151
+ console.log(` ${c.dim}Restart to apply: ${c.reset}${c.blue}bloby restart${c.reset}`);
2018
2152
  }
2019
- break;
2153
+ commandsFooter();
2154
+ return;
2020
2155
  }
2021
2156
 
2022
2157
  default:
2023
- console.log(`\n ${c.red}✗${c.reset} Unknown tunnel command: ${action}`);
2158
+ console.log(`\n ${GLYPH.err} Unknown tunnel command: ${c.bold}${action}${c.reset}`);
2024
2159
  console.log(` ${c.dim}Available: setup, status, reset${c.reset}\n`);
2025
- process.exit(1);
2160
+ process.exit(2);
2026
2161
  }
2027
2162
  }
2028
2163
 
2029
2164
  // ── Password Reset ──
2030
2165
 
2031
- async function passwordReset() {
2166
+ async function cmdPasswordReset() {
2032
2167
  const DB_PATH = path.join(DATA_DIR, 'memory.db');
2033
2168
 
2034
2169
  if (!fs.existsSync(DB_PATH)) {
2035
- console.log(`\n ${c.red}✗${c.reset} No database found. Run ${c.pink}bloby init${c.reset} and complete setup first.\n`);
2170
+ console.log(`\n ${GLYPH.err} No database found. Run ${c.blue}bloby init${c.reset} and complete setup first.\n`);
2036
2171
  process.exit(1);
2037
2172
  }
2038
2173
 
@@ -2051,15 +2186,15 @@ async function passwordReset() {
2051
2186
 
2052
2187
  db.close();
2053
2188
 
2054
- console.log(`\n ${c.blue}✔${c.reset} Password reset successful.\n`);
2055
- console.log(` ${c.dim}The onboard wizard will appear on your next visit`);
2056
- console.log(` so you can create a new password.${c.reset}\n`);
2189
+ console.log(`\n ${GLYPH.ok} Password reset successful.`);
2190
+ console.log(` ${c.dim}The onboard wizard will appear on your next visit so you can create a new password.${c.reset}`);
2057
2191
 
2058
- // Auto-restart daemon if it's running
2059
- if (isDaemonInstalled() && isDaemonActive()) {
2060
- console.log(` ${c.dim}Restarting Bloby daemon...${c.reset}`);
2061
- daemon('restart');
2192
+ // Restart so the running supervisor drops cached sessions
2193
+ if (isDaemonActive()) {
2194
+ console.log('');
2195
+ return cmdRestart();
2062
2196
  }
2197
+ commandsFooter();
2063
2198
  }
2064
2199
 
2065
2200
  // ── x402 (Base mainnet payment client) ──
@@ -2096,7 +2231,7 @@ async function ensureX402Module() {
2096
2231
  { cwd: toolsDir, stdio: 'inherit' },
2097
2232
  );
2098
2233
  } catch {
2099
- console.error(` ${c.red}✗${c.reset} Failed to install x402-fetch. Check network and retry.`);
2234
+ console.error(` ${GLYPH.err} Failed to install x402-fetch. Check network and retry.`);
2100
2235
  process.exit(1);
2101
2236
  }
2102
2237
  }
@@ -2104,7 +2239,7 @@ async function ensureX402Module() {
2104
2239
  return import(pathToFileURL(installed).href);
2105
2240
  }
2106
2241
 
2107
- async function x402Pay(rest) {
2242
+ async function cmdX402(rest) {
2108
2243
  if (!rest.length || rest.includes('-h') || rest.includes('--help')) {
2109
2244
  console.log(`
2110
2245
  ${c.bold}bloby x402 <url> [options]${c.reset}
@@ -2126,8 +2261,8 @@ async function x402Pay(rest) {
2126
2261
  process.exit(rest.length ? 0 : 1);
2127
2262
  }
2128
2263
 
2129
- const cfg = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8'));
2130
- if (!cfg.wallet?.privateKey) {
2264
+ const cfg = readConfig();
2265
+ if (!cfg?.wallet?.privateKey) {
2131
2266
  console.error(' ✗ No wallet found in ~/.bloby/config.json. Run `bloby init` first.');
2132
2267
  process.exit(1);
2133
2268
  }
@@ -2194,19 +2329,28 @@ async function x402Pay(rest) {
2194
2329
  }
2195
2330
  }
2196
2331
 
2197
- // ── Route ──
2332
+ // ── Router (strict: only known commands run; anything else errors with a hint) ──
2198
2333
 
2199
2334
  switch (command) {
2200
- case 'init': init(); break;
2201
- case 'start': start(); break;
2202
- case 'stop': daemon('stop'); break;
2203
- case 'logs': daemon('logs'); break;
2204
- case 'status': status(); break;
2205
- case 'update': update(); break;
2206
- case 'daemon': daemon(subcommand); break;
2207
- case 'tunnel': tunnel(subcommand); break;
2208
- case 'password-reset': passwordReset(); break;
2209
- case 'x402': x402Pay(args.slice(1)); break;
2335
+ case undefined:
2336
+ // Bare `bloby` — documented first-run UX: set up if new, otherwise start.
2337
+ if (flags.has('--version') || flags.has('-v')) { console.log(pkg.version); break; }
2338
+ if (flags.has('--help') || flags.has('-h')) { printHelp(); break; }
2339
+ fs.existsSync(CONFIG_PATH) ? cmdStart() : cmdInit();
2340
+ break;
2341
+ case 'init': cmdInit(); break;
2342
+ case 'start': cmdStart(); break;
2343
+ case 'stop': cmdStop(); break;
2344
+ case 'restart': cmdRestart(); break;
2345
+ case 'status': cmdStatus(); break;
2346
+ case 'logs': cmdLogs(); break;
2347
+ case 'update': cmdUpdate(); break;
2348
+ case 'daemon': cmdDaemon(subcommand); break;
2349
+ case 'tunnel': cmdTunnel(subcommand); break;
2350
+ case 'password-reset': cmdPasswordReset(); break;
2351
+ case 'x402': cmdX402(argv.slice(x402Index + 1)); break;
2352
+ case 'help': printHelp(); break;
2353
+ case 'version': console.log(pkg.version); break;
2210
2354
  default:
2211
- fs.existsSync(CONFIG_PATH) ? start() : init();
2355
+ unknownCommand(command);
2212
2356
  }