vibelet 1.0.12 → 1.0.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/vibelet.mjs DELETED
@@ -1,1414 +0,0 @@
1
- #!/usr/bin/env node
2
-
3
- import { spawn, spawnSync } from 'node:child_process';
4
- import { cpSync, existsSync, mkdirSync, readFileSync, realpathSync, renameSync, rmSync, statSync, writeFileSync, openSync, writeSync } from 'node:fs';
5
- import { homedir } from 'node:os';
6
- import { basename, delimiter, dirname, join, resolve } from 'node:path';
7
- import { fileURLToPath } from 'node:url';
8
- import QRCode from 'qrcode';
9
- import { extractQuickTunnelUrl } from './cloudflared-quick-tunnel.mjs';
10
- import { formatCloudflaredFailureMessage, resolveCloudflaredLaunchSpec } from './cloudflared-resolver.mjs';
11
- import { canUseSystemdUserManager, isSystemdUserManagerUnavailable } from './linux-systemd.mjs';
12
- import { doesHealthMatchRequestedConnectionConfig, shouldReuseHealthyDaemon } from './vibelet-runtime-policy.mjs';
13
-
14
- // ─── Paths & constants ─────────────────────────────────────────────────────────
15
-
16
- const rootDir = resolve(dirname(fileURLToPath(import.meta.url)), '..');
17
- const packageJson = JSON.parse(readFileSync(join(rootDir, 'package.json'), 'utf8'));
18
- const daemonDistDir = resolve(rootDir, 'dist');
19
- const daemonEntryPath = resolve(daemonDistDir, 'index.cjs');
20
- const vibeletDir = join(homedir(), '.vibelet');
21
- const pairingQrPngPath = join(vibeletDir, 'pairing-qr.png');
22
- const logDir = join(vibeletDir, 'logs');
23
- const runtimeDir = join(vibeletDir, 'runtime');
24
- const runtimeCurrentDir = join(runtimeDir, 'current');
25
- const runtimeMetadataPath = join(runtimeCurrentDir, 'runtime.json');
26
- const runtimeDaemonEntryPath = join(runtimeCurrentDir, 'dist', 'index.cjs');
27
- const stdoutLogPath = join(logDir, 'daemon.stdout.log');
28
- const stderrLogPath = join(logDir, 'daemon.stderr.log');
29
- const pidFilePath = join(vibeletDir, 'daemon.pid');
30
- const relayConfigPath = join(vibeletDir, 'relay.json');
31
- const tunnelStatePath = join(vibeletDir, 'tunnel.json');
32
- const updateCheckPath = join(vibeletDir, 'update-check.json');
33
- const OFFICIAL_SITE_URL = 'https://vibelet.icu';
34
- const UPDATE_CHECK_INTERVAL_MS = 4 * 60 * 60 * 1000; // 4 hours
35
-
36
- let officialSitePrinted = false;
37
- let updateMessage = '';
38
- let port = normalizePortValue(process.env.VIBE_PORT) ?? 9876;
39
-
40
- function normalizePortValue(rawValue) {
41
- if (typeof rawValue !== 'string' && typeof rawValue !== 'number') {
42
- return null;
43
- }
44
- const normalized = String(rawValue).trim();
45
- if (!/^\d+$/.test(normalized)) {
46
- return null;
47
- }
48
- const parsed = Number(normalized);
49
- if (!Number.isInteger(parsed) || parsed <= 0 || parsed > 65535) {
50
- return null;
51
- }
52
- return parsed;
53
- }
54
-
55
- function printOfficialSite() {
56
- if (officialSitePrinted) return;
57
- officialSitePrinted = true;
58
- try {
59
- if (updateMessage) writeSync(2, `\n${updateMessage}\n`);
60
- writeSync(1, `\nOfficial site: ${OFFICIAL_SITE_URL}\n`);
61
- } catch {
62
- // Best-effort branding footer; ignore broken pipes and closed stdio.
63
- }
64
- }
65
-
66
- // ─── Update check ──────────────────────────────────────────────────────────────
67
-
68
- function readUpdateCheck() {
69
- try {
70
- return JSON.parse(readFileSync(updateCheckPath, 'utf8'));
71
- } catch {
72
- return null;
73
- }
74
- }
75
-
76
- function writeUpdateCheck(data) {
77
- try {
78
- mkdirSync(vibeletDir, { recursive: true });
79
- writeFileSync(updateCheckPath, JSON.stringify(data, null, 2) + '\n', 'utf8');
80
- } catch {
81
- // Best-effort; failing to persist is fine.
82
- }
83
- }
84
-
85
- function compareVersions(a, b) {
86
- const pa = a.split('.').map(Number);
87
- const pb = b.split('.').map(Number);
88
- for (let i = 0; i < 3; i++) {
89
- if ((pa[i] || 0) < (pb[i] || 0)) return -1;
90
- if ((pa[i] || 0) > (pb[i] || 0)) return 1;
91
- }
92
- return 0;
93
- }
94
-
95
- function checkForUpdateFromCache() {
96
- const cached = readUpdateCheck();
97
- if (!cached?.latestVersion) return;
98
- if (compareVersions(packageJson.version, cached.latestVersion) < 0) {
99
- updateMessage =
100
- `\x1b[33m╭───────────────────────────────────────────╮\x1b[0m\n` +
101
- `\x1b[33m│\x1b[0m Update available: \x1b[90m${packageJson.version}\x1b[0m → \x1b[32m${cached.latestVersion}\x1b[0m${' '.repeat(Math.max(0, 14 - packageJson.version.length - cached.latestVersion.length))}\x1b[33m│\x1b[0m\n` +
102
- `\x1b[33m│\x1b[0m Run \x1b[36mnpx @vibelet/cli@latest\x1b[0m to upgrade \x1b[33m│\x1b[0m\n` +
103
- `\x1b[33m╰───────────────────────────────────────────╯\x1b[0m`;
104
- }
105
- }
106
-
107
- function fetchLatestVersionInBackground() {
108
- const cached = readUpdateCheck();
109
- const now = Date.now();
110
- if (cached?.checkedAt && now - cached.checkedAt < UPDATE_CHECK_INTERVAL_MS) {
111
- return; // Checked recently; skip.
112
- }
113
-
114
- // Fire-and-forget: spawn a detached process to query the registry so we
115
- // never block the CLI. Results are read on the *next* invocation.
116
- const script = `
117
- const https = await import('node:https');
118
- const fs = await import('node:fs');
119
- const url = 'https://registry.npmjs.org/@vibelet/cli/latest';
120
- https.get(url, { headers: { 'Accept': 'application/json' }, timeout: 8000 }, (res) => {
121
- let data = '';
122
- res.on('data', (c) => data += c);
123
- res.on('end', () => {
124
- try {
125
- const version = JSON.parse(data).version;
126
- if (version) {
127
- fs.writeFileSync(${JSON.stringify(updateCheckPath)}, JSON.stringify({
128
- latestVersion: version,
129
- checkedAt: Date.now(),
130
- }, null, 2) + '\\n');
131
- }
132
- } catch {}
133
- });
134
- }).on('error', () => {});
135
- `;
136
-
137
- const child = spawn(process.execPath, ['--input-type=module', '-e', script], {
138
- detached: true,
139
- stdio: 'ignore',
140
- windowsHide: true,
141
- });
142
- child.unref();
143
- }
144
-
145
- // ─── Helpers ────────────────────────────────────────────────────────────────────
146
-
147
- function fail(message, details) {
148
- process.stderr.write(`${message}\n`);
149
- if (details) {
150
- process.stderr.write(`${details}\n`);
151
- }
152
- process.exit(1);
153
- }
154
-
155
- function readRuntimeMetadata() {
156
- if (!existsSync(runtimeMetadataPath)) return null;
157
- try {
158
- return JSON.parse(readFileSync(runtimeMetadataPath, 'utf8'));
159
- } catch {
160
- return null;
161
- }
162
- }
163
-
164
- function ensureRuntimeInstalled() {
165
- if (!existsSync(daemonEntryPath)) {
166
- fail('The compiled daemon runtime is missing.', 'Run `pnpm build:release` before invoking `npx @vibelet/cli` from a source checkout.');
167
- }
168
-
169
- const sourceDaemonStat = statSync(daemonEntryPath);
170
- const runtimeMetadata = readRuntimeMetadata();
171
- const runtimeLooksFresh =
172
- existsSync(runtimeDaemonEntryPath) &&
173
- runtimeMetadata?.version === packageJson.version &&
174
- runtimeMetadata?.daemonEntryMtimeMs === sourceDaemonStat.mtimeMs;
175
-
176
- if (runtimeLooksFresh) {
177
- return;
178
- }
179
-
180
- mkdirSync(runtimeDir, { recursive: true });
181
- const nextRuntimeDir = join(runtimeDir, `current.${Date.now()}.${process.pid}`);
182
- rmSync(nextRuntimeDir, { recursive: true, force: true });
183
- mkdirSync(nextRuntimeDir, { recursive: true });
184
- mkdirSync(logDir, { recursive: true });
185
-
186
- writeFileSync(join(nextRuntimeDir, 'package.json'), JSON.stringify({
187
- name: 'vibelet-runtime',
188
- private: true,
189
- type: 'module',
190
- }, null, 2) + '\n', 'utf8');
191
-
192
- cpSync(daemonDistDir, join(nextRuntimeDir, 'dist'), {
193
- recursive: true,
194
- dereference: true,
195
- force: true,
196
- });
197
-
198
- writeFileSync(runtimeMetadataPath.replace(runtimeCurrentDir, nextRuntimeDir), JSON.stringify({
199
- version: packageJson.version,
200
- daemonEntryMtimeMs: sourceDaemonStat.mtimeMs,
201
- installedAt: new Date().toISOString(),
202
- }, null, 2) + '\n', 'utf8');
203
-
204
- rmSync(runtimeCurrentDir, { recursive: true, force: true });
205
- renameSync(nextRuntimeDir, runtimeCurrentDir);
206
- }
207
-
208
- // ─── PID file helpers ───────────────────────────────────────────────────────────
209
-
210
- function writePidFile(pid) {
211
- mkdirSync(dirname(pidFilePath), { recursive: true });
212
- writeFileSync(pidFilePath, String(pid), 'utf8');
213
- }
214
-
215
- function readPidFile() {
216
- try {
217
- const pid = Number(readFileSync(pidFilePath, 'utf8').trim());
218
- return Number.isFinite(pid) && pid > 0 ? pid : null;
219
- } catch {
220
- return null;
221
- }
222
- }
223
-
224
- function removePidFile() {
225
- rmSync(pidFilePath, { force: true });
226
- }
227
-
228
- function isProcessAlive(pid) {
229
- try {
230
- process.kill(pid, 0);
231
- return true;
232
- } catch {
233
- return false;
234
- }
235
- }
236
-
237
- // ─── Service PATH helpers ──────────────────────────────────────────────────────
238
-
239
- // Transient shims like fnm_multishells/<pid>/bin disappear or get GC'd after the
240
- // shell that created them exits. Long-lived services (launchd, systemd) that
241
- // inherit these paths end up spawning wrapper scripts whose `exec node` fails
242
- // with "node: not found". Stabilize to the underlying stable dir/file.
243
- function isTransientNodePath(p) {
244
- return typeof p === 'string' && p.includes('fnm_multishells');
245
- }
246
-
247
- function stabilizeServiceFile(p) {
248
- if (!p || !isTransientNodePath(p)) return p;
249
- try {
250
- const stable = join(realpathSync(dirname(p)), basename(p));
251
- if (existsSync(stable) && !isTransientNodePath(stable)) return stable;
252
- } catch { /* broken symlink */ }
253
- return p;
254
- }
255
-
256
- function stabilizeServiceDir(dir) {
257
- if (!dir || !isTransientNodePath(dir)) return dir;
258
- try {
259
- const real = realpathSync(dir);
260
- if (real && !isTransientNodePath(real)) return real;
261
- } catch { /* broken symlink */ }
262
- return dir;
263
- }
264
-
265
- // Build a PATH the daemon can rely on under launchd/systemd, where no login
266
- // shell ran and no rc files loaded. Puts the node bin dir first so wrapper
267
- // scripts spawned by the daemon (codex, claude) can resolve `node` via PATH,
268
- // then falls back to common system and user-tool locations.
269
- function buildServicePath(nodeBinDir) {
270
- const home = homedir();
271
- const pnpmHome = process.env.PNPM_HOME
272
- || (process.platform === 'win32' ? join(home, 'pnpm') : join(home, 'Library', 'pnpm'));
273
- const baseline = process.platform === 'win32'
274
- ? [
275
- nodeBinDir,
276
- join(home, '.npm-global', 'bin'),
277
- join(home, '.local', 'bin'),
278
- join(home, '.claude', 'bin'),
279
- pnpmHome,
280
- ]
281
- : [
282
- nodeBinDir,
283
- '/usr/local/bin',
284
- '/opt/homebrew/bin',
285
- '/usr/bin',
286
- '/bin',
287
- '/usr/sbin',
288
- '/sbin',
289
- join(home, '.npm-global', 'bin'),
290
- join(home, '.local', 'bin'),
291
- join(home, '.claude', 'bin'),
292
- pnpmHome,
293
- ];
294
- const current = (process.env.PATH || '')
295
- .split(delimiter)
296
- .filter(Boolean)
297
- .map(stabilizeServiceDir);
298
- const out = [];
299
- const seen = new Set();
300
- for (const p of [...baseline, ...current]) {
301
- if (p && !seen.has(p)) { out.push(p); seen.add(p); }
302
- }
303
- return out.join(delimiter);
304
- }
305
-
306
- // ─── Platform service backends ──────────────────────────────────────────────────
307
-
308
- function createDarwinBackend() {
309
- const label = 'dev.vibelet.daemon';
310
- const uid = process.getuid?.();
311
- const launchDomain = `gui/${uid ?? 0}`;
312
- const launchAgentsDir = join(homedir(), 'Library', 'LaunchAgents');
313
- const plistPath = join(launchAgentsDir, `${label}.plist`);
314
-
315
- function launchctl(args) {
316
- return spawnSync('launchctl', args, { encoding: 'utf8' });
317
- }
318
-
319
- function plistContents() {
320
- // Prefer a stable node path — fnm_multishells shims disappear when the
321
- // installing shell exits, leaving launchd unable to respawn the daemon.
322
- const nodeBin = stabilizeServiceFile(process.execPath);
323
- const programArgs = [
324
- nodeBin,
325
- runtimeDaemonEntryPath,
326
- ].map((value) => ` <string>${value}</string>`).join('\n');
327
-
328
- // launchd starts the daemon with a bare PATH; inject a rich one so child
329
- // CLIs like codex (a sh wrapper that does `exec node`) can find node.
330
- const envVars = {
331
- PATH: buildServicePath(dirname(nodeBin)),
332
- VIBE_PORT: String(port),
333
- };
334
- if (process.env.VIBELET_RELAY_URL) envVars.VIBELET_RELAY_URL = process.env.VIBELET_RELAY_URL;
335
- if (process.env.VIBELET_CANONICAL_HOST) envVars.VIBELET_CANONICAL_HOST = process.env.VIBELET_CANONICAL_HOST;
336
- if (process.env.VIBELET_FALLBACK_HOSTS) envVars.VIBELET_FALLBACK_HOSTS = process.env.VIBELET_FALLBACK_HOSTS;
337
- if (process.env.CODEX_PATH) envVars.CODEX_PATH = process.env.CODEX_PATH;
338
- if (process.env.CLAUDE_PATH) envVars.CLAUDE_PATH = process.env.CLAUDE_PATH;
339
- const envSection = Object.keys(envVars).length > 0
340
- ? ` <key>EnvironmentVariables</key>
341
- <dict>
342
- ${Object.entries(envVars).map(([k, v]) => ` <key>${k}</key>\n <string>${v}</string>`).join('\n')}
343
- </dict>`
344
- : '';
345
-
346
- return `<?xml version="1.0" encoding="UTF-8"?>
347
- <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
348
- <plist version="1.0">
349
- <dict>
350
- <key>Label</key>
351
- <string>${label}</string>
352
- <key>ProgramArguments</key>
353
- <array>
354
- ${programArgs}
355
- </array>
356
- <key>WorkingDirectory</key>
357
- <string>${runtimeCurrentDir}</string>
358
- <key>StandardOutPath</key>
359
- <string>${stdoutLogPath}</string>
360
- <key>StandardErrorPath</key>
361
- <string>${stderrLogPath}</string>
362
- ${envSection}
363
- <key>RunAtLoad</key>
364
- <true/>
365
- <key>KeepAlive</key>
366
- <true/>
367
- </dict>
368
- </plist>
369
- `;
370
- }
371
-
372
- return {
373
- name: 'launchd',
374
- handlesProcessLifecycle: true,
375
-
376
- isServiceInstalled() {
377
- return launchctl(['print', `${launchDomain}/${label}`]).status === 0;
378
- },
379
-
380
- install() {
381
- mkdirSync(launchAgentsDir, { recursive: true });
382
- mkdirSync(logDir, { recursive: true });
383
- const nextContents = plistContents();
384
- const currentContents = existsSync(plistPath) ? readFileSync(plistPath, 'utf8') : null;
385
- const serviceLoaded = this.isServiceInstalled();
386
- const changed = currentContents !== nextContents;
387
- if (changed) {
388
- writeFileSync(plistPath, nextContents, 'utf8');
389
- }
390
-
391
- if (changed && serviceLoaded) {
392
- launchctl(['bootout', `${launchDomain}/${label}`]);
393
- }
394
-
395
- if (changed || !serviceLoaded) {
396
- const result = launchctl(['bootstrap', launchDomain, plistPath]);
397
- if (result.status !== 0) {
398
- fail('Failed to bootstrap vibelet launch agent.', result.stderr || result.stdout);
399
- }
400
- }
401
- },
402
-
403
- start() {
404
- const result = launchctl(['kickstart', `${launchDomain}/${label}`]);
405
- if (result.status !== 0 && !this.isServiceInstalled()) {
406
- fail('Failed to start vibelet daemon.', result.stderr || result.stdout);
407
- }
408
- },
409
-
410
- stop() {
411
- if (!this.isServiceInstalled()) return;
412
- launchctl(['bootout', `${launchDomain}/${label}`]);
413
- // bootout returns before launchd actually finishes unloading. Poll
414
- // until the service is gone so a follow-up install() doesn't see a
415
- // stale registration, skip bootstrap, and leave the daemon down.
416
- const deadline = Date.now() + 5000;
417
- const wait = new Int32Array(new SharedArrayBuffer(4));
418
- while (Date.now() < deadline && this.isServiceInstalled()) {
419
- Atomics.wait(wait, 0, 0, 100);
420
- }
421
- },
422
-
423
- statusLabel() {
424
- return this.isServiceInstalled() ? 'loaded' : 'not loaded';
425
- },
426
- };
427
- }
428
-
429
- function createLinuxBackend() {
430
- const unitName = 'vibelet-daemon.service';
431
- const unitDir = join(homedir(), '.config', 'systemd', 'user');
432
- const unitPath = join(unitDir, unitName);
433
-
434
- function systemctl(args) {
435
- return spawnSync('systemctl', ['--user', ...args], { encoding: 'utf8' });
436
- }
437
-
438
- function resultOutput(result) {
439
- return [result?.stderr, result?.stdout].filter(Boolean).join('\n').trim();
440
- }
441
-
442
- let useSystemd = canUseSystemdUserManager();
443
-
444
- function demoteToDetachedIfSystemdUnavailable(result) {
445
- if (!isSystemdUserManagerUnavailable(resultOutput(result))) {
446
- return false;
447
- }
448
- useSystemd = false;
449
- return true;
450
- }
451
-
452
- function unitContents() {
453
- const nodeBin = stabilizeServiceFile(process.execPath);
454
- const servicePath = buildServicePath(dirname(nodeBin));
455
- return `[Unit]
456
- Description=Vibelet Daemon
457
- After=network.target
458
-
459
- [Service]
460
- ExecStart=${nodeBin} ${runtimeDaemonEntryPath}
461
- WorkingDirectory=${runtimeCurrentDir}
462
- Restart=always
463
- RestartSec=3
464
- StandardOutput=append:${stdoutLogPath}
465
- StandardError=append:${stderrLogPath}
466
- Environment=PATH=${servicePath}
467
- Environment=VIBE_PORT=${port}${process.env.VIBELET_RELAY_URL ? `\nEnvironment=VIBELET_RELAY_URL=${process.env.VIBELET_RELAY_URL}` : ''}${process.env.VIBELET_CANONICAL_HOST ? `\nEnvironment=VIBELET_CANONICAL_HOST=${process.env.VIBELET_CANONICAL_HOST}` : ''}${process.env.VIBELET_FALLBACK_HOSTS ? `\nEnvironment=VIBELET_FALLBACK_HOSTS=${process.env.VIBELET_FALLBACK_HOSTS}` : ''}
468
-
469
- [Install]
470
- WantedBy=default.target
471
- `;
472
- }
473
-
474
- // Fallback: detached process with PID file (no systemd)
475
- const fallback = createDetachedBackend();
476
-
477
- return {
478
- get name() {
479
- return useSystemd ? 'systemd' : 'detached';
480
- },
481
-
482
- get handlesProcessLifecycle() {
483
- return useSystemd;
484
- },
485
-
486
- isServiceInstalled() {
487
- if (!useSystemd) return fallback.isServiceInstalled();
488
- const result = systemctl(['is-enabled', unitName]);
489
- if (result.status !== 0 && demoteToDetachedIfSystemdUnavailable(result)) {
490
- return fallback.isServiceInstalled();
491
- }
492
- return result.status === 0;
493
- },
494
-
495
- install() {
496
- if (!useSystemd) {
497
- fallback.install();
498
- return;
499
- }
500
- mkdirSync(unitDir, { recursive: true });
501
- mkdirSync(logDir, { recursive: true });
502
- const nextContents = unitContents();
503
- const currentContents = existsSync(unitPath) ? readFileSync(unitPath, 'utf8') : null;
504
- if (currentContents !== nextContents) {
505
- writeFileSync(unitPath, nextContents, 'utf8');
506
- const reloadResult = systemctl(['daemon-reload']);
507
- if (reloadResult.status !== 0) {
508
- if (demoteToDetachedIfSystemdUnavailable(reloadResult)) {
509
- fallback.install();
510
- return;
511
- }
512
- fail('Failed to reload vibelet systemd user units.', resultOutput(reloadResult));
513
- }
514
- }
515
- const enableResult = systemctl(['enable', unitName]);
516
- if (enableResult.status !== 0) {
517
- if (demoteToDetachedIfSystemdUnavailable(enableResult)) {
518
- fallback.install();
519
- return;
520
- }
521
- fail('Failed to enable vibelet daemon.', resultOutput(enableResult));
522
- }
523
- },
524
-
525
- start() {
526
- if (!useSystemd) {
527
- fallback.start();
528
- return;
529
- }
530
- const result = systemctl(['start', unitName]);
531
- if (result.status !== 0) {
532
- if (demoteToDetachedIfSystemdUnavailable(result)) {
533
- fallback.install();
534
- fallback.start();
535
- return;
536
- }
537
- fail('Failed to start vibelet daemon.', result.stderr || result.stdout);
538
- }
539
- },
540
-
541
- stop() {
542
- if (!useSystemd) {
543
- fallback.stop();
544
- return;
545
- }
546
- const result = systemctl(['stop', unitName]);
547
- if (result.status !== 0 && demoteToDetachedIfSystemdUnavailable(result)) {
548
- fallback.stop();
549
- }
550
- },
551
-
552
- statusLabel() {
553
- if (!useSystemd) return fallback.statusLabel();
554
- const result = systemctl(['is-active', unitName]);
555
- if (result.status !== 0 && demoteToDetachedIfSystemdUnavailable(result)) {
556
- return fallback.statusLabel();
557
- }
558
- return result.stdout?.trim() || 'unknown';
559
- },
560
- };
561
- }
562
-
563
- function createDetachedBackend() {
564
- return {
565
- name: 'detached',
566
- handlesProcessLifecycle: false,
567
-
568
- isServiceInstalled() {
569
- const pid = readPidFile();
570
- return pid !== null && isProcessAlive(pid);
571
- },
572
-
573
- install() {
574
- mkdirSync(logDir, { recursive: true });
575
- },
576
-
577
- start() {
578
- if (this.isServiceInstalled()) return;
579
- const stdoutFd = openSync(stdoutLogPath, 'a');
580
- const stderrFd = openSync(stderrLogPath, 'a');
581
- const child = spawn(process.execPath, [runtimeDaemonEntryPath], {
582
- detached: true,
583
- stdio: ['ignore', stdoutFd, stderrFd],
584
- cwd: runtimeCurrentDir,
585
- env: { ...process.env, VIBE_PORT: String(port) },
586
- });
587
- child.unref();
588
- writePidFile(child.pid);
589
- },
590
-
591
- stop() {
592
- const pid = readPidFile();
593
- if (pid && isProcessAlive(pid)) {
594
- try {
595
- process.kill(pid, 'SIGTERM');
596
- } catch { /* already dead */ }
597
- }
598
- removePidFile();
599
- },
600
-
601
- statusLabel() {
602
- const pid = readPidFile();
603
- if (!pid) return 'not running';
604
- return isProcessAlive(pid) ? `running (pid ${pid})` : 'not running (stale pid)';
605
- },
606
- };
607
- }
608
-
609
- function createWindowsBackend() {
610
- // Windows: detached process with PID file
611
- // Node.js detached on Windows creates a new console window — use windowsHide
612
- return {
613
- name: 'detached',
614
- handlesProcessLifecycle: false,
615
-
616
- isServiceInstalled() {
617
- const pid = readPidFile();
618
- return pid !== null && isProcessAlive(pid);
619
- },
620
-
621
- install() {
622
- mkdirSync(logDir, { recursive: true });
623
- },
624
-
625
- start() {
626
- if (this.isServiceInstalled()) return;
627
- const stdoutFd = openSync(stdoutLogPath, 'a');
628
- const stderrFd = openSync(stderrLogPath, 'a');
629
- const child = spawn(process.execPath, [runtimeDaemonEntryPath], {
630
- detached: true,
631
- stdio: ['ignore', stdoutFd, stderrFd],
632
- cwd: runtimeCurrentDir,
633
- env: { ...process.env, VIBE_PORT: String(port) },
634
- windowsHide: true,
635
- });
636
- child.unref();
637
- writePidFile(child.pid);
638
- },
639
-
640
- stop() {
641
- const pid = readPidFile();
642
- if (pid && isProcessAlive(pid)) {
643
- try {
644
- process.kill(pid, 'SIGTERM');
645
- } catch { /* already dead */ }
646
- }
647
- removePidFile();
648
- },
649
-
650
- statusLabel() {
651
- const pid = readPidFile();
652
- if (!pid) return 'not running';
653
- return isProcessAlive(pid) ? `running (pid ${pid})` : 'not running (stale pid)';
654
- },
655
- };
656
- }
657
-
658
- function resolveBackend() {
659
- switch (process.platform) {
660
- case 'darwin': return createDarwinBackend();
661
- case 'linux': return createLinuxBackend();
662
- case 'win32': return createWindowsBackend();
663
- default: return createDetachedBackend();
664
- }
665
- }
666
-
667
- // ─── HTTP helpers ───────────────────────────────────────────────────────────────
668
-
669
- async function probeHealth(timeoutMs = 0) {
670
- const deadline = Date.now() + timeoutMs;
671
- do {
672
- try {
673
- const response = await fetch(`http://127.0.0.1:${port}/health`, {
674
- signal: AbortSignal.timeout(1500),
675
- });
676
- if (response.ok) {
677
- return await response.json();
678
- }
679
- } catch {
680
- // Retry until timeout.
681
- }
682
- if (timeoutMs <= 0) break;
683
- await new Promise((resolvePromise) => setTimeout(resolvePromise, 250));
684
- } while (Date.now() < deadline);
685
- return null;
686
- }
687
-
688
- async function waitForDaemonExit(timeoutMs) {
689
- const deadline = Date.now() + timeoutMs;
690
- while (Date.now() < deadline) {
691
- const health = await probeHealth(0);
692
- if (!health) return true;
693
- await new Promise((resolvePromise) => setTimeout(resolvePromise, 200));
694
- }
695
- return false;
696
- }
697
-
698
- async function waitForHealth(timeoutMs = 30_000) {
699
- const health = await probeHealth(timeoutMs);
700
- if (health) {
701
- return health;
702
- }
703
- fail(`Timed out waiting for vibelet daemon on port ${port}.`);
704
- }
705
-
706
- async function postJson(pathname, body = undefined) {
707
- const response = await fetch(`http://127.0.0.1:${port}${pathname}`, {
708
- method: 'POST',
709
- headers: body ? { 'Content-Type': 'application/json' } : undefined,
710
- body: body ? JSON.stringify(body) : undefined,
711
- });
712
- const payload = await response.json().catch(() => ({}));
713
- if (!response.ok) {
714
- fail(`Request to ${pathname} failed.`, JSON.stringify(payload, null, 2));
715
- }
716
- return payload;
717
- }
718
-
719
- async function requestShutdown() {
720
- try {
721
- await fetch(`http://127.0.0.1:${port}/shutdown`, {
722
- method: 'POST',
723
- signal: AbortSignal.timeout(2000),
724
- });
725
- } catch {
726
- // Request timed out or connection refused — daemon may be deadlocked or
727
- // already gone. Either way, fall through to polling for actual exit.
728
- }
729
- return waitForDaemonExit(5_000);
730
- }
731
-
732
- function isDaemonStartCommand(command) {
733
- return command === 'default' || command === 'start' || command === 'restart' || command === 'reset';
734
- }
735
-
736
- async function stopRunningDaemon(backend) {
737
- const gracefullyStopped = await requestShutdown();
738
- backend.stop();
739
- if (gracefullyStopped) return;
740
- const died = await waitForDaemonExit(5_000);
741
- if (!died) {
742
- fail('Daemon did not stop in time.');
743
- }
744
- }
745
-
746
- function createCompactPairingPayload(pairingPayload) {
747
- const connections = normalizePairingConnections(pairingPayload);
748
- const compactPayload = {
749
- t: 'vp',
750
- d: pairingPayload.daemonId,
751
- n: pairingPayload.displayName,
752
- h: pairingPayload.canonicalHost,
753
- p: pairingPayload.port,
754
- c: pairingPayload.pairNonce,
755
- e: pairingPayload.expiresAt,
756
- };
757
- if (pairingPayload.fallbackHosts) compactPayload.f = pairingPayload.fallbackHosts;
758
- if (connections.length > 0) {
759
- compactPayload.o = connections.map((target) => [
760
- target.kind,
761
- target.host,
762
- target.port,
763
- target.source,
764
- target.stability,
765
- target.secure === true,
766
- ]);
767
- }
768
- return compactPayload;
769
- }
770
-
771
- function normalizeHostValue(host) {
772
- if (typeof host !== 'string') {
773
- return '';
774
- }
775
- return host.trim().replace(/\.$/, '').toLowerCase();
776
- }
777
-
778
- function isIpv4Host(host) {
779
- const parts = host.split('.');
780
- return parts.length === 4 && parts.every((part) => /^\d+$/.test(part) && Number(part) >= 0 && Number(part) <= 255);
781
- }
782
-
783
- function isTailscaleHost(host) {
784
- if (host.endsWith('.ts.net')) {
785
- return true;
786
- }
787
- if (!isIpv4Host(host)) {
788
- return false;
789
- }
790
- const [first, second] = host.split('.').map(Number);
791
- return first === 100 && second >= 64 && second <= 127;
792
- }
793
-
794
- function isLocalNetworkHost(host) {
795
- if (host.endsWith('.local')) {
796
- return true;
797
- }
798
- if (!isIpv4Host(host)) {
799
- return false;
800
- }
801
- const [first, second] = host.split('.').map(Number);
802
- return first === 10
803
- || (first === 172 && second >= 16 && second <= 31)
804
- || (first === 192 && second === 168);
805
- }
806
-
807
- function isQuickTunnelHost(host) {
808
- return host.endsWith('.trycloudflare.com');
809
- }
810
-
811
- function buildLegacyConnection(host, port, isPrimary) {
812
- if (isQuickTunnelHost(host)) {
813
- return {
814
- kind: 'relay',
815
- host,
816
- port,
817
- source: 'quick_tunnel',
818
- stability: 'ephemeral',
819
- };
820
- }
821
- if (isTailscaleHost(host)) {
822
- return {
823
- kind: 'direct',
824
- host,
825
- port,
826
- source: 'tailscale',
827
- stability: 'stable',
828
- };
829
- }
830
- if (isLocalNetworkHost(host)) {
831
- return {
832
- kind: 'direct',
833
- host,
834
- port,
835
- source: 'local_network',
836
- stability: 'stable',
837
- };
838
- }
839
- return {
840
- kind: 'direct',
841
- host,
842
- port,
843
- source: isPrimary ? 'configured_host' : 'fallback',
844
- stability: 'stable',
845
- };
846
- }
847
-
848
- function normalizePairingConnections(pairingPayload) {
849
- const explicitConnections = Array.isArray(pairingPayload.connections)
850
- ? pairingPayload.connections
851
- .filter((target) => (
852
- target
853
- && typeof target.host === 'string'
854
- && Number.isFinite(target.port)
855
- && typeof target.source === 'string'
856
- && typeof target.stability === 'string'
857
- ))
858
- .map((target) => ({
859
- kind: target.kind === 'relay' ? 'relay' : 'direct',
860
- host: normalizeHostValue(target.host),
861
- port: Math.floor(Number(target.port)),
862
- ...(target.secure === true ? { secure: true } : {}),
863
- source: target.source,
864
- stability: target.stability,
865
- }))
866
- .filter((target) => target.host && target.port > 0)
867
- : [];
868
-
869
- const dedupe = (targets) => {
870
- const seen = new Set();
871
- return targets.filter((target) => {
872
- const key = `${target.host}:${target.port}:${target.secure === true ? 'secure' : 'plain'}`;
873
- if (seen.has(key)) {
874
- return false;
875
- }
876
- seen.add(key);
877
- return true;
878
- });
879
- };
880
-
881
- if (explicitConnections.length > 0) {
882
- return dedupe(explicitConnections);
883
- }
884
-
885
- const canonicalHost = normalizeHostValue(pairingPayload.canonicalHost) || 'localhost';
886
- const port = Number.isFinite(pairingPayload.port) && pairingPayload.port > 0
887
- ? Math.floor(Number(pairingPayload.port))
888
- : 9876;
889
- const fallbackHosts = Array.isArray(pairingPayload.fallbackHosts)
890
- ? pairingPayload.fallbackHosts
891
- .map((host) => normalizeHostValue(host))
892
- .filter((host) => host && host !== canonicalHost)
893
- : [];
894
-
895
- return dedupe([
896
- buildLegacyConnection(canonicalHost, port, true),
897
- ...fallbackHosts.map((host) => buildLegacyConnection(host, port, false)),
898
- ]);
899
- }
900
-
901
- function formatConnectionSourceLabel(source) {
902
- switch (source) {
903
- case 'configured_relay':
904
- return 'Custom Relay';
905
- case 'quick_tunnel':
906
- return 'Quick Tunnel';
907
- case 'configured_host':
908
- return 'Manual Host';
909
- case 'tailscale':
910
- return 'Tailscale';
911
- case 'local_network':
912
- return 'LAN';
913
- case 'fallback':
914
- default:
915
- return 'Fallback';
916
- }
917
- }
918
-
919
- function formatConnectionSummary(target) {
920
- const stabilityLabel = target.stability === 'ephemeral' ? 'ephemeral' : 'stable';
921
- return `${target.host}:${target.port} [${formatConnectionSourceLabel(target.source)}, ${stabilityLabel}]`;
922
- }
923
-
924
- async function printPairingQr(pairingPayload) {
925
- const payload = JSON.stringify(createCompactPairingPayload(pairingPayload));
926
- mkdirSync(vibeletDir, { recursive: true });
927
- await QRCode.toFile(pairingQrPngPath, payload, {
928
- type: 'png',
929
- errorCorrectionLevel: 'M',
930
- margin: 1,
931
- scale: 8,
932
- });
933
- const qr = await QRCode.toString(payload, {
934
- type: 'terminal',
935
- small: true,
936
- errorCorrectionLevel: 'M',
937
- });
938
- process.stdout.write(`\nScan this QR code with the Vibelet app:\n\n${qr}\n`);
939
- process.stdout.write(`If Vibelet app can't scan the terminal QR, open this PNG instead:\n${pairingQrPngPath}\n`);
940
- }
941
-
942
- // ─── Commands ───────────────────────────────────────────────────────────────────
943
-
944
- async function printPairingSummary(existingHealth = null) {
945
- const health = existingHealth ?? await waitForHealth();
946
- const pairingPayload = await postJson('/pair/open');
947
- const connections = normalizePairingConnections(pairingPayload);
948
- const [preferredConnection, ...otherConnections] = connections;
949
-
950
- process.stdout.write(`Vibelet daemon is ready.\n\n`);
951
- process.stdout.write(`Device: ${health.displayName}\n`);
952
- process.stdout.write(`Daemon ID: ${health.daemonId}\n`);
953
- process.stdout.write(`Host: ${pairingPayload.canonicalHost}\n`);
954
- process.stdout.write(`Port: ${pairingPayload.port}\n`);
955
- if (preferredConnection) {
956
- process.stdout.write(`Preferred path: ${formatConnectionSummary(preferredConnection)}\n`);
957
- }
958
- if (otherConnections.length > 0) {
959
- process.stdout.write(`Other paths:\n`);
960
- otherConnections.forEach((target) => {
961
- process.stdout.write(` - ${formatConnectionSummary(target)}\n`);
962
- });
963
- }
964
- process.stdout.write(`Paired devices: ${health.pairedDevices}\n`);
965
- await printPairingQr(pairingPayload);
966
- }
967
-
968
- function printHelp() {
969
- process.stdout.write(`Vibelet ${packageJson.version}\n\n`);
970
- process.stdout.write(`Package names:\n`);
971
- process.stdout.write(` @vibelet/cli\n`);
972
- process.stdout.write(` vibelet\n\n`);
973
- process.stdout.write(`Usage:\n`);
974
- process.stdout.write(` npx ${packageJson.name} Install/start the daemon, auto-enable remote access, and print a pairing QR code\n`);
975
- process.stdout.write(` npx ${packageJson.name} start Same as above\n`);
976
- process.stdout.write(` npx ${packageJson.name} --local Skip the default Cloudflare Tunnel for this run\n`);
977
- process.stdout.write(` npx ${packageJson.name} --force Force a new Cloudflare Tunnel URL\n`);
978
- process.stdout.write(` npx ${packageJson.name} --relay <url> Use a custom tunnel URL for remote access\n`);
979
- process.stdout.write(` npx ${packageJson.name} --host <ip> Set the primary host/IP address\n`);
980
- process.stdout.write(` npx ${packageJson.name} --port <port> Start or query the daemon on a custom port\n`);
981
- process.stdout.write(` npx ${packageJson.name} --fallback-hosts <ips> Comma-separated fallback IPs\n`);
982
- process.stdout.write(` npx ${packageJson.name} stop Stop the daemon\n`);
983
- process.stdout.write(` npx ${packageJson.name} restart Restart the daemon\n`);
984
- process.stdout.write(` npx ${packageJson.name} status Show service and daemon status\n`);
985
- process.stdout.write(` npx ${packageJson.name} logs Print recent daemon logs\n`);
986
- process.stdout.write(` npx ${packageJson.name} reset Reset pairings and print a fresh QR code\n`);
987
- process.stdout.write(` npx ${packageJson.name} --help Show this help text\n`);
988
- process.stdout.write(` npx ${packageJson.name} --version Show the installed CLI version\n`);
989
- process.stdout.write(`\n`);
990
- process.stdout.write(`You can also invoke the published alias with:\n`);
991
- process.stdout.write(` npx ${packageJson.name === 'vibelet' ? '@vibelet/cli' : 'vibelet'}\n`);
992
- process.stdout.write(` vibelet\n\n`);
993
- process.stdout.write(`Remote access:\n`);
994
- process.stdout.write(` # Remote access is on by default for start/reset/restart\n`);
995
- process.stdout.write(` npx ${packageJson.name}\n\n`);
996
- process.stdout.write(` # Want LAN-only pairing for this run?\n`);
997
- process.stdout.write(` npx ${packageJson.name} --local\n\n`);
998
- process.stdout.write(` # Need a fresh Cloudflare Tunnel URL?\n`);
999
- process.stdout.write(` npx ${packageJson.name} --force\n\n`);
1000
- process.stdout.write(` # Or bring your own tunnel and pass the URL manually:\n`);
1001
- process.stdout.write(` npx cloudflared tunnel --protocol http2 --url http://localhost:${port}\n`);
1002
- process.stdout.write(` ngrok http ${port}\n`);
1003
- process.stdout.write(` npx ${packageJson.name} --relay=https://<your-tunnel-url>\n\n`);
1004
- process.stdout.write(` # Tailscale (P2P VPN, no tunnel needed)\n`);
1005
- process.stdout.write(` npx ${packageJson.name} --host=<tailscale-ip>\n`);
1006
- }
1007
-
1008
- function consumeFlag(name) {
1009
- const idx = process.argv.indexOf(`--${name}`);
1010
- if (idx === -1) return false;
1011
- process.argv.splice(idx, 1);
1012
- return true;
1013
- }
1014
-
1015
- function readNpmConfigFlag(name) {
1016
- const value = process.env[`npm_config_${name.replace(/-/g, '_')}`];
1017
- return value === '' || value === 'true';
1018
- }
1019
-
1020
- function parseNamedArg(name, errorHint) {
1021
- const inlinePrefix = `--${name}=`;
1022
- const inlineArg = process.argv.find((arg) => arg.startsWith(inlinePrefix));
1023
- if (inlineArg) {
1024
- const idx = process.argv.indexOf(inlineArg);
1025
- process.argv.splice(idx, 1);
1026
- return inlineArg.slice(inlinePrefix.length);
1027
- }
1028
-
1029
- const idx = process.argv.indexOf(`--${name}`);
1030
- if (idx === -1) return null;
1031
- const value = process.argv[idx + 1];
1032
- if (!value || value.startsWith('-')) {
1033
- fail(`--${name} requires an argument${errorHint ? ` (e.g. --${name} ${errorHint})` : ''}`);
1034
- }
1035
- process.argv.splice(idx, 2);
1036
- return value;
1037
- }
1038
-
1039
- function parseRelayArg() {
1040
- return parseNamedArg('relay', 'https://abc.trycloudflare.com');
1041
- }
1042
-
1043
- function parsePortArg() {
1044
- const rawValue = parseNamedArg('port', '9876');
1045
- if (rawValue === null) return null;
1046
- const parsed = normalizePortValue(rawValue);
1047
- if (parsed === null) {
1048
- fail('--port must be an integer between 1 and 65535');
1049
- }
1050
- return parsed;
1051
- }
1052
-
1053
- function loadRelayConfig() {
1054
- try {
1055
- const data = JSON.parse(readFileSync(relayConfigPath, 'utf8'));
1056
- return data.relayUrl || '';
1057
- } catch {
1058
- return '';
1059
- }
1060
- }
1061
-
1062
- function saveRelayConfig(relayUrl) {
1063
- mkdirSync(vibeletDir, { recursive: true });
1064
- writeFileSync(relayConfigPath, JSON.stringify({ relayUrl }, null, 2) + '\n', 'utf8');
1065
- }
1066
-
1067
- function clearRelayConfig() {
1068
- rmSync(relayConfigPath, { force: true });
1069
- }
1070
-
1071
- // ─── Tunnel management ──────────────────────────────────────────────────────────
1072
-
1073
- function loadTunnelState() {
1074
- try {
1075
- return JSON.parse(readFileSync(tunnelStatePath, 'utf8'));
1076
- } catch {
1077
- return null;
1078
- }
1079
- }
1080
-
1081
- function saveTunnelState(pid, url) {
1082
- mkdirSync(vibeletDir, { recursive: true });
1083
- writeFileSync(tunnelStatePath, JSON.stringify({ pid, url }, null, 2) + '\n', 'utf8');
1084
- }
1085
-
1086
- function clearTunnelState() {
1087
- rmSync(tunnelStatePath, { force: true });
1088
- }
1089
-
1090
- function stopTunnel() {
1091
- const state = loadTunnelState();
1092
- if (state?.pid && isProcessAlive(state.pid)) {
1093
- try { process.kill(state.pid, 'SIGTERM'); } catch { /* already dead */ }
1094
- }
1095
- clearTunnelState();
1096
- }
1097
-
1098
- function getAliveTunnel() {
1099
- const state = loadTunnelState();
1100
- if (state?.pid && state?.url && isProcessAlive(state.pid)) {
1101
- return state;
1102
- }
1103
- return null;
1104
- }
1105
-
1106
- function readCloudflaredLog(logPath) {
1107
- try {
1108
- return readFileSync(logPath, 'utf8');
1109
- } catch {
1110
- return '';
1111
- }
1112
- }
1113
-
1114
- function startTunnel() {
1115
- return new Promise((resolve, reject) => {
1116
- const logPath = join(logDir, 'tunnel.stderr.log');
1117
- mkdirSync(logDir, { recursive: true });
1118
-
1119
- const launchSpec = resolveCloudflaredLaunchSpec();
1120
-
1121
- // Strategy: start cloudflared with output to log files (so it survives detach),
1122
- // then tail the log to capture the URL.
1123
- // Truncate log so we don't match a stale URL from a previous run.
1124
- writeFileSync(logPath, '', 'utf8');
1125
- const logFd = openSync(logPath, 'a');
1126
- const child = spawn(launchSpec.command, [
1127
- ...launchSpec.args,
1128
- 'tunnel',
1129
- '--protocol',
1130
- 'http2',
1131
- '--url',
1132
- `http://localhost:${port}`,
1133
- ], {
1134
- detached: true,
1135
- stdio: ['ignore', logFd, logFd],
1136
- });
1137
- child.unref();
1138
-
1139
- const pid = child.pid;
1140
- let url = null;
1141
-
1142
- const timeout = setTimeout(() => {
1143
- if (!url) {
1144
- try { process.kill(pid, 'SIGTERM'); } catch { /* */ }
1145
- reject(new Error(formatCloudflaredFailureMessage({
1146
- launchSpec,
1147
- logContent: readCloudflaredLog(logPath),
1148
- logPath,
1149
- phase: 'timeout',
1150
- })));
1151
- }
1152
- }, launchSpec.urlTimeoutMs);
1153
-
1154
- // Poll the log file for the tunnel URL
1155
- const poll = setInterval(() => {
1156
- try {
1157
- const content = readFileSync(logPath, 'utf8');
1158
- const tunnelUrl = extractQuickTunnelUrl(content);
1159
- if (tunnelUrl) {
1160
- url = tunnelUrl;
1161
- clearInterval(poll);
1162
- clearTimeout(timeout);
1163
- saveTunnelState(pid, url);
1164
- resolve({ pid, url });
1165
- return;
1166
- }
1167
- // Check if process died before producing URL
1168
- if (!isProcessAlive(pid)) {
1169
- clearInterval(poll);
1170
- clearTimeout(timeout);
1171
- reject(new Error(formatCloudflaredFailureMessage({
1172
- launchSpec,
1173
- logContent: content,
1174
- logPath,
1175
- phase: 'exit',
1176
- })));
1177
- }
1178
- } catch { /* file not ready yet */ }
1179
- }, 300);
1180
-
1181
- child.on('error', (err) => {
1182
- clearInterval(poll);
1183
- clearTimeout(timeout);
1184
- reject(new Error(formatCloudflaredFailureMessage({
1185
- launchSpec,
1186
- logContent: readCloudflaredLog(logPath),
1187
- logPath,
1188
- err,
1189
- phase: 'spawn',
1190
- })));
1191
- });
1192
- });
1193
- }
1194
-
1195
- async function main() {
1196
- // Update check: read cached result (sync, instant) and spawn background fetch.
1197
- checkForUpdateFromCache();
1198
- fetchLatestVersionInBackground();
1199
-
1200
- const portArg = parsePortArg();
1201
- if (portArg !== null) {
1202
- port = portArg;
1203
- process.env.VIBE_PORT = String(portArg);
1204
- }
1205
-
1206
- consumeFlag('remote') || consumeFlag('tunnel') || readNpmConfigFlag('remote') || readNpmConfigFlag('tunnel');
1207
- const localFlag = consumeFlag('local') || readNpmConfigFlag('local');
1208
- const forceFlag = consumeFlag('force') || readNpmConfigFlag('force');
1209
- const relayArg = parseRelayArg();
1210
- const hostArg = parseNamedArg('host', '100.x.x.x');
1211
- const fallbackHostsArg = parseNamedArg('fallback-hosts', '100.x.x.x,192.168.1.x');
1212
- const command = process.argv[2] ?? 'default';
1213
- const startCommand = isDaemonStartCommand(command);
1214
- // --remote/--tunnel remain accepted for compatibility, but startup commands
1215
- // now default to managed remote access unless another connection target wins.
1216
- const shouldManageTunnel = startCommand
1217
- && !localFlag
1218
- && relayArg === null
1219
- && !hostArg
1220
- && !fallbackHostsArg;
1221
-
1222
- if (command === '--help' || command === '-h' || command === 'help') {
1223
- printHelp();
1224
- return;
1225
- }
1226
-
1227
- if (command === '--version' || command === '-v' || command === 'version') {
1228
- process.stdout.write(`${packageJson.version}\n`);
1229
- return;
1230
- }
1231
-
1232
- if (shouldManageTunnel) {
1233
- const existing = forceFlag ? null : getAliveTunnel();
1234
- if (existing) {
1235
- process.stdout.write(`Reusing tunnel: ${existing.url} (pid ${existing.pid})\n`);
1236
- saveRelayConfig(existing.url);
1237
- } else {
1238
- if (forceFlag) stopTunnel();
1239
- process.stdout.write('Starting Cloudflare Tunnel...\n');
1240
- try {
1241
- const tunnel = await startTunnel();
1242
- process.stdout.write(`Tunnel ready: ${tunnel.url}\n`);
1243
- saveRelayConfig(tunnel.url);
1244
- } catch (err) {
1245
- fail(err.message);
1246
- }
1247
- }
1248
- }
1249
-
1250
- // --relay "" clears saved relay; --relay <url> saves it; omitted uses saved value
1251
- if (relayArg !== null) {
1252
- if (relayArg) {
1253
- saveRelayConfig(relayArg);
1254
- } else {
1255
- clearRelayConfig();
1256
- }
1257
- }
1258
- const shouldIgnoreSavedRelay = relayArg === null && (localFlag || Boolean(hostArg) || Boolean(fallbackHostsArg));
1259
- const relayUrl = relayArg !== null
1260
- ? relayArg
1261
- : (shouldIgnoreSavedRelay ? '' : loadRelayConfig());
1262
- if (relayUrl) {
1263
- process.env.VIBELET_RELAY_URL = relayUrl;
1264
- } else {
1265
- delete process.env.VIBELET_RELAY_URL;
1266
- }
1267
- if (hostArg) {
1268
- process.env.VIBELET_CANONICAL_HOST = hostArg;
1269
- } else {
1270
- delete process.env.VIBELET_CANONICAL_HOST;
1271
- }
1272
- if (fallbackHostsArg) {
1273
- process.env.VIBELET_FALLBACK_HOSTS = fallbackHostsArg;
1274
- } else {
1275
- delete process.env.VIBELET_FALLBACK_HOSTS;
1276
- }
1277
- const backend = resolveBackend();
1278
-
1279
- if (command === 'stop') {
1280
- process.stdout.write('Stopping vibelet daemon...\n');
1281
- // Always try graceful HTTP shutdown first — gives the daemon time to
1282
- // close sessions and flush logs before the service manager kills it.
1283
- await stopRunningDaemon(backend);
1284
- // Also stop tunnel if running
1285
- const tunnelState = getAliveTunnel();
1286
- if (tunnelState) {
1287
- stopTunnel();
1288
- process.stdout.write('Tunnel stopped.\n');
1289
- }
1290
- process.stdout.write('Daemon stopped.\n');
1291
- return;
1292
- }
1293
-
1294
- if (command === 'status') {
1295
- process.stdout.write(`Service (${backend.name}): ${backend.statusLabel()}\n`);
1296
- process.stdout.write(`Runtime: ${existsSync(runtimeDaemonEntryPath) ? runtimeDaemonEntryPath : 'not installed'}\n`);
1297
- const tunnelState = getAliveTunnel();
1298
- if (tunnelState) {
1299
- process.stdout.write(`Tunnel: ${tunnelState.url} (pid ${tunnelState.pid})\n`);
1300
- }
1301
- const savedRelay = loadRelayConfig();
1302
- if (savedRelay) {
1303
- process.stdout.write(`Relay: ${savedRelay}\n`);
1304
- }
1305
- const health = await probeHealth(1_500);
1306
- if (health) {
1307
- process.stdout.write(JSON.stringify(health, null, 2) + '\n');
1308
- } else {
1309
- process.stdout.write('Daemon is not responding.\n');
1310
- }
1311
- return;
1312
- }
1313
-
1314
- if (command === 'logs') {
1315
- process.stdout.write(`Stdout: ${stdoutLogPath}\n`);
1316
- process.stdout.write(`Stderr: ${stderrLogPath}\n\n`);
1317
- const logFiles = [stdoutLogPath, stderrLogPath].filter((filePath) => existsSync(filePath));
1318
- if (logFiles.length === 0) {
1319
- process.stdout.write('No daemon logs have been written yet.\n');
1320
- return;
1321
- }
1322
- if (process.platform === 'win32') {
1323
- for (const logFile of logFiles) {
1324
- const content = readFileSync(logFile, 'utf8');
1325
- const lines = content.split('\n').slice(-80).join('\n');
1326
- process.stdout.write(`==> ${logFile} <==\n${lines}\n`);
1327
- }
1328
- } else {
1329
- const result = spawnSync('tail', ['-n', '80', ...logFiles], { encoding: 'utf8' });
1330
- if (result.stdout) process.stdout.write(result.stdout);
1331
- if (result.stderr) process.stderr.write(result.stderr);
1332
- }
1333
- return;
1334
- }
1335
-
1336
- if (command === 'restart') {
1337
- process.stdout.write('Restarting vibelet daemon...\n');
1338
- await stopRunningDaemon(backend);
1339
- process.stdout.write('Daemon stopped. Starting...\n');
1340
- ensureRuntimeInstalled();
1341
- backend.install();
1342
- backend.start();
1343
- await printPairingSummary();
1344
- return;
1345
- }
1346
-
1347
- if (command === 'reset') {
1348
- const healthyDaemon = await probeHealth(1_500);
1349
- const hasExplicitConfigOverrides = !doesHealthMatchRequestedConnectionConfig({
1350
- health: healthyDaemon,
1351
- relayUrl,
1352
- canonicalHost: hostArg || '',
1353
- fallbackHosts: fallbackHostsArg || '',
1354
- localMode: localFlag,
1355
- }) && (localFlag || Boolean(relayUrl) || Boolean(hostArg) || Boolean(fallbackHostsArg));
1356
- const shouldReplaceDetachedDaemon = !healthyDaemon && !backend.handlesProcessLifecycle && backend.isServiceInstalled();
1357
- if (shouldReplaceDetachedDaemon || (healthyDaemon && hasExplicitConfigOverrides)) {
1358
- await stopRunningDaemon(backend);
1359
- }
1360
- ensureRuntimeInstalled();
1361
- backend.install();
1362
- backend.start();
1363
- await waitForHealth();
1364
- await postJson('/pair/reset');
1365
- await printPairingSummary();
1366
- return;
1367
- }
1368
-
1369
- if (command !== 'default' && command !== 'start') {
1370
- printHelp();
1371
- fail(`Unknown command: ${command}`);
1372
- }
1373
-
1374
- const healthyDaemon = await probeHealth(1_500);
1375
- const hasExplicitConfigOverrides = !doesHealthMatchRequestedConnectionConfig({
1376
- health: healthyDaemon,
1377
- relayUrl,
1378
- canonicalHost: hostArg || '',
1379
- fallbackHosts: fallbackHostsArg || '',
1380
- localMode: localFlag,
1381
- }) && (localFlag || Boolean(relayUrl) || Boolean(hostArg) || Boolean(fallbackHostsArg));
1382
- const existingHealth = shouldReuseHealthyDaemon({
1383
- command,
1384
- daemonHealthy: Boolean(healthyDaemon),
1385
- hasExplicitConfigOverrides,
1386
- }) ? healthyDaemon : null;
1387
-
1388
- if (existingHealth) {
1389
- process.stdout.write('Vibelet daemon is already running.\n');
1390
- process.stdout.write('Reusing the current runtime so active sessions stay alive.\n');
1391
- process.stdout.write('Run `npx vibelet restart` to force a full restart.\n\n');
1392
- await printPairingSummary(existingHealth);
1393
- return;
1394
- }
1395
-
1396
- const shouldReplaceDetachedDaemon = !healthyDaemon && !backend.handlesProcessLifecycle && backend.isServiceInstalled();
1397
- if (shouldReplaceDetachedDaemon || (healthyDaemon && hasExplicitConfigOverrides)) {
1398
- await stopRunningDaemon(backend);
1399
- }
1400
-
1401
- ensureRuntimeInstalled();
1402
- backend.install();
1403
- backend.start();
1404
- await printPairingSummary();
1405
- }
1406
-
1407
- process.on('SIGINT', () => process.exit(0));
1408
- process.on('SIGTERM', () => process.exit(0));
1409
- process.on('exit', printOfficialSite);
1410
- await main();
1411
- // On Windows + Node 24, process.exit(0) can trigger a libuv assertion
1412
- // (UV_HANDLE_CLOSING) when detached child handles haven't fully released.
1413
- // Letting the event loop drain naturally avoids this.
1414
- if (process.platform !== 'win32') process.exit(0);