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