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/README.md +11 -5
- package/bin/cli.js +1944 -1463
- package/package.json +4 -5
- package/scripts/install.sh +1 -0
- package/supervisor/backend.ts +11 -0
- package/supervisor/channels/manager.ts +2 -0
- package/supervisor/channels/whatsapp-auth.ts +216 -0
- package/supervisor/channels/whatsapp.ts +106 -11
- package/supervisor/index.ts +91 -2
- package/tsconfig.json +1 -1
- package/cli/commands/daemon.ts +0 -31
- package/cli/commands/init.ts +0 -40
- package/cli/commands/start.ts +0 -91
- package/cli/commands/tunnel.ts +0 -175
- package/cli/commands/update.ts +0 -174
- package/cli/core/base-adapter.ts +0 -99
- package/cli/core/cloudflared.ts +0 -71
- package/cli/core/config.ts +0 -58
- package/cli/core/os-detector.ts +0 -31
- package/cli/core/server.ts +0 -87
- package/cli/core/types.ts +0 -15
- package/cli/index.ts +0 -72
- package/cli/platforms/darwin.ts +0 -110
- package/cli/platforms/index.ts +0 -20
- package/cli/platforms/linux.ts +0 -116
- package/cli/platforms/win32.ts +0 -20
- package/cli/utils/ui.ts +0 -38
package/bin/cli.js
CHANGED
|
@@ -1,6 +1,29 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
-
|
|
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
|
-
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
const
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
// ──
|
|
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
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
-
|
|
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
|
|
164
|
-
|
|
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
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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>${
|
|
859
|
+
<string>${LAUNCHD_LOG}</string>
|
|
226
860
|
<key>StandardErrorPath</key>
|
|
227
|
-
<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
|
-
|
|
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;
|
|
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
|
|
247
|
-
if (PLATFORM === 'linux') return
|
|
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
|
-
|
|
252
|
-
|
|
253
|
-
if (PLATFORM === '
|
|
254
|
-
|
|
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
|
|
258
|
-
return
|
|
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
|
-
|
|
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
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
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
|
-
|
|
293
|
-
|
|
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
|
|
296
|
-
const
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
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
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
let
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
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
|
-
|
|
313
|
-
|
|
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
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
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
|
-
|
|
358
|
-
|
|
359
|
-
|
|
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
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
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
|
-
|
|
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
|
-
|
|
380
|
-
|
|
381
|
-
|
|
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
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
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
|
-
|
|
396
|
-
if (
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
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
|
-
|
|
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
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
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
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
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
|
-
|
|
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
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
}
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
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
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
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 (
|
|
474
|
-
console.log(`\n ${
|
|
475
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
504
|
-
|
|
505
|
-
|
|
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
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
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
|
-
|
|
532
|
-
|
|
533
|
-
this.render();
|
|
534
|
-
}, 80);
|
|
535
|
-
this.render();
|
|
1290
|
+
} else {
|
|
1291
|
+
throw new Error(`Unsupported platform: ${PLATFORM}/${arch}`);
|
|
536
1292
|
}
|
|
537
1293
|
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
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
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
1307
|
+
async function ensureCloudflared(spinner) {
|
|
1308
|
+
if (hasCloudflared()) return;
|
|
1309
|
+
if (spinner) spinner.update('Installing cloudflared...');
|
|
1310
|
+
await installCloudflared();
|
|
1311
|
+
}
|
|
554
1312
|
|
|
555
|
-
|
|
556
|
-
|
|
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
|
-
|
|
559
|
-
|
|
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
|
-
|
|
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
|
-
|
|
565
|
-
|
|
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
|
-
|
|
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
|
-
|
|
577
|
-
|
|
1350
|
+
const handleData = (data) => {
|
|
1351
|
+
const text = data.toString();
|
|
578
1352
|
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
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
|
-
|
|
588
|
-
|
|
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
|
-
|
|
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
|
-
|
|
600
|
-
|
|
601
|
-
this.render();
|
|
602
|
-
}
|
|
1367
|
+
// Safety-net timeout: resolve after 45s even if __READY__ never arrives
|
|
1368
|
+
setTimeout(doResolve, 45_000);
|
|
603
1369
|
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
1370
|
+
child.stdout.on('data', handleData);
|
|
1371
|
+
child.stderr.on('data', (data) => {
|
|
1372
|
+
stderrBuf += data.toString();
|
|
1373
|
+
handleData(data);
|
|
1374
|
+
});
|
|
608
1375
|
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
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
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
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
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
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
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
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
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
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
|
-
|
|
661
|
-
${c.dim}─────────────────────────────────${c.reset}
|
|
1441
|
+
if (wantTunnel && tunnelMode !== 'named') await ensureCloudflared(ui);
|
|
662
1442
|
|
|
663
|
-
|
|
664
|
-
|
|
1443
|
+
const installed = installServiceFiles({ spinner: ui });
|
|
1444
|
+
if (!installed.ok) return { ok: false, error: installed.error };
|
|
665
1445
|
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
if (
|
|
670
|
-
|
|
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
|
-
|
|
1453
|
+
const sinceMs = Date.now();
|
|
1454
|
+
const oldTunnelUrl = config.tunnelUrl || null;
|
|
1455
|
+
ui.update('');
|
|
673
1456
|
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
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
|
-
|
|
681
|
-
|
|
682
|
-
|
|
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
|
-
|
|
696
|
-
|
|
697
|
-
|
|
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
|
-
|
|
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
|
-
|
|
705
|
-
console.log(`
|
|
706
|
-
${c.dim}─────────────────────────────────${c.reset}
|
|
1487
|
+
// ── Commands ──
|
|
707
1488
|
|
|
708
|
-
|
|
1489
|
+
async function cmdStart() {
|
|
1490
|
+
if (!fs.existsSync(CONFIG_PATH)) return cmdInit();
|
|
1491
|
+
banner();
|
|
709
1492
|
|
|
710
|
-
|
|
711
|
-
|
|
1493
|
+
const config = readConfig();
|
|
1494
|
+
const runtime = readRuntime();
|
|
1495
|
+
const dPid = hasDaemonSupport() ? daemonPid() : null;
|
|
712
1496
|
|
|
713
|
-
|
|
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
|
-
|
|
716
|
-
|
|
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
|
-
|
|
719
|
-
|
|
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
|
-
|
|
722
|
-
|
|
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
|
|
727
|
-
|
|
728
|
-
|
|
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
|
-
|
|
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
|
-
|
|
733
|
-
|
|
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
|
-
|
|
736
|
-
|
|
737
|
-
|
|
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
|
-
|
|
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
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
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
|
-
|
|
755
|
-
|
|
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
|
-
|
|
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
|
-
|
|
767
|
-
if (
|
|
768
|
-
console.log(`\n ${
|
|
769
|
-
console.log(`
|
|
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 (
|
|
772
|
-
console.log(
|
|
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
|
-
|
|
778
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
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
|
-
|
|
803
|
-
|
|
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
|
-
|
|
806
|
-
if (
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
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
|
-
|
|
824
|
-
|
|
825
|
-
|
|
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
|
-
|
|
1754
|
+
spawnSync('tail', ['-n', String(lines), LAUNCHD_LOG], { stdio: 'inherit' });
|
|
1755
|
+
commandsFooter();
|
|
1756
|
+
return;
|
|
849
1757
|
}
|
|
850
1758
|
|
|
851
|
-
//
|
|
852
|
-
|
|
853
|
-
|
|
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 (
|
|
857
|
-
|
|
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
|
-
// ──
|
|
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
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
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
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
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
|
-
|
|
912
|
-
|
|
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
|
-
|
|
919
|
-
|
|
920
|
-
|
|
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
|
-
|
|
939
|
-
|
|
940
|
-
|
|
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
|
-
|
|
1848
|
+
writeLine(` ${c.bold}${c.white}How do you want to connect your bot?${c.reset}`);
|
|
1849
|
+
writeLine();
|
|
950
1850
|
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
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
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
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
|
-
|
|
967
|
-
|
|
968
|
-
const viteWarm = new Promise((r) => { viteWarmResolve = r; });
|
|
1866
|
+
process.stdout.write('\x1b[?25l');
|
|
1867
|
+
render();
|
|
969
1868
|
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
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
|
-
|
|
986
|
-
|
|
1893
|
+
process.stdin.on('data', onKey);
|
|
1894
|
+
});
|
|
1895
|
+
}
|
|
987
1896
|
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
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
|
-
|
|
995
|
-
|
|
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
|
-
|
|
998
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
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
|
-
|
|
1013
|
-
|
|
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
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
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
|
-
|
|
1022
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1973
|
+
async function cmdInit() {
|
|
1974
|
+
banner();
|
|
1035
1975
|
|
|
1036
|
-
|
|
1037
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
|
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
|
-
//
|
|
1096
|
-
|
|
1097
|
-
|
|
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
|
-
|
|
1137
|
-
|
|
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
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
const
|
|
1370
|
-
|
|
1371
|
-
process.
|
|
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
|
-
|
|
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
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
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
|
-
|
|
1411
|
-
banner();
|
|
2095
|
+
if (!selfUpdate) banner();
|
|
1412
2096
|
|
|
1413
2097
|
// Refuse to run the update as root — file ownership would get poisoned
|
|
1414
|
-
if (
|
|
1415
|
-
console.log(`\n ${
|
|
1416
|
-
console.log(
|
|
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
|
-
|
|
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()}`, {
|
|
1428
|
-
|
|
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
|
-
|
|
2117
|
+
} catch (e) {
|
|
2118
|
+
s.fail(`Failed to check for updates: ${e.message}`);
|
|
1432
2119
|
process.exit(1);
|
|
1433
2120
|
}
|
|
1434
2121
|
|
|
1435
|
-
|
|
1436
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
2150
|
+
s2.succeed('Downloaded.');
|
|
1484
2151
|
|
|
1485
|
-
// Stop daemon AFTER download succeeds — minimizes downtime
|
|
1486
|
-
// Skipped during self-update (supervisor handles
|
|
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
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 ${
|
|
1556
|
-
console.log(
|
|
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
|
-
|
|
1563
|
-
console.log(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
2295
|
+
// During self-update the supervisor handles its own relaunch — just exit 0.
|
|
1627
2296
|
if (selfUpdate) {
|
|
1628
|
-
console.log(
|
|
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
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
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
|
-
|
|
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}
|
|
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
|
-
// ──
|
|
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
|
|
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 ${
|
|
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
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
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
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
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
|
-
|
|
1806
|
-
|
|
1807
|
-
|
|
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
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
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
|
-
|
|
1831
|
-
|
|
1832
|
-
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1869
|
-
|
|
1870
|
-
|
|
1871
|
-
|
|
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
|
-
|
|
1918
|
-
|
|
1919
|
-
|
|
1920
|
-
|
|
1921
|
-
|
|
1922
|
-
|
|
1923
|
-
|
|
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 ${
|
|
1928
|
-
|
|
1929
|
-
process.exit(
|
|
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
|
-
// ──
|
|
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
|
|
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
|
-
|
|
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(` ${
|
|
2439
|
+
console.log(` ${GLYPH.ok} Bloby config updated\n`);
|
|
1960
2440
|
|
|
1961
|
-
|
|
1962
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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 =
|
|
1989
|
-
const mode =
|
|
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
|
|
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
|
|
1995
|
-
if (config.tunnel?.domain) console.log(` ${c.dim}Domain
|
|
1996
|
-
if (config.tunnel?.configPath) console.log(` ${c.dim}Config
|
|
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
|
|
2470
|
+
console.log(` ${c.dim}${'URL'.padEnd(9)}${c.reset}${c.blue}${link(config.tunnelUrl)}${c.reset}`);
|
|
2000
2471
|
}
|
|
2001
|
-
|
|
2002
|
-
|
|
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.
|
|
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 =
|
|
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 ${
|
|
2015
|
-
|
|
2016
|
-
|
|
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
|
-
|
|
2490
|
+
commandsFooter();
|
|
2491
|
+
return;
|
|
2020
2492
|
}
|
|
2021
2493
|
|
|
2022
2494
|
default:
|
|
2023
|
-
console.log(`\n ${
|
|
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(
|
|
2497
|
+
process.exit(2);
|
|
2026
2498
|
}
|
|
2027
2499
|
}
|
|
2028
2500
|
|
|
2029
2501
|
// ── Password Reset ──
|
|
2030
2502
|
|
|
2031
|
-
async function
|
|
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 ${
|
|
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 ${
|
|
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
|
-
//
|
|
2059
|
-
if (
|
|
2060
|
-
console.log(
|
|
2061
|
-
|
|
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(` ${
|
|
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
|
|
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 =
|
|
2130
|
-
if (!cfg
|
|
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
|
-
// ──
|
|
2669
|
+
// ── Router (strict: only known commands run; anything else errors with a hint) ──
|
|
2198
2670
|
|
|
2199
2671
|
switch (command) {
|
|
2200
|
-
case
|
|
2201
|
-
|
|
2202
|
-
|
|
2203
|
-
|
|
2204
|
-
|
|
2205
|
-
|
|
2206
|
-
case '
|
|
2207
|
-
case '
|
|
2208
|
-
case '
|
|
2209
|
-
case '
|
|
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
|
-
|
|
2692
|
+
unknownCommand(command);
|
|
2212
2693
|
}
|