bloby-bot 0.67.0 → 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
@@ -194,11 +194,57 @@ const GLYPH = {
194
194
  };
195
195
  const DIVIDER = ` ${c.dim}${'─'.repeat(29)}${c.reset}`;
196
196
  const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
197
+ const BAR_WIDTH = 30;
197
198
 
198
199
  function link(url) {
199
200
  return isTTY && useColor ? `\x1b]8;;${url}\x07${url}\x1b]8;;\x07` : url;
200
201
  }
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
+
202
248
  // Single terminal-cleanup path: restore cursor + raw mode, stop any spinner.
203
249
  // Registered once; every exit route (normal, SIGINT, SIGTERM, crash) goes through it.
204
250
  let activeSpinner = null;
@@ -270,6 +316,159 @@ class Spinner {
270
316
  info(text) { this.stopRaw(); console.log(` ${c.dim}${text ?? this.text}${c.reset}`); return this; }
271
317
  }
272
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
+
273
472
  function banner() {
274
473
  if (HOSTED) return;
275
474
  console.log(`
@@ -846,40 +1045,130 @@ async function killStraySupervisors({ exceptPid = null } = {}) {
846
1045
  }
847
1046
 
848
1047
  // ── Readiness wait ──
849
- // Replaces the old launchd-log-offset __READY__ grep with portable signals that
850
- // work identically on macOS/Linux: the supervisor pidfile, /api/health, and the
851
- // tunnelUrl the supervisor writes back into config.json.
852
-
853
- async function waitForReady({ sinceMs, oldTunnelUrl, wantTunnel, tunnelMode, spinner, timeoutMs = 90_000 }) {
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())}`;
1061
+ }
1062
+
1063
+ async function waitForReady({ sinceMs, oldTunnelUrl, tunnelMode, ui, logOffset = 0, timeoutMs = 150_000 }) {
1064
+ const U = ui ?? { advance() {}, setNote() {}, update() {} };
854
1065
  const config = readConfig() || {};
855
1066
  const port = config.port || 7400;
856
1067
  const relayUrl = config.relay?.url || null;
1068
+ const wantTunnel = tunnelMode !== 'off';
1069
+
857
1070
  let healthy = false;
858
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
+
859
1080
  const deadline = Date.now() + timeoutMs;
860
- // Interactive callers stop waiting on a slow tunnel after 60s (don't hold the
861
- // terminal hostage); callers that passed a bigger budget (hosted provisioning)
862
- // get the full window a slow tunnel is not a failed tunnel.
863
- const tunnelDeadline = Date.now() + (timeoutMs > 90_000 ? timeoutMs : Math.min(timeoutMs, 60_000));
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 '';
1101
+ }
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
+ };
864
1110
 
865
1111
  while (Date.now() < deadline) {
866
1112
  if (!healthy) {
867
1113
  const health = await fetchHealth(port, 2000);
868
1114
  if (health) {
869
1115
  healthy = true;
870
- if (spinner && wantTunnel) spinner.update('Connecting tunnel...');
1116
+ if (phase === 'server') {
1117
+ U.advance();
1118
+ phase = wantTunnel ? 'tunnel' : 'done';
1119
+ }
871
1120
  }
872
1121
  }
1122
+
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; }
873
1127
  if (wantTunnel && !tunnelUrl) {
874
- const cfg = readConfig();
875
- // Named tunnels keep a stable URL, so "changed since last run" never fires —
876
- // accept any URL the supervisor wrote once it is healthy.
877
- if (cfg?.tunnelUrl && (cfg.tunnelUrl !== oldTunnelUrl || (tunnelMode === 'named' && healthy))) {
878
- tunnelUrl = cfg.tunnelUrl;
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;
1136
+ }
879
1137
  }
880
1138
  }
881
- if (healthy && (!wantTunnel || tunnelUrl)) break;
882
- if (healthy && wantTunnel && Date.now() > tunnelDeadline) break; // healthy but tunnel slow — don't hold the terminal hostage
1139
+
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
+ }
1153
+
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';
1166
+ }
1167
+ }
1168
+
1169
+ if (phase === 'done') break;
1170
+ if (healthy && tunnelFailed) break;
1171
+ if (healthy && wantTunnel && !tunnelUrl && Date.now() > tunnelDeadline) break;
883
1172
  await new Promise(r => setTimeout(r, 1000));
884
1173
  }
885
1174
 
@@ -887,8 +1176,10 @@ async function waitForReady({ sinceMs, oldTunnelUrl, wantTunnel, tunnelMode, spi
887
1176
  const cfg = readConfig() || {};
888
1177
  return {
889
1178
  healthy,
890
- tunnelUrl: tunnelUrl || (wantTunnel && cfg.tunnelUrl !== oldTunnelUrl ? cfg.tunnelUrl : tunnelUrl),
1179
+ tunnelUrl,
891
1180
  relayUrl: cfg.relay?.url || relayUrl,
1181
+ ready: verified,
1182
+ tunnelFailed,
892
1183
  runtime: readRuntime(),
893
1184
  sinceMs,
894
1185
  };
@@ -912,13 +1203,25 @@ function getNetworkUrls(port) {
912
1203
  return urls;
913
1204
  }
914
1205
 
915
- function printReadyBlock({ healthy, tunnelUrl, relayUrl, port, tunnelMode }) {
916
- console.log('');
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
+
917
1213
  if (healthy) {
918
- console.log(` ${c.pink}${c.bold}✔ Bloby is running${c.reset}\n`);
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}`);
1218
+ }
1219
+ resultBox(boxLines);
919
1220
  } else {
920
- console.log(` ${GLYPH.warn} Bloby is starting — not healthy yet. Check ${c.blue}bloby status${c.reset} in a moment.\n`);
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}`]);
921
1222
  }
1223
+
1224
+ console.log('');
922
1225
  console.log(` ${c.dim}${'Local'.padEnd(9)}${c.reset}${c.blue}${link(`http://localhost:${port}`)}${c.reset}`);
923
1226
  if (tunnelMode === 'off') {
924
1227
  for (const u of getNetworkUrls(port)) {
@@ -927,12 +1230,20 @@ function printReadyBlock({ healthy, tunnelUrl, relayUrl, port, tunnelMode }) {
927
1230
  }
928
1231
  if (tunnelUrl) {
929
1232
  console.log(` ${c.dim}${'Tunnel'.padEnd(9)}${c.reset}${c.blue}${link(tunnelUrl)}${c.reset}`);
930
- } else if (tunnelMode !== 'off') {
931
- console.log(` ${c.dim}${'Tunnel'.padEnd(9)}still connecting — check ${c.reset}${c.blue}bloby status${c.reset}${c.dim} shortly${c.reset}`);
932
1233
  }
933
- if (relayUrl) {
1234
+ if (relayUrl && relayUrl !== primary) {
934
1235
  console.log(` ${c.dim}${'Relay'.padEnd(9)}${c.reset}${c.pink}${link(relayUrl)}${c.reset}`);
935
1236
  }
1237
+
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}`);
1246
+ }
936
1247
  }
937
1248
 
938
1249
  // ── Cloudflared install (shared by init/start/tunnel) ──
@@ -1091,15 +1402,17 @@ async function runForeground() {
1091
1402
  s.fail(`Server failed to start: ${err.message}`);
1092
1403
  process.exit(1);
1093
1404
  }
1094
- s.succeed('Bloby is running (foreground — Ctrl+C stops it)');
1405
+ s.stopRaw();
1095
1406
  printReadyBlock({
1096
1407
  healthy: true,
1408
+ ready: !result.tunnelFailed,
1409
+ tunnelFailed: result.tunnelFailed,
1097
1410
  tunnelUrl: result.tunnelFailed ? null : result.tunnelUrl,
1098
1411
  relayUrl: result.relayUrl,
1099
1412
  port: config.port || 7400,
1100
1413
  tunnelMode,
1101
1414
  });
1102
- console.log(`\n ${c.dim}Press Ctrl+C to stop${c.reset}\n`);
1415
+ console.log(`\n ${c.dim}Running in the foreground — press Ctrl+C to stop${c.reset}\n`);
1103
1416
  result.child.stdout.on('data', (d) => process.stdout.write(` ${c.dim}${d.toString().trim()}${c.reset}\n`));
1104
1417
  result.child.stderr.on('data', (d) => {
1105
1418
  const line = d.toString().trim();
@@ -1111,31 +1424,47 @@ async function runForeground() {
1111
1424
 
1112
1425
  // ── start / stop / restart cores (shared so restart ≡ stop + start, exactly) ──
1113
1426
 
1114
- async function startCore({ spinner, timeoutMs }) {
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'] : [])];
1431
+ }
1432
+
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 }) {
1115
1437
  const config = readConfig() || {};
1116
1438
  const tunnelMode = tunnelModeOf(config);
1117
1439
  const wantTunnel = tunnelMode !== 'off';
1118
1440
 
1119
- if (wantTunnel && tunnelMode !== 'named') await ensureCloudflared(spinner);
1441
+ if (wantTunnel && tunnelMode !== 'named') await ensureCloudflared(ui);
1120
1442
 
1121
- const installed = installServiceFiles({ spinner });
1443
+ const installed = installServiceFiles({ spinner: ui });
1122
1444
  if (!installed.ok) return { ok: false, error: installed.error };
1445
+
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 {}
1452
+ }
1123
1453
  const sinceMs = Date.now();
1124
1454
  const oldTunnelUrl = config.tunnelUrl || null;
1455
+ ui.update('');
1125
1456
 
1126
- spinner.update('Starting Bloby...');
1127
- const started = startDaemonService({ spinner });
1457
+ const started = startDaemonService({ spinner: ui });
1128
1458
  if (!started.ok) return { ok: false, error: started.error };
1459
+ ui.advance(); // Preparing → Starting server
1129
1460
 
1130
- spinner.update('Waiting for the server...');
1131
- const ready = await waitForReady({ sinceMs, oldTunnelUrl, wantTunnel, tunnelMode, spinner, ...(timeoutMs ? { timeoutMs } : {}) });
1461
+ const ready = await waitForReady({ sinceMs, oldTunnelUrl, tunnelMode, ui, logOffset, ...(timeoutMs ? { timeoutMs } : {}) });
1132
1462
  return { ok: true, ...ready, port: config.port || 7400, tunnelMode };
1133
1463
  }
1134
1464
 
1135
1465
  async function stopCore({ spinner }) {
1136
1466
  const stopped = await stopDaemonService({ spinner });
1137
1467
  if (!stopped.ok) return stopped;
1138
- spinner.update('Waiting for processes to exit...');
1139
1468
  await killStraySupervisors();
1140
1469
  return { ok: true };
1141
1470
  }
@@ -1181,7 +1510,8 @@ async function cmdStart() {
1181
1510
 
1182
1511
  if (dPid || runtime) {
1183
1512
  if (dPid) {
1184
- console.log(`\n ${c.blue}●${c.reset} Bloby is already running. Use ${c.blue}bloby restart${c.reset} if you want to restart it.\n`);
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`);
1185
1515
  describeRunning(config);
1186
1516
  } else {
1187
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}.`);
@@ -1201,16 +1531,16 @@ async function cmdStart() {
1201
1531
  return;
1202
1532
  }
1203
1533
 
1204
- console.log('');
1205
- const s = new Spinner().start('Starting Bloby...');
1206
- const result = await startCore({ spinner: s });
1534
+ const steps = new Steps(startStepTitles(config)).start();
1535
+ const result = await startCore({ ui: steps });
1207
1536
  if (!result.ok) {
1208
- s.fail(`Failed to start: ${result.error}`);
1209
- console.log(` Check ${c.blue}bloby logs${c.reset} for details.`);
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}`);
1210
1539
  commandsFooter();
1211
1540
  process.exit(1);
1212
1541
  }
1213
- s.stopRaw();
1542
+ if (result.healthy && !result.tunnelFailed && (result.tunnelMode === 'off' || result.ready)) steps.finish();
1543
+ else steps.fail();
1214
1544
  printReadyBlock(result);
1215
1545
  commandsFooter();
1216
1546
  }
@@ -1243,7 +1573,8 @@ async function cmdStop() {
1243
1573
  commandsFooter();
1244
1574
  process.exit(1);
1245
1575
  }
1246
- s.succeed('Bloby stopped.');
1576
+ s.stopRaw();
1577
+ resultBox([` ${c.bold}✔ Bloby stopped${c.reset}`]);
1247
1578
  commandsFooter();
1248
1579
  }
1249
1580
 
@@ -1258,35 +1589,35 @@ async function cmdRestart() {
1258
1589
  process.exit(1);
1259
1590
  }
1260
1591
 
1261
- console.log('');
1262
1592
  // Anything that can block (cloudflared download) happens BEFORE the stop —
1263
1593
  // never leave Bloby down while waiting on the network.
1264
1594
  const config = readConfig() || {};
1265
1595
  const preMode = tunnelModeOf(config);
1266
1596
  if (preMode !== 'off' && preMode !== 'named' && !hasCloudflared()) {
1597
+ console.log('');
1267
1598
  const sCf = new Spinner().start('Installing cloudflared...');
1268
1599
  try { await installCloudflared(); sCf.succeed('cloudflared ready'); }
1269
1600
  catch (e) { sCf.fail(`cloudflared install failed: ${e.message}`); process.exit(1); }
1270
1601
  }
1271
1602
 
1272
- const s = new Spinner().start('Stopping Bloby...');
1273
- const stopped = await stopCore({ spinner: s });
1603
+ const steps = new Steps(['Stopping Bloby', ...startStepTitles(config)]).start();
1604
+ const stopped = await stopCore({ spinner: steps });
1274
1605
  if (!stopped.ok) {
1275
- s.fail(`Failed to stop: ${stopped.error}`);
1606
+ steps.fail(`Failed to stop: ${stopped.error}`);
1276
1607
  commandsFooter();
1277
1608
  process.exit(1);
1278
1609
  }
1279
- s.succeed('Stopped.');
1610
+ steps.advance(); // Stopping Bloby → Preparing
1280
1611
 
1281
- const s2 = new Spinner().start('Starting Bloby...');
1282
- const result = await startCore({ spinner: s2 });
1612
+ const result = await startCore({ ui: steps });
1283
1613
  if (!result.ok) {
1284
- s2.fail(`Failed to start: ${result.error}`);
1285
- console.log(` Check ${c.blue}bloby logs${c.reset} for details.`);
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}`);
1286
1616
  commandsFooter();
1287
1617
  process.exit(1);
1288
1618
  }
1289
- s2.stopRaw();
1619
+ if (result.healthy && !result.tunnelFailed && (result.tunnelMode === 'off' || result.ready)) steps.finish();
1620
+ else steps.fail();
1290
1621
  printReadyBlock(result);
1291
1622
  commandsFooter();
1292
1623
  }
@@ -1311,20 +1642,19 @@ async function cmdStatus() {
1311
1642
  (PLATFORM === 'linux' && ['activating', 'active'].includes(systemdShow().activeState))
1312
1643
  );
1313
1644
 
1314
- console.log('');
1315
1645
  if (running && health) {
1316
1646
  const up = runtime?.startedAt ? ` · up ${formatUptime(Date.now() - runtime.startedAt)}` : (health.uptime != null ? ` · up ${formatUptime(health.uptime * 1000)}` : '');
1317
1647
  const mode = dPid && pid === dPid ? 'daemon' : 'foreground';
1318
1648
  const pidPart = pid ? ` · PID ${pid}` : '';
1319
- console.log(` ${c.blue}●${c.reset} ${c.bold}Bloby is running${c.reset} ${c.dim}v${version}${pidPart}${up} · ${mode}${c.reset}`);
1649
+ resultBox([` ${c.blue}●${c.reset} ${c.bold}Bloby is running${c.reset} ${c.dim}v${version}${pidPart}${up} · ${mode}${c.reset}`]);
1320
1650
  } else if (running) {
1321
- console.log(` ${c.yellow}●${c.reset} ${c.bold}Bloby is starting${c.reset} ${c.dim}PID ${pid} alive, not answering yet — give it a moment${c.reset}`);
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}`]);
1322
1652
  } else if (jobLoadedNoPid) {
1323
- console.log(` ${c.yellow}●${c.reset} ${c.bold}Bloby is restarting${c.reset} ${c.dim}service loaded but the process is down (starting or crash-looping) — check ${c.reset}${c.blue}bloby logs${c.reset}`);
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}`]);
1324
1654
  } else if (installed) {
1325
- console.log(` ${c.dim} Bloby is stopped${c.reset} ${c.dim}run ${c.reset}${c.blue}bloby start${c.reset}`);
1655
+ resultBox([` ${c.dim}●${c.reset} ${c.bold}Bloby is stopped${c.reset} ${c.dim}run ${c.reset}${c.blue}bloby start${c.reset}`]);
1326
1656
  } else {
1327
- console.log(` ${c.dim} Bloby is not set up${c.reset} ${c.dim}run ${c.reset}${c.blue}bloby init${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}`]);
1328
1658
  }
1329
1659
 
1330
1660
  if (config) {
@@ -1654,7 +1984,8 @@ async function cmdInit() {
1654
1984
  console.log(`__HOSTED_READY__=${JSON.stringify({ tunnelUrl: config?.tunnelUrl || `http://localhost:${config?.port || 7400}`, status: config?.tunnelUrl ? 'ok' : 'tunnel_failed', daemon: true })}`);
1655
1985
  process.exit(0);
1656
1986
  }
1657
- console.log(`\n ${c.blue}●${c.reset} Bloby is already set up and running. Use ${c.blue}bloby restart${c.reset} to restart it, or ${c.blue}bloby tunnel setup${c.reset} to change the tunnel.\n`);
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`);
1658
1989
  describeRunning(config);
1659
1990
  commandsFooter();
1660
1991
  return;
@@ -1724,23 +2055,27 @@ async function cmdInit() {
1724
2055
  }
1725
2056
 
1726
2057
  if (log) log('Starting daemon...');
1727
- console.log('');
1728
- const s = new Spinner().start('Setting up Bloby...');
2058
+ const initConfig = readConfig() || {};
2059
+ const steps = new Steps(startStepTitles(initConfig)).start();
1729
2060
  // Hosted provisioning treats tunnel_failed as meaningful — give cold cloud
1730
2061
  // boxes a longer window before declaring the tunnel down.
1731
- const result = await startCore({ spinner: s, ...(HOSTED ? { timeoutMs: 150_000 } : {}) });
2062
+ const result = await startCore({ ui: steps, ...(HOSTED ? { timeoutMs: 180_000 } : {}) });
1732
2063
  if (!result.ok) {
1733
- s.fail(`Failed to start: ${result.error}`);
2064
+ steps.fail(`Failed to start: ${result.error}`);
1734
2065
  if (HOSTED) {
1735
2066
  console.error(JSON.stringify({ error: result.error }));
1736
2067
  }
1737
2068
  process.exit(1);
1738
2069
  }
1739
- s.stopRaw();
2070
+ if (result.healthy && !result.tunnelFailed && (result.tunnelMode === 'off' || result.ready)) steps.finish();
2071
+ else steps.fail();
1740
2072
 
1741
2073
  if (HOSTED) {
1742
2074
  if (log) log(result.healthy ? 'Daemon running' : 'Daemon starting');
1743
- console.log(`__HOSTED_READY__=${JSON.stringify({ tunnelUrl: result.tunnelUrl || `http://localhost:${result.port}`, status: result.tunnelUrl ? 'ok' : 'tunnel_failed', daemon: true })}`);
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 })}`);
1744
2079
  process.exit(0);
1745
2080
  }
1746
2081
 
@@ -1965,13 +2300,15 @@ async function cmdUpdate() {
1965
2300
 
1966
2301
  // Restart only if it was running before — update does not start a stopped bot.
1967
2302
  if (daemonWasRunning) {
1968
- const s7 = new Spinner().start('Restarting Bloby...');
1969
- const result = await startCore({ spinner: s7 });
2303
+ const cfgNow = readConfig() || {};
2304
+ const steps = new Steps(startStepTitles(cfgNow)).start();
2305
+ const result = await startCore({ ui: steps });
1970
2306
  if (result.ok && result.healthy) {
1971
- s7.succeed('Bloby restarted on the new version.');
2307
+ if (!result.tunnelFailed && (result.tunnelMode === 'off' || result.ready)) steps.finish();
2308
+ else steps.fail();
1972
2309
  printReadyBlock(result);
1973
2310
  } else {
1974
- s7.warn(`Bloby may still be starting — check ${c.blue}bloby status${c.reset}.`);
2311
+ steps.fail(`Bloby may still be starting — check ${c.blue}bloby status${c.reset}.`);
1975
2312
  }
1976
2313
  } else {
1977
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}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bloby-bot",
3
- "version": "0.67.0",
3
+ "version": "0.68.0",
4
4
  "releaseNotes": [
5
5
  "1. Fix: agent self-update now actively relaunches the daemon (launchctl/systemctl) instead of relying on launchd KeepAlive — so `update` from chat/pulse comes back up reliably, matching manual `bloby update`",
6
6
  "2. New agent control surface (/__bloby/control/*): restart-and-verify the backend in-turn, tail backend + frontend logs, and acked self-update — replacing the flaky touch .update/.restart triggers",
@@ -9,6 +9,17 @@ let child: ChildProcess | null = null;
9
9
  let restarts = 0;
10
10
  let lastSpawnTime = 0;
11
11
  let intentionallyStopped = false;
12
+
13
+ // Hard backstop against an orphaned backend. SIGTERM/SIGINT go through stopBackend() for a graceful
14
+ // stop, but process.exit() (server EADDRINUSE handler, self-update relaunch, fatal errors) bypasses
15
+ // those handlers AND can't run async cleanup — so the backend child would survive as an orphan
16
+ // (PPID→1) still holding BACKEND_PORT, EADDRINUSE-ing every later backend spawn until killPort
17
+ // happens to reclaim it. 'exit' fires on EVERY exit path (including process.exit) and allows the one
18
+ // synchronous kill we need. (A SIGKILL of the supervisor itself can't be caught — killPort covers it
19
+ // on the next startup.)
20
+ process.on('exit', () => {
21
+ try { if (child && child.exitCode === null) child.kill('SIGKILL'); } catch {}
22
+ });
12
23
  // True once the backend has crash-looped past MAX_RESTARTS and given up — i.e. it's down and
13
24
  // will NOT come back without the user fixing the code. The supervisor shows the "backend down"
14
25
  // interstitial in this state. Cleared on every spawn attempt (a deliberate restart is "trying again").