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