vibelet 1.0.10 → 1.0.12

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 CHANGED
@@ -1,13 +1,14 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  import { spawn, spawnSync } from 'node:child_process';
4
- import { cpSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync, openSync, writeSync } from 'node:fs';
4
+ import { cpSync, existsSync, mkdirSync, readFileSync, realpathSync, renameSync, rmSync, statSync, writeFileSync, openSync, writeSync } from 'node:fs';
5
5
  import { homedir } from 'node:os';
6
- import { dirname, join, resolve } from 'node:path';
6
+ import { basename, delimiter, dirname, join, resolve } from 'node:path';
7
7
  import { fileURLToPath } from 'node:url';
8
8
  import QRCode from 'qrcode';
9
9
  import { extractQuickTunnelUrl } from './cloudflared-quick-tunnel.mjs';
10
10
  import { formatCloudflaredFailureMessage, resolveCloudflaredLaunchSpec } from './cloudflared-resolver.mjs';
11
+ import { canUseSystemdUserManager, isSystemdUserManagerUnavailable } from './linux-systemd.mjs';
11
12
  import { doesHealthMatchRequestedConnectionConfig, shouldReuseHealthyDaemon } from './vibelet-runtime-policy.mjs';
12
13
 
13
14
  // ─── Paths & constants ─────────────────────────────────────────────────────────
@@ -16,7 +17,6 @@ const rootDir = resolve(dirname(fileURLToPath(import.meta.url)), '..');
16
17
  const packageJson = JSON.parse(readFileSync(join(rootDir, 'package.json'), 'utf8'));
17
18
  const daemonDistDir = resolve(rootDir, 'dist');
18
19
  const daemonEntryPath = resolve(daemonDistDir, 'index.cjs');
19
- const port = Number(process.env.VIBE_PORT) || 9876;
20
20
  const vibeletDir = join(homedir(), '.vibelet');
21
21
  const pairingQrPngPath = join(vibeletDir, 'pairing-qr.png');
22
22
  const logDir = join(vibeletDir, 'logs');
@@ -35,6 +35,22 @@ const UPDATE_CHECK_INTERVAL_MS = 4 * 60 * 60 * 1000; // 4 hours
35
35
 
36
36
  let officialSitePrinted = false;
37
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
+ }
38
54
 
39
55
  function printOfficialSite() {
40
56
  if (officialSitePrinted) return;
@@ -147,7 +163,7 @@ function readRuntimeMetadata() {
147
163
 
148
164
  function ensureRuntimeInstalled() {
149
165
  if (!existsSync(daemonEntryPath)) {
150
- fail('The compiled daemon runtime is missing.', 'Run `pnpm build` before invoking `npx @vibelet/cli` from a source checkout.');
166
+ fail('The compiled daemon runtime is missing.', 'Run `pnpm build:release` before invoking `npx @vibelet/cli` from a source checkout.');
151
167
  }
152
168
 
153
169
  const sourceDaemonStat = statSync(daemonEntryPath);
@@ -218,6 +234,75 @@ function isProcessAlive(pid) {
218
234
  }
219
235
  }
220
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
+
221
306
  // ─── Platform service backends ──────────────────────────────────────────────────
222
307
 
223
308
  function createDarwinBackend() {
@@ -232,12 +317,20 @@ function createDarwinBackend() {
232
317
  }
233
318
 
234
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);
235
323
  const programArgs = [
236
- process.execPath,
324
+ nodeBin,
237
325
  runtimeDaemonEntryPath,
238
326
  ].map((value) => ` <string>${value}</string>`).join('\n');
239
327
 
240
- const envVars = { VIBE_PORT: String(port) };
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
+ };
241
334
  if (process.env.VIBELET_RELAY_URL) envVars.VIBELET_RELAY_URL = process.env.VIBELET_RELAY_URL;
242
335
  if (process.env.VIBELET_CANONICAL_HOST) envVars.VIBELET_CANONICAL_HOST = process.env.VIBELET_CANONICAL_HOST;
243
336
  if (process.env.VIBELET_FALLBACK_HOSTS) envVars.VIBELET_FALLBACK_HOSTS = process.env.VIBELET_FALLBACK_HOSTS;
@@ -342,22 +435,35 @@ function createLinuxBackend() {
342
435
  return spawnSync('systemctl', ['--user', ...args], { encoding: 'utf8' });
343
436
  }
344
437
 
345
- function hasSystemd() {
346
- return spawnSync('systemctl', ['--user', '--version'], { encoding: 'utf8' }).status === 0;
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;
347
450
  }
348
451
 
349
452
  function unitContents() {
453
+ const nodeBin = stabilizeServiceFile(process.execPath);
454
+ const servicePath = buildServicePath(dirname(nodeBin));
350
455
  return `[Unit]
351
456
  Description=Vibelet Daemon
352
457
  After=network.target
353
458
 
354
459
  [Service]
355
- ExecStart=${process.execPath} ${runtimeDaemonEntryPath}
460
+ ExecStart=${nodeBin} ${runtimeDaemonEntryPath}
356
461
  WorkingDirectory=${runtimeCurrentDir}
357
462
  Restart=always
358
463
  RestartSec=3
359
464
  StandardOutput=append:${stdoutLogPath}
360
465
  StandardError=append:${stderrLogPath}
466
+ Environment=PATH=${servicePath}
361
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}` : ''}
362
468
 
363
469
  [Install]
@@ -368,18 +474,26 @@ WantedBy=default.target
368
474
  // Fallback: detached process with PID file (no systemd)
369
475
  const fallback = createDetachedBackend();
370
476
 
371
- const useSystemd = hasSystemd();
372
477
  return {
373
- name: useSystemd ? 'systemd' : 'detached',
374
- handlesProcessLifecycle: useSystemd,
478
+ get name() {
479
+ return useSystemd ? 'systemd' : 'detached';
480
+ },
481
+
482
+ get handlesProcessLifecycle() {
483
+ return useSystemd;
484
+ },
375
485
 
376
486
  isServiceInstalled() {
377
- if (!hasSystemd()) return fallback.isServiceInstalled();
378
- return systemctl(['is-enabled', unitName]).status === 0;
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;
379
493
  },
380
494
 
381
495
  install() {
382
- if (!hasSystemd()) {
496
+ if (!useSystemd) {
383
497
  fallback.install();
384
498
  return;
385
499
  }
@@ -389,33 +503,58 @@ WantedBy=default.target
389
503
  const currentContents = existsSync(unitPath) ? readFileSync(unitPath, 'utf8') : null;
390
504
  if (currentContents !== nextContents) {
391
505
  writeFileSync(unitPath, nextContents, 'utf8');
392
- systemctl(['daemon-reload']);
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));
393
522
  }
394
- systemctl(['enable', unitName]);
395
523
  },
396
524
 
397
525
  start() {
398
- if (!hasSystemd()) {
526
+ if (!useSystemd) {
399
527
  fallback.start();
400
528
  return;
401
529
  }
402
530
  const result = systemctl(['start', unitName]);
403
531
  if (result.status !== 0) {
532
+ if (demoteToDetachedIfSystemdUnavailable(result)) {
533
+ fallback.install();
534
+ fallback.start();
535
+ return;
536
+ }
404
537
  fail('Failed to start vibelet daemon.', result.stderr || result.stdout);
405
538
  }
406
539
  },
407
540
 
408
541
  stop() {
409
- if (!hasSystemd()) {
542
+ if (!useSystemd) {
410
543
  fallback.stop();
411
544
  return;
412
545
  }
413
- systemctl(['stop', unitName]);
546
+ const result = systemctl(['stop', unitName]);
547
+ if (result.status !== 0 && demoteToDetachedIfSystemdUnavailable(result)) {
548
+ fallback.stop();
549
+ }
414
550
  },
415
551
 
416
552
  statusLabel() {
417
- if (!hasSystemd()) return fallback.statusLabel();
553
+ if (!useSystemd) return fallback.statusLabel();
418
554
  const result = systemctl(['is-active', unitName]);
555
+ if (result.status !== 0 && demoteToDetachedIfSystemdUnavailable(result)) {
556
+ return fallback.statusLabel();
557
+ }
419
558
  return result.stdout?.trim() || 'unknown';
420
559
  },
421
560
  };
@@ -556,7 +695,7 @@ async function waitForDaemonExit(timeoutMs) {
556
695
  return false;
557
696
  }
558
697
 
559
- async function waitForHealth(timeoutMs = 10_000) {
698
+ async function waitForHealth(timeoutMs = 30_000) {
560
699
  const health = await probeHealth(timeoutMs);
561
700
  if (health) {
562
701
  return health;
@@ -623,6 +762,7 @@ function createCompactPairingPayload(pairingPayload) {
623
762
  target.port,
624
763
  target.source,
625
764
  target.stability,
765
+ target.secure === true,
626
766
  ]);
627
767
  }
628
768
  return compactPayload;
@@ -719,6 +859,7 @@ function normalizePairingConnections(pairingPayload) {
719
859
  kind: target.kind === 'relay' ? 'relay' : 'direct',
720
860
  host: normalizeHostValue(target.host),
721
861
  port: Math.floor(Number(target.port)),
862
+ ...(target.secure === true ? { secure: true } : {}),
722
863
  source: target.source,
723
864
  stability: target.stability,
724
865
  }))
@@ -728,7 +869,7 @@ function normalizePairingConnections(pairingPayload) {
728
869
  const dedupe = (targets) => {
729
870
  const seen = new Set();
730
871
  return targets.filter((target) => {
731
- const key = `${target.host}:${target.port}`;
872
+ const key = `${target.host}:${target.port}:${target.secure === true ? 'secure' : 'plain'}`;
732
873
  if (seen.has(key)) {
733
874
  return false;
734
875
  }
@@ -836,6 +977,7 @@ function printHelp() {
836
977
  process.stdout.write(` npx ${packageJson.name} --force Force a new Cloudflare Tunnel URL\n`);
837
978
  process.stdout.write(` npx ${packageJson.name} --relay <url> Use a custom tunnel URL for remote access\n`);
838
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`);
839
981
  process.stdout.write(` npx ${packageJson.name} --fallback-hosts <ips> Comma-separated fallback IPs\n`);
840
982
  process.stdout.write(` npx ${packageJson.name} stop Stop the daemon\n`);
841
983
  process.stdout.write(` npx ${packageJson.name} restart Restart the daemon\n`);
@@ -898,6 +1040,16 @@ function parseRelayArg() {
898
1040
  return parseNamedArg('relay', 'https://abc.trycloudflare.com');
899
1041
  }
900
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
+
901
1053
  function loadRelayConfig() {
902
1054
  try {
903
1055
  const data = JSON.parse(readFileSync(relayConfigPath, 'utf8'));
@@ -1045,6 +1197,12 @@ async function main() {
1045
1197
  checkForUpdateFromCache();
1046
1198
  fetchLatestVersionInBackground();
1047
1199
 
1200
+ const portArg = parsePortArg();
1201
+ if (portArg !== null) {
1202
+ port = portArg;
1203
+ process.env.VIBE_PORT = String(portArg);
1204
+ }
1205
+
1048
1206
  consumeFlag('remote') || consumeFlag('tunnel') || readNpmConfigFlag('remote') || readNpmConfigFlag('tunnel');
1049
1207
  const localFlag = consumeFlag('local') || readNpmConfigFlag('local');
1050
1208
  const forceFlag = consumeFlag('force') || readNpmConfigFlag('force');
@@ -1195,7 +1353,8 @@ async function main() {
1195
1353
  fallbackHosts: fallbackHostsArg || '',
1196
1354
  localMode: localFlag,
1197
1355
  }) && (localFlag || Boolean(relayUrl) || Boolean(hostArg) || Boolean(fallbackHostsArg));
1198
- if (healthyDaemon && hasExplicitConfigOverrides) {
1356
+ const shouldReplaceDetachedDaemon = !healthyDaemon && !backend.handlesProcessLifecycle && backend.isServiceInstalled();
1357
+ if (shouldReplaceDetachedDaemon || (healthyDaemon && hasExplicitConfigOverrides)) {
1199
1358
  await stopRunningDaemon(backend);
1200
1359
  }
1201
1360
  ensureRuntimeInstalled();
@@ -1234,7 +1393,8 @@ async function main() {
1234
1393
  return;
1235
1394
  }
1236
1395
 
1237
- if (healthyDaemon && hasExplicitConfigOverrides) {
1396
+ const shouldReplaceDetachedDaemon = !healthyDaemon && !backend.handlesProcessLifecycle && backend.isServiceInstalled();
1397
+ if (shouldReplaceDetachedDaemon || (healthyDaemon && hasExplicitConfigOverrides)) {
1238
1398
  await stopRunningDaemon(backend);
1239
1399
  }
1240
1400