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 +407 -70
- package/package.json +1 -1
- package/supervisor/backend.ts +11 -0
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
|
-
//
|
|
850
|
-
//
|
|
851
|
-
//
|
|
852
|
-
|
|
853
|
-
|
|
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
|
-
//
|
|
861
|
-
// terminal
|
|
862
|
-
|
|
863
|
-
const
|
|
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 (
|
|
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
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
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
|
-
|
|
882
|
-
if (
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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}
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1205
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
1273
|
-
const stopped = await stopCore({ spinner:
|
|
1603
|
+
const steps = new Steps(['Stopping Bloby', ...startStepTitles(config)]).start();
|
|
1604
|
+
const stopped = await stopCore({ spinner: steps });
|
|
1274
1605
|
if (!stopped.ok) {
|
|
1275
|
-
|
|
1606
|
+
steps.fail(`Failed to stop: ${stopped.error}`);
|
|
1276
1607
|
commandsFooter();
|
|
1277
1608
|
process.exit(1);
|
|
1278
1609
|
}
|
|
1279
|
-
|
|
1610
|
+
steps.advance(); // Stopping Bloby → Preparing
|
|
1280
1611
|
|
|
1281
|
-
const
|
|
1282
|
-
const result = await startCore({ spinner: s2 });
|
|
1612
|
+
const result = await startCore({ ui: steps });
|
|
1283
1613
|
if (!result.ok) {
|
|
1284
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1728
|
-
const
|
|
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({
|
|
2062
|
+
const result = await startCore({ ui: steps, ...(HOSTED ? { timeoutMs: 180_000 } : {}) });
|
|
1732
2063
|
if (!result.ok) {
|
|
1733
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1969
|
-
const
|
|
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
|
-
|
|
2307
|
+
if (!result.tunnelFailed && (result.tunnelMode === 'off' || result.ready)) steps.finish();
|
|
2308
|
+
else steps.fail();
|
|
1972
2309
|
printReadyBlock(result);
|
|
1973
2310
|
} else {
|
|
1974
|
-
|
|
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.
|
|
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",
|
package/supervisor/backend.ts
CHANGED
|
@@ -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").
|