vibelet 0.1.37 → 1.0.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.
Files changed (323) hide show
  1. package/README.md +80 -0
  2. package/bin/cloudflared-quick-tunnel.mjs +11 -0
  3. package/bin/cloudflared-resolver.mjs +171 -0
  4. package/bin/vibelet-runtime-policy.mjs +36 -0
  5. package/bin/vibelet.cjs +12 -0
  6. package/bin/vibelet.mjs +1062 -0
  7. package/dist/index.cjs +126 -0
  8. package/package.json +25 -24
  9. package/app.json +0 -5
  10. package/dist/advertised-hosts.d.ts +0 -34
  11. package/dist/advertised-hosts.d.ts.map +0 -1
  12. package/dist/advertised-hosts.js +0 -176
  13. package/dist/advertised-hosts.js.map +0 -1
  14. package/dist/advertised-hosts.test.d.ts +0 -2
  15. package/dist/advertised-hosts.test.d.ts.map +0 -1
  16. package/dist/advertised-hosts.test.js +0 -96
  17. package/dist/advertised-hosts.test.js.map +0 -1
  18. package/dist/audit.d.ts +0 -30
  19. package/dist/audit.d.ts.map +0 -1
  20. package/dist/audit.js +0 -73
  21. package/dist/audit.js.map +0 -1
  22. package/dist/audit.test.d.ts +0 -2
  23. package/dist/audit.test.d.ts.map +0 -1
  24. package/dist/audit.test.js +0 -33
  25. package/dist/audit.test.js.map +0 -1
  26. package/dist/auth.d.ts +0 -6
  27. package/dist/auth.d.ts.map +0 -1
  28. package/dist/auth.js +0 -27
  29. package/dist/auth.js.map +0 -1
  30. package/dist/claude-hooks.d.ts +0 -58
  31. package/dist/claude-hooks.d.ts.map +0 -1
  32. package/dist/claude-hooks.js +0 -129
  33. package/dist/claude-hooks.js.map +0 -1
  34. package/dist/cli-version.d.ts +0 -3
  35. package/dist/cli-version.d.ts.map +0 -1
  36. package/dist/cli-version.js +0 -35
  37. package/dist/cli-version.js.map +0 -1
  38. package/dist/cli-version.test.d.ts +0 -2
  39. package/dist/cli-version.test.d.ts.map +0 -1
  40. package/dist/cli-version.test.js +0 -38
  41. package/dist/cli-version.test.js.map +0 -1
  42. package/dist/config.d.ts +0 -30
  43. package/dist/config.d.ts.map +0 -1
  44. package/dist/config.js +0 -327
  45. package/dist/config.js.map +0 -1
  46. package/dist/config.test.d.ts +0 -2
  47. package/dist/config.test.d.ts.map +0 -1
  48. package/dist/config.test.js +0 -184
  49. package/dist/config.test.js.map +0 -1
  50. package/dist/dev-auth.test.d.ts +0 -2
  51. package/dist/dev-auth.test.d.ts.map +0 -1
  52. package/dist/dev-auth.test.js +0 -154
  53. package/dist/dev-auth.test.js.map +0 -1
  54. package/dist/dev-script.test.d.ts +0 -2
  55. package/dist/dev-script.test.d.ts.map +0 -1
  56. package/dist/dev-script.test.js +0 -412
  57. package/dist/dev-script.test.js.map +0 -1
  58. package/dist/drivers/claude.d.ts +0 -34
  59. package/dist/drivers/claude.d.ts.map +0 -1
  60. package/dist/drivers/claude.js +0 -413
  61. package/dist/drivers/claude.js.map +0 -1
  62. package/dist/drivers/claude.test.d.ts +0 -2
  63. package/dist/drivers/claude.test.d.ts.map +0 -1
  64. package/dist/drivers/claude.test.js +0 -951
  65. package/dist/drivers/claude.test.js.map +0 -1
  66. package/dist/drivers/codex.d.ts +0 -38
  67. package/dist/drivers/codex.d.ts.map +0 -1
  68. package/dist/drivers/codex.js +0 -771
  69. package/dist/drivers/codex.js.map +0 -1
  70. package/dist/drivers/codex.test.d.ts +0 -2
  71. package/dist/drivers/codex.test.d.ts.map +0 -1
  72. package/dist/drivers/codex.test.js +0 -939
  73. package/dist/drivers/codex.test.js.map +0 -1
  74. package/dist/drivers/types.d.ts +0 -14
  75. package/dist/drivers/types.d.ts.map +0 -1
  76. package/dist/drivers/types.js +0 -2
  77. package/dist/drivers/types.js.map +0 -1
  78. package/dist/e2e.test.d.ts +0 -2
  79. package/dist/e2e.test.d.ts.map +0 -1
  80. package/dist/e2e.test.js +0 -111
  81. package/dist/e2e.test.js.map +0 -1
  82. package/dist/identity.d.ts +0 -10
  83. package/dist/identity.d.ts.map +0 -1
  84. package/dist/identity.js +0 -66
  85. package/dist/identity.js.map +0 -1
  86. package/dist/identity.test.d.ts +0 -2
  87. package/dist/identity.test.d.ts.map +0 -1
  88. package/dist/identity.test.js +0 -25
  89. package/dist/identity.test.js.map +0 -1
  90. package/dist/index-entry.test.d.ts +0 -2
  91. package/dist/index-entry.test.d.ts.map +0 -1
  92. package/dist/index-entry.test.js +0 -272
  93. package/dist/index-entry.test.js.map +0 -1
  94. package/dist/index.d.ts +0 -2
  95. package/dist/index.d.ts.map +0 -1
  96. package/dist/index.js +0 -707
  97. package/dist/index.js.map +0 -1
  98. package/dist/logger.d.ts +0 -31
  99. package/dist/logger.d.ts.map +0 -1
  100. package/dist/logger.js +0 -75
  101. package/dist/logger.js.map +0 -1
  102. package/dist/metrics.d.ts +0 -52
  103. package/dist/metrics.d.ts.map +0 -1
  104. package/dist/metrics.js +0 -89
  105. package/dist/metrics.js.map +0 -1
  106. package/dist/pairing-store.d.ts +0 -29
  107. package/dist/pairing-store.d.ts.map +0 -1
  108. package/dist/pairing-store.js +0 -131
  109. package/dist/pairing-store.js.map +0 -1
  110. package/dist/pairing-store.test.d.ts +0 -2
  111. package/dist/pairing-store.test.d.ts.map +0 -1
  112. package/dist/pairing-store.test.js +0 -47
  113. package/dist/pairing-store.test.js.map +0 -1
  114. package/dist/paths.d.ts +0 -16
  115. package/dist/paths.d.ts.map +0 -1
  116. package/dist/paths.js +0 -18
  117. package/dist/paths.js.map +0 -1
  118. package/dist/perf-compare.d.ts +0 -13
  119. package/dist/perf-compare.d.ts.map +0 -1
  120. package/dist/perf-compare.js +0 -125
  121. package/dist/perf-compare.js.map +0 -1
  122. package/dist/port-conflict.d.ts +0 -9
  123. package/dist/port-conflict.d.ts.map +0 -1
  124. package/dist/port-conflict.js +0 -33
  125. package/dist/port-conflict.js.map +0 -1
  126. package/dist/port-conflict.test.d.ts +0 -2
  127. package/dist/port-conflict.test.d.ts.map +0 -1
  128. package/dist/port-conflict.test.js +0 -38
  129. package/dist/port-conflict.test.js.map +0 -1
  130. package/dist/process-scanner.d.ts +0 -43
  131. package/dist/process-scanner.d.ts.map +0 -1
  132. package/dist/process-scanner.js +0 -453
  133. package/dist/process-scanner.js.map +0 -1
  134. package/dist/process-scanner.perf.test.d.ts +0 -2
  135. package/dist/process-scanner.perf.test.d.ts.map +0 -1
  136. package/dist/process-scanner.perf.test.js +0 -186
  137. package/dist/process-scanner.perf.test.js.map +0 -1
  138. package/dist/process-scanner.test.d.ts +0 -2
  139. package/dist/process-scanner.test.d.ts.map +0 -1
  140. package/dist/process-scanner.test.js +0 -399
  141. package/dist/process-scanner.test.js.map +0 -1
  142. package/dist/push-protocol.d.ts +0 -15
  143. package/dist/push-protocol.d.ts.map +0 -1
  144. package/dist/push-protocol.js +0 -23
  145. package/dist/push-protocol.js.map +0 -1
  146. package/dist/push-protocol.test.d.ts +0 -2
  147. package/dist/push-protocol.test.d.ts.map +0 -1
  148. package/dist/push-protocol.test.js +0 -57
  149. package/dist/push-protocol.test.js.map +0 -1
  150. package/dist/push-store.d.ts +0 -22
  151. package/dist/push-store.d.ts.map +0 -1
  152. package/dist/push-store.js +0 -103
  153. package/dist/push-store.js.map +0 -1
  154. package/dist/push-store.test.d.ts +0 -2
  155. package/dist/push-store.test.d.ts.map +0 -1
  156. package/dist/push-store.test.js +0 -79
  157. package/dist/push-store.test.js.map +0 -1
  158. package/dist/push.d.ts +0 -65
  159. package/dist/push.d.ts.map +0 -1
  160. package/dist/push.js +0 -202
  161. package/dist/push.js.map +0 -1
  162. package/dist/push.test.d.ts +0 -2
  163. package/dist/push.test.d.ts.map +0 -1
  164. package/dist/push.test.js +0 -199
  165. package/dist/push.test.js.map +0 -1
  166. package/dist/safe-stdio.d.ts +0 -3
  167. package/dist/safe-stdio.d.ts.map +0 -1
  168. package/dist/safe-stdio.js +0 -46
  169. package/dist/safe-stdio.js.map +0 -1
  170. package/dist/scanner.d.ts +0 -30
  171. package/dist/scanner.d.ts.map +0 -1
  172. package/dist/scanner.js +0 -859
  173. package/dist/scanner.js.map +0 -1
  174. package/dist/scanner.perf.test.d.ts +0 -2
  175. package/dist/scanner.perf.test.d.ts.map +0 -1
  176. package/dist/scanner.perf.test.js +0 -320
  177. package/dist/scanner.perf.test.js.map +0 -1
  178. package/dist/scanner.test.d.ts +0 -2
  179. package/dist/scanner.test.d.ts.map +0 -1
  180. package/dist/scanner.test.js +0 -948
  181. package/dist/scanner.test.js.map +0 -1
  182. package/dist/session-inventory.d.ts +0 -63
  183. package/dist/session-inventory.d.ts.map +0 -1
  184. package/dist/session-inventory.js +0 -525
  185. package/dist/session-inventory.js.map +0 -1
  186. package/dist/session-inventory.perf.test.d.ts +0 -2
  187. package/dist/session-inventory.perf.test.d.ts.map +0 -1
  188. package/dist/session-inventory.perf.test.js +0 -220
  189. package/dist/session-inventory.perf.test.js.map +0 -1
  190. package/dist/session-inventory.test.d.ts +0 -2
  191. package/dist/session-inventory.test.d.ts.map +0 -1
  192. package/dist/session-inventory.test.js +0 -712
  193. package/dist/session-inventory.test.js.map +0 -1
  194. package/dist/session-manager.d.ts +0 -75
  195. package/dist/session-manager.d.ts.map +0 -1
  196. package/dist/session-manager.js +0 -1515
  197. package/dist/session-manager.js.map +0 -1
  198. package/dist/session-manager.test.d.ts +0 -2
  199. package/dist/session-manager.test.d.ts.map +0 -1
  200. package/dist/session-manager.test.js +0 -2861
  201. package/dist/session-manager.test.js.map +0 -1
  202. package/dist/session-store.d.ts +0 -42
  203. package/dist/session-store.d.ts.map +0 -1
  204. package/dist/session-store.js +0 -163
  205. package/dist/session-store.js.map +0 -1
  206. package/dist/session-store.test.d.ts +0 -2
  207. package/dist/session-store.test.d.ts.map +0 -1
  208. package/dist/session-store.test.js +0 -236
  209. package/dist/session-store.test.js.map +0 -1
  210. package/dist/session-title.d.ts +0 -6
  211. package/dist/session-title.d.ts.map +0 -1
  212. package/dist/session-title.js +0 -105
  213. package/dist/session-title.js.map +0 -1
  214. package/dist/session-title.perf.test.d.ts +0 -2
  215. package/dist/session-title.perf.test.d.ts.map +0 -1
  216. package/dist/session-title.perf.test.js +0 -99
  217. package/dist/session-title.perf.test.js.map +0 -1
  218. package/dist/session-title.test.d.ts +0 -2
  219. package/dist/session-title.test.d.ts.map +0 -1
  220. package/dist/session-title.test.js +0 -199
  221. package/dist/session-title.test.js.map +0 -1
  222. package/dist/shutdown-endpoint.test.d.ts +0 -2
  223. package/dist/shutdown-endpoint.test.d.ts.map +0 -1
  224. package/dist/shutdown-endpoint.test.js +0 -93
  225. package/dist/shutdown-endpoint.test.js.map +0 -1
  226. package/dist/storage-housekeeping.d.ts +0 -28
  227. package/dist/storage-housekeeping.d.ts.map +0 -1
  228. package/dist/storage-housekeeping.js +0 -76
  229. package/dist/storage-housekeeping.js.map +0 -1
  230. package/dist/storage-housekeeping.test.d.ts +0 -2
  231. package/dist/storage-housekeeping.test.d.ts.map +0 -1
  232. package/dist/storage-housekeeping.test.js +0 -65
  233. package/dist/storage-housekeeping.test.js.map +0 -1
  234. package/dist/test-daemon-harness.d.ts +0 -31
  235. package/dist/test-daemon-harness.d.ts.map +0 -1
  236. package/dist/test-daemon-harness.js +0 -337
  237. package/dist/test-daemon-harness.js.map +0 -1
  238. package/dist/token-auth.test.d.ts +0 -2
  239. package/dist/token-auth.test.d.ts.map +0 -1
  240. package/dist/token-auth.test.js +0 -52
  241. package/dist/token-auth.test.js.map +0 -1
  242. package/dist/utils.d.ts +0 -4
  243. package/dist/utils.d.ts.map +0 -1
  244. package/dist/utils.js +0 -40
  245. package/dist/utils.js.map +0 -1
  246. package/dist/utils.test.d.ts +0 -2
  247. package/dist/utils.test.d.ts.map +0 -1
  248. package/dist/utils.test.js +0 -54
  249. package/dist/utils.test.js.map +0 -1
  250. package/dist/ws-data.d.ts +0 -4
  251. package/dist/ws-data.d.ts.map +0 -1
  252. package/dist/ws-data.js +0 -20
  253. package/dist/ws-data.js.map +0 -1
  254. package/dist/ws-data.test.d.ts +0 -2
  255. package/dist/ws-data.test.d.ts.map +0 -1
  256. package/dist/ws-data.test.js +0 -17
  257. package/dist/ws-data.test.js.map +0 -1
  258. package/perf-reporter.mjs +0 -138
  259. package/scripts/build-release.mjs +0 -41
  260. package/scripts/dev.mjs +0 -537
  261. package/src/advertised-hosts.test.ts +0 -125
  262. package/src/advertised-hosts.ts +0 -225
  263. package/src/audit.test.ts +0 -38
  264. package/src/audit.ts +0 -117
  265. package/src/auth.ts +0 -31
  266. package/src/claude-hooks.ts +0 -195
  267. package/src/cli-version.test.ts +0 -36
  268. package/src/cli-version.ts +0 -46
  269. package/src/config.test.ts +0 -254
  270. package/src/config.ts +0 -324
  271. package/src/dev-auth.test.ts +0 -183
  272. package/src/dev-script.test.ts +0 -511
  273. package/src/drivers/claude.test.ts +0 -1186
  274. package/src/drivers/claude.ts +0 -443
  275. package/src/drivers/codex.test.ts +0 -1096
  276. package/src/drivers/codex.ts +0 -879
  277. package/src/drivers/types.ts +0 -15
  278. package/src/e2e.test.ts +0 -139
  279. package/src/identity.test.ts +0 -26
  280. package/src/identity.ts +0 -82
  281. package/src/index-entry.test.ts +0 -336
  282. package/src/index.ts +0 -781
  283. package/src/logger.ts +0 -112
  284. package/src/metrics.ts +0 -117
  285. package/src/pairing-store.test.ts +0 -53
  286. package/src/pairing-store.ts +0 -154
  287. package/src/paths.ts +0 -19
  288. package/src/perf-compare.ts +0 -164
  289. package/src/port-conflict.test.ts +0 -45
  290. package/src/port-conflict.ts +0 -44
  291. package/src/process-scanner.perf.test.ts +0 -222
  292. package/src/process-scanner.test.ts +0 -575
  293. package/src/process-scanner.ts +0 -514
  294. package/src/push-protocol.test.ts +0 -74
  295. package/src/push-protocol.ts +0 -36
  296. package/src/push-store.test.ts +0 -89
  297. package/src/push-store.ts +0 -126
  298. package/src/push.test.ts +0 -234
  299. package/src/push.ts +0 -318
  300. package/src/safe-stdio.ts +0 -51
  301. package/src/scanner.perf.test.ts +0 -359
  302. package/src/scanner.test.ts +0 -1045
  303. package/src/scanner.ts +0 -924
  304. package/src/session-inventory.perf.test.ts +0 -250
  305. package/src/session-inventory.test.ts +0 -1002
  306. package/src/session-inventory.ts +0 -721
  307. package/src/session-manager.test.ts +0 -3430
  308. package/src/session-manager.ts +0 -1775
  309. package/src/session-store.test.ts +0 -276
  310. package/src/session-store.ts +0 -202
  311. package/src/session-title.perf.test.ts +0 -118
  312. package/src/session-title.test.ts +0 -286
  313. package/src/session-title.ts +0 -108
  314. package/src/shutdown-endpoint.test.ts +0 -95
  315. package/src/storage-housekeeping.test.ts +0 -78
  316. package/src/storage-housekeeping.ts +0 -111
  317. package/src/test-daemon-harness.ts +0 -410
  318. package/src/token-auth.test.ts +0 -67
  319. package/src/utils.test.ts +0 -65
  320. package/src/utils.ts +0 -47
  321. package/src/ws-data.test.ts +0 -20
  322. package/src/ws-data.ts +0 -26
  323. package/tsconfig.json +0 -12
@@ -0,0 +1,1062 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { spawn, spawnSync } from 'node:child_process';
4
+ import { cpSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync, openSync, writeSync } from 'node:fs';
5
+ import { homedir } from 'node:os';
6
+ import { 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 { doesHealthMatchRequestedConnectionConfig, shouldReuseHealthyDaemon } from './vibelet-runtime-policy.mjs';
12
+
13
+ // ─── Paths & constants ─────────────────────────────────────────────────────────
14
+
15
+ const rootDir = resolve(dirname(fileURLToPath(import.meta.url)), '..');
16
+ const packageJson = JSON.parse(readFileSync(join(rootDir, 'package.json'), 'utf8'));
17
+ const daemonDistDir = resolve(rootDir, 'dist');
18
+ const daemonEntryPath = resolve(daemonDistDir, 'index.cjs');
19
+ const port = Number(process.env.VIBE_PORT) || 9876;
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
+
39
+ function printOfficialSite() {
40
+ if (officialSitePrinted) return;
41
+ officialSitePrinted = true;
42
+ try {
43
+ if (updateMessage) writeSync(2, `\n${updateMessage}\n`);
44
+ writeSync(1, `\nOfficial site: ${OFFICIAL_SITE_URL}\n`);
45
+ } catch {
46
+ // Best-effort branding footer; ignore broken pipes and closed stdio.
47
+ }
48
+ }
49
+
50
+ // ─── Update check ──────────────────────────────────────────────────────────────
51
+
52
+ function readUpdateCheck() {
53
+ try {
54
+ return JSON.parse(readFileSync(updateCheckPath, 'utf8'));
55
+ } catch {
56
+ return null;
57
+ }
58
+ }
59
+
60
+ function writeUpdateCheck(data) {
61
+ try {
62
+ mkdirSync(vibeletDir, { recursive: true });
63
+ writeFileSync(updateCheckPath, JSON.stringify(data, null, 2) + '\n', 'utf8');
64
+ } catch {
65
+ // Best-effort; failing to persist is fine.
66
+ }
67
+ }
68
+
69
+ function compareVersions(a, b) {
70
+ const pa = a.split('.').map(Number);
71
+ const pb = b.split('.').map(Number);
72
+ for (let i = 0; i < 3; i++) {
73
+ if ((pa[i] || 0) < (pb[i] || 0)) return -1;
74
+ if ((pa[i] || 0) > (pb[i] || 0)) return 1;
75
+ }
76
+ return 0;
77
+ }
78
+
79
+ function checkForUpdateFromCache() {
80
+ const cached = readUpdateCheck();
81
+ if (!cached?.latestVersion) return;
82
+ if (compareVersions(packageJson.version, cached.latestVersion) < 0) {
83
+ updateMessage =
84
+ `\x1b[33m╭───────────────────────────────────────────╮\x1b[0m\n` +
85
+ `\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` +
86
+ `\x1b[33m│\x1b[0m Run \x1b[36mnpx @vibelet/cli@latest\x1b[0m to upgrade \x1b[33m│\x1b[0m\n` +
87
+ `\x1b[33m╰───────────────────────────────────────────╯\x1b[0m`;
88
+ }
89
+ }
90
+
91
+ function fetchLatestVersionInBackground() {
92
+ const cached = readUpdateCheck();
93
+ const now = Date.now();
94
+ if (cached?.checkedAt && now - cached.checkedAt < UPDATE_CHECK_INTERVAL_MS) {
95
+ return; // Checked recently; skip.
96
+ }
97
+
98
+ // Fire-and-forget: spawn a detached process to query the registry so we
99
+ // never block the CLI. Results are read on the *next* invocation.
100
+ const script = `
101
+ const https = await import('node:https');
102
+ const fs = await import('node:fs');
103
+ const url = 'https://registry.npmjs.org/@vibelet/cli/latest';
104
+ https.get(url, { headers: { 'Accept': 'application/json' }, timeout: 8000 }, (res) => {
105
+ let data = '';
106
+ res.on('data', (c) => data += c);
107
+ res.on('end', () => {
108
+ try {
109
+ const version = JSON.parse(data).version;
110
+ if (version) {
111
+ fs.writeFileSync(${JSON.stringify(updateCheckPath)}, JSON.stringify({
112
+ latestVersion: version,
113
+ checkedAt: Date.now(),
114
+ }, null, 2) + '\\n');
115
+ }
116
+ } catch {}
117
+ });
118
+ }).on('error', () => {});
119
+ `;
120
+
121
+ const child = spawn(process.execPath, ['--input-type=module', '-e', script], {
122
+ detached: true,
123
+ stdio: 'ignore',
124
+ windowsHide: true,
125
+ });
126
+ child.unref();
127
+ }
128
+
129
+ // ─── Helpers ────────────────────────────────────────────────────────────────────
130
+
131
+ function fail(message, details) {
132
+ process.stderr.write(`${message}\n`);
133
+ if (details) {
134
+ process.stderr.write(`${details}\n`);
135
+ }
136
+ process.exit(1);
137
+ }
138
+
139
+ function readRuntimeMetadata() {
140
+ if (!existsSync(runtimeMetadataPath)) return null;
141
+ try {
142
+ return JSON.parse(readFileSync(runtimeMetadataPath, 'utf8'));
143
+ } catch {
144
+ return null;
145
+ }
146
+ }
147
+
148
+ function ensureRuntimeInstalled() {
149
+ if (!existsSync(daemonEntryPath)) {
150
+ fail('The compiled daemon runtime is missing.', 'Run `pnpm build` before invoking `npx @vibelet/cli` from a source checkout.');
151
+ }
152
+
153
+ const sourceDaemonStat = statSync(daemonEntryPath);
154
+ const runtimeMetadata = readRuntimeMetadata();
155
+ const runtimeLooksFresh =
156
+ existsSync(runtimeDaemonEntryPath) &&
157
+ runtimeMetadata?.version === packageJson.version &&
158
+ runtimeMetadata?.daemonEntryMtimeMs === sourceDaemonStat.mtimeMs;
159
+
160
+ if (runtimeLooksFresh) {
161
+ return;
162
+ }
163
+
164
+ mkdirSync(runtimeDir, { recursive: true });
165
+ const nextRuntimeDir = join(runtimeDir, `current.${Date.now()}.${process.pid}`);
166
+ rmSync(nextRuntimeDir, { recursive: true, force: true });
167
+ mkdirSync(nextRuntimeDir, { recursive: true });
168
+ mkdirSync(logDir, { recursive: true });
169
+
170
+ writeFileSync(join(nextRuntimeDir, 'package.json'), JSON.stringify({
171
+ name: 'vibelet-runtime',
172
+ private: true,
173
+ type: 'module',
174
+ }, null, 2) + '\n', 'utf8');
175
+
176
+ cpSync(daemonDistDir, join(nextRuntimeDir, 'dist'), {
177
+ recursive: true,
178
+ dereference: true,
179
+ force: true,
180
+ });
181
+
182
+ writeFileSync(runtimeMetadataPath.replace(runtimeCurrentDir, nextRuntimeDir), JSON.stringify({
183
+ version: packageJson.version,
184
+ daemonEntryMtimeMs: sourceDaemonStat.mtimeMs,
185
+ installedAt: new Date().toISOString(),
186
+ }, null, 2) + '\n', 'utf8');
187
+
188
+ rmSync(runtimeCurrentDir, { recursive: true, force: true });
189
+ renameSync(nextRuntimeDir, runtimeCurrentDir);
190
+ }
191
+
192
+ // ─── PID file helpers ───────────────────────────────────────────────────────────
193
+
194
+ function writePidFile(pid) {
195
+ mkdirSync(dirname(pidFilePath), { recursive: true });
196
+ writeFileSync(pidFilePath, String(pid), 'utf8');
197
+ }
198
+
199
+ function readPidFile() {
200
+ try {
201
+ const pid = Number(readFileSync(pidFilePath, 'utf8').trim());
202
+ return Number.isFinite(pid) && pid > 0 ? pid : null;
203
+ } catch {
204
+ return null;
205
+ }
206
+ }
207
+
208
+ function removePidFile() {
209
+ rmSync(pidFilePath, { force: true });
210
+ }
211
+
212
+ function isProcessAlive(pid) {
213
+ try {
214
+ process.kill(pid, 0);
215
+ return true;
216
+ } catch {
217
+ return false;
218
+ }
219
+ }
220
+
221
+ // ─── Platform service backends ──────────────────────────────────────────────────
222
+
223
+ function createDarwinBackend() {
224
+ const label = 'dev.vibelet.daemon';
225
+ const uid = process.getuid?.();
226
+ const launchDomain = `gui/${uid ?? 0}`;
227
+ const launchAgentsDir = join(homedir(), 'Library', 'LaunchAgents');
228
+ const plistPath = join(launchAgentsDir, `${label}.plist`);
229
+
230
+ function launchctl(args) {
231
+ return spawnSync('launchctl', args, { encoding: 'utf8' });
232
+ }
233
+
234
+ function plistContents() {
235
+ const programArgs = [
236
+ process.execPath,
237
+ runtimeDaemonEntryPath,
238
+ ].map((value) => ` <string>${value}</string>`).join('\n');
239
+
240
+ const envVars = { VIBE_PORT: String(port) };
241
+ if (process.env.VIBELET_RELAY_URL) envVars.VIBELET_RELAY_URL = process.env.VIBELET_RELAY_URL;
242
+ if (process.env.VIBELET_CANONICAL_HOST) envVars.VIBELET_CANONICAL_HOST = process.env.VIBELET_CANONICAL_HOST;
243
+ if (process.env.VIBELET_FALLBACK_HOSTS) envVars.VIBELET_FALLBACK_HOSTS = process.env.VIBELET_FALLBACK_HOSTS;
244
+ const envSection = Object.keys(envVars).length > 0
245
+ ? ` <key>EnvironmentVariables</key>
246
+ <dict>
247
+ ${Object.entries(envVars).map(([k, v]) => ` <key>${k}</key>\n <string>${v}</string>`).join('\n')}
248
+ </dict>`
249
+ : '';
250
+
251
+ return `<?xml version="1.0" encoding="UTF-8"?>
252
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
253
+ <plist version="1.0">
254
+ <dict>
255
+ <key>Label</key>
256
+ <string>${label}</string>
257
+ <key>ProgramArguments</key>
258
+ <array>
259
+ ${programArgs}
260
+ </array>
261
+ <key>WorkingDirectory</key>
262
+ <string>${runtimeCurrentDir}</string>
263
+ <key>StandardOutPath</key>
264
+ <string>${stdoutLogPath}</string>
265
+ <key>StandardErrorPath</key>
266
+ <string>${stderrLogPath}</string>
267
+ ${envSection}
268
+ <key>RunAtLoad</key>
269
+ <true/>
270
+ <key>KeepAlive</key>
271
+ <true/>
272
+ </dict>
273
+ </plist>
274
+ `;
275
+ }
276
+
277
+ return {
278
+ name: 'launchd',
279
+ handlesProcessLifecycle: true,
280
+
281
+ isServiceInstalled() {
282
+ return launchctl(['print', `${launchDomain}/${label}`]).status === 0;
283
+ },
284
+
285
+ install() {
286
+ mkdirSync(launchAgentsDir, { recursive: true });
287
+ mkdirSync(logDir, { recursive: true });
288
+ const nextContents = plistContents();
289
+ const currentContents = existsSync(plistPath) ? readFileSync(plistPath, 'utf8') : null;
290
+ const serviceLoaded = this.isServiceInstalled();
291
+ const changed = currentContents !== nextContents;
292
+ if (changed) {
293
+ writeFileSync(plistPath, nextContents, 'utf8');
294
+ }
295
+
296
+ if (changed && serviceLoaded) {
297
+ launchctl(['bootout', `${launchDomain}/${label}`]);
298
+ }
299
+
300
+ if (changed || !serviceLoaded) {
301
+ const result = launchctl(['bootstrap', launchDomain, plistPath]);
302
+ if (result.status !== 0) {
303
+ fail('Failed to bootstrap vibelet launch agent.', result.stderr || result.stdout);
304
+ }
305
+ }
306
+ },
307
+
308
+ start() {
309
+ const result = launchctl(['kickstart', `${launchDomain}/${label}`]);
310
+ if (result.status !== 0 && !this.isServiceInstalled()) {
311
+ fail('Failed to start vibelet daemon.', result.stderr || result.stdout);
312
+ }
313
+ },
314
+
315
+ stop() {
316
+ if (this.isServiceInstalled()) {
317
+ launchctl(['bootout', `${launchDomain}/${label}`]);
318
+ }
319
+ },
320
+
321
+ statusLabel() {
322
+ return this.isServiceInstalled() ? 'loaded' : 'not loaded';
323
+ },
324
+ };
325
+ }
326
+
327
+ function createLinuxBackend() {
328
+ const unitName = 'vibelet-daemon.service';
329
+ const unitDir = join(homedir(), '.config', 'systemd', 'user');
330
+ const unitPath = join(unitDir, unitName);
331
+
332
+ function systemctl(args) {
333
+ return spawnSync('systemctl', ['--user', ...args], { encoding: 'utf8' });
334
+ }
335
+
336
+ function hasSystemd() {
337
+ return spawnSync('systemctl', ['--user', '--version'], { encoding: 'utf8' }).status === 0;
338
+ }
339
+
340
+ function unitContents() {
341
+ return `[Unit]
342
+ Description=Vibelet Daemon
343
+ After=network.target
344
+
345
+ [Service]
346
+ ExecStart=${process.execPath} ${runtimeDaemonEntryPath}
347
+ WorkingDirectory=${runtimeCurrentDir}
348
+ Restart=always
349
+ RestartSec=3
350
+ StandardOutput=append:${stdoutLogPath}
351
+ StandardError=append:${stderrLogPath}
352
+ 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}` : ''}
353
+
354
+ [Install]
355
+ WantedBy=default.target
356
+ `;
357
+ }
358
+
359
+ // Fallback: detached process with PID file (no systemd)
360
+ const fallback = createDetachedBackend();
361
+
362
+ const useSystemd = hasSystemd();
363
+ return {
364
+ name: useSystemd ? 'systemd' : 'detached',
365
+ handlesProcessLifecycle: useSystemd,
366
+
367
+ isServiceInstalled() {
368
+ if (!hasSystemd()) return fallback.isServiceInstalled();
369
+ return systemctl(['is-enabled', unitName]).status === 0;
370
+ },
371
+
372
+ install() {
373
+ if (!hasSystemd()) {
374
+ fallback.install();
375
+ return;
376
+ }
377
+ mkdirSync(unitDir, { recursive: true });
378
+ mkdirSync(logDir, { recursive: true });
379
+ const nextContents = unitContents();
380
+ const currentContents = existsSync(unitPath) ? readFileSync(unitPath, 'utf8') : null;
381
+ if (currentContents !== nextContents) {
382
+ writeFileSync(unitPath, nextContents, 'utf8');
383
+ systemctl(['daemon-reload']);
384
+ }
385
+ systemctl(['enable', unitName]);
386
+ },
387
+
388
+ start() {
389
+ if (!hasSystemd()) {
390
+ fallback.start();
391
+ return;
392
+ }
393
+ const result = systemctl(['start', unitName]);
394
+ if (result.status !== 0) {
395
+ fail('Failed to start vibelet daemon.', result.stderr || result.stdout);
396
+ }
397
+ },
398
+
399
+ stop() {
400
+ if (!hasSystemd()) {
401
+ fallback.stop();
402
+ return;
403
+ }
404
+ systemctl(['stop', unitName]);
405
+ },
406
+
407
+ statusLabel() {
408
+ if (!hasSystemd()) return fallback.statusLabel();
409
+ const result = systemctl(['is-active', unitName]);
410
+ return result.stdout?.trim() || 'unknown';
411
+ },
412
+ };
413
+ }
414
+
415
+ function createDetachedBackend() {
416
+ return {
417
+ name: 'detached',
418
+ handlesProcessLifecycle: false,
419
+
420
+ isServiceInstalled() {
421
+ const pid = readPidFile();
422
+ return pid !== null && isProcessAlive(pid);
423
+ },
424
+
425
+ install() {
426
+ mkdirSync(logDir, { recursive: true });
427
+ },
428
+
429
+ start() {
430
+ if (this.isServiceInstalled()) return;
431
+ const stdoutFd = openSync(stdoutLogPath, 'a');
432
+ const stderrFd = openSync(stderrLogPath, 'a');
433
+ const child = spawn(process.execPath, [runtimeDaemonEntryPath], {
434
+ detached: true,
435
+ stdio: ['ignore', stdoutFd, stderrFd],
436
+ cwd: runtimeCurrentDir,
437
+ env: { ...process.env, VIBE_PORT: String(port) },
438
+ });
439
+ child.unref();
440
+ writePidFile(child.pid);
441
+ },
442
+
443
+ stop() {
444
+ const pid = readPidFile();
445
+ if (pid && isProcessAlive(pid)) {
446
+ try {
447
+ process.kill(pid, 'SIGTERM');
448
+ } catch { /* already dead */ }
449
+ }
450
+ removePidFile();
451
+ },
452
+
453
+ statusLabel() {
454
+ const pid = readPidFile();
455
+ if (!pid) return 'not running';
456
+ return isProcessAlive(pid) ? `running (pid ${pid})` : 'not running (stale pid)';
457
+ },
458
+ };
459
+ }
460
+
461
+ function createWindowsBackend() {
462
+ // Windows: detached process with PID file
463
+ // Node.js detached on Windows creates a new console window — use windowsHide
464
+ return {
465
+ name: 'detached',
466
+ handlesProcessLifecycle: false,
467
+
468
+ isServiceInstalled() {
469
+ const pid = readPidFile();
470
+ return pid !== null && isProcessAlive(pid);
471
+ },
472
+
473
+ install() {
474
+ mkdirSync(logDir, { recursive: true });
475
+ },
476
+
477
+ start() {
478
+ if (this.isServiceInstalled()) return;
479
+ const stdoutFd = openSync(stdoutLogPath, 'a');
480
+ const stderrFd = openSync(stderrLogPath, 'a');
481
+ const child = spawn(process.execPath, [runtimeDaemonEntryPath], {
482
+ detached: true,
483
+ stdio: ['ignore', stdoutFd, stderrFd],
484
+ cwd: runtimeCurrentDir,
485
+ env: { ...process.env, VIBE_PORT: String(port) },
486
+ windowsHide: true,
487
+ });
488
+ child.unref();
489
+ writePidFile(child.pid);
490
+ },
491
+
492
+ stop() {
493
+ const pid = readPidFile();
494
+ if (pid && isProcessAlive(pid)) {
495
+ try {
496
+ process.kill(pid, 'SIGTERM');
497
+ } catch { /* already dead */ }
498
+ }
499
+ removePidFile();
500
+ },
501
+
502
+ statusLabel() {
503
+ const pid = readPidFile();
504
+ if (!pid) return 'not running';
505
+ return isProcessAlive(pid) ? `running (pid ${pid})` : 'not running (stale pid)';
506
+ },
507
+ };
508
+ }
509
+
510
+ function resolveBackend() {
511
+ switch (process.platform) {
512
+ case 'darwin': return createDarwinBackend();
513
+ case 'linux': return createLinuxBackend();
514
+ case 'win32': return createWindowsBackend();
515
+ default: return createDetachedBackend();
516
+ }
517
+ }
518
+
519
+ // ─── HTTP helpers ───────────────────────────────────────────────────────────────
520
+
521
+ async function probeHealth(timeoutMs = 0) {
522
+ const deadline = Date.now() + timeoutMs;
523
+ do {
524
+ try {
525
+ const response = await fetch(`http://127.0.0.1:${port}/health`);
526
+ if (response.ok) {
527
+ return await response.json();
528
+ }
529
+ } catch {
530
+ // Retry until timeout.
531
+ }
532
+ if (timeoutMs <= 0) break;
533
+ await new Promise((resolvePromise) => setTimeout(resolvePromise, 250));
534
+ } while (Date.now() < deadline);
535
+ return null;
536
+ }
537
+
538
+ async function waitForHealth(timeoutMs = 10_000) {
539
+ const health = await probeHealth(timeoutMs);
540
+ if (health) {
541
+ return health;
542
+ }
543
+ fail(`Timed out waiting for vibelet daemon on port ${port}.`);
544
+ }
545
+
546
+ async function postJson(pathname, body = undefined) {
547
+ const response = await fetch(`http://127.0.0.1:${port}${pathname}`, {
548
+ method: 'POST',
549
+ headers: body ? { 'Content-Type': 'application/json' } : undefined,
550
+ body: body ? JSON.stringify(body) : undefined,
551
+ });
552
+ const payload = await response.json().catch(() => ({}));
553
+ if (!response.ok) {
554
+ fail(`Request to ${pathname} failed.`, JSON.stringify(payload, null, 2));
555
+ }
556
+ return payload;
557
+ }
558
+
559
+ async function requestShutdown() {
560
+ try {
561
+ await fetch(`http://127.0.0.1:${port}/shutdown`, { method: 'POST' });
562
+ // Wait for daemon to actually stop
563
+ const deadline = Date.now() + 5_000;
564
+ while (Date.now() < deadline) {
565
+ const health = await probeHealth(0);
566
+ if (!health) return true;
567
+ await new Promise((r) => setTimeout(r, 200));
568
+ }
569
+ return false;
570
+ } catch {
571
+ return true; // Already dead
572
+ }
573
+ }
574
+
575
+ function isDaemonStartCommand(command) {
576
+ return command === 'default' || command === 'start' || command === 'restart' || command === 'reset';
577
+ }
578
+
579
+ async function stopRunningDaemon(backend) {
580
+ await requestShutdown();
581
+ backend.stop();
582
+ const stillAlive = await probeHealth(5_000);
583
+ if (stillAlive) {
584
+ fail('Daemon did not stop in time.');
585
+ }
586
+ }
587
+
588
+ function createCompactPairingPayload(pairingPayload) {
589
+ const compactPayload = {
590
+ t: 'vp',
591
+ d: pairingPayload.daemonId,
592
+ n: pairingPayload.displayName,
593
+ h: pairingPayload.canonicalHost,
594
+ p: pairingPayload.port,
595
+ c: pairingPayload.pairNonce,
596
+ e: pairingPayload.expiresAt,
597
+ };
598
+ if (pairingPayload.fallbackHosts) compactPayload.f = pairingPayload.fallbackHosts;
599
+ return compactPayload;
600
+ }
601
+
602
+ async function printPairingQr(pairingPayload) {
603
+ const payload = JSON.stringify(createCompactPairingPayload(pairingPayload));
604
+ mkdirSync(vibeletDir, { recursive: true });
605
+ await QRCode.toFile(pairingQrPngPath, payload, {
606
+ type: 'png',
607
+ errorCorrectionLevel: 'M',
608
+ margin: 1,
609
+ scale: 8,
610
+ });
611
+ const qr = await QRCode.toString(payload, {
612
+ type: 'terminal',
613
+ small: true,
614
+ errorCorrectionLevel: 'M',
615
+ });
616
+ process.stdout.write(`\nScan this QR code with the Vibelet app:\n\n${qr}\n`);
617
+ process.stdout.write(`If Vibelet app can't scan the terminal QR, open this PNG instead:\n${pairingQrPngPath}\n`);
618
+ }
619
+
620
+ // ─── Commands ───────────────────────────────────────────────────────────────────
621
+
622
+ async function printPairingSummary(existingHealth = null) {
623
+ const health = existingHealth ?? await waitForHealth();
624
+ const pairingPayload = await postJson('/pair/open');
625
+
626
+ process.stdout.write(`Vibelet daemon is ready.\n\n`);
627
+ process.stdout.write(`Device: ${health.displayName}\n`);
628
+ process.stdout.write(`Daemon ID: ${health.daemonId}\n`);
629
+ process.stdout.write(`Host: ${pairingPayload.canonicalHost}\n`);
630
+ process.stdout.write(`Port: ${pairingPayload.port}\n`);
631
+ process.stdout.write(`Paired devices: ${health.pairedDevices}\n`);
632
+ await printPairingQr(pairingPayload);
633
+ }
634
+
635
+ function printHelp() {
636
+ process.stdout.write(`Vibelet ${packageJson.version}\n\n`);
637
+ process.stdout.write(`Package names:\n`);
638
+ process.stdout.write(` @vibelet/cli\n`);
639
+ process.stdout.write(` vibelet\n\n`);
640
+ process.stdout.write(`Usage:\n`);
641
+ process.stdout.write(` npx ${packageJson.name} Install/start the daemon, auto-enable remote access, and print a pairing QR code\n`);
642
+ process.stdout.write(` npx ${packageJson.name} start Same as above\n`);
643
+ process.stdout.write(` npx ${packageJson.name} --local Skip the default Cloudflare Tunnel for this run\n`);
644
+ process.stdout.write(` npx ${packageJson.name} --remote --force Force a new Cloudflare Tunnel URL\n`);
645
+ process.stdout.write(` npx ${packageJson.name} --relay <url> Use a custom tunnel URL for remote access\n`);
646
+ process.stdout.write(` npx ${packageJson.name} --host <ip> Set the primary host/IP address\n`);
647
+ process.stdout.write(` npx ${packageJson.name} --fallback-hosts <ips> Comma-separated fallback IPs\n`);
648
+ process.stdout.write(` npx ${packageJson.name} stop Stop the daemon\n`);
649
+ process.stdout.write(` npx ${packageJson.name} restart Restart the daemon\n`);
650
+ process.stdout.write(` npx ${packageJson.name} status Show service and daemon status\n`);
651
+ process.stdout.write(` npx ${packageJson.name} logs Print recent daemon logs\n`);
652
+ process.stdout.write(` npx ${packageJson.name} reset Reset pairings and print a fresh QR code\n`);
653
+ process.stdout.write(` npx ${packageJson.name} --help Show this help text\n`);
654
+ process.stdout.write(` npx ${packageJson.name} --version Show the installed CLI version\n`);
655
+ process.stdout.write(`\n`);
656
+ process.stdout.write(`You can also invoke the published alias with:\n`);
657
+ process.stdout.write(` npx ${packageJson.name === 'vibelet' ? '@vibelet/cli' : 'vibelet'}\n`);
658
+ process.stdout.write(` vibelet\n\n`);
659
+ process.stdout.write(`Remote access:\n`);
660
+ process.stdout.write(` # Remote access is on by default for start/reset/restart\n`);
661
+ process.stdout.write(` npx ${packageJson.name}\n\n`);
662
+ process.stdout.write(` # Want LAN-only pairing for this run?\n`);
663
+ process.stdout.write(` npx ${packageJson.name} --local\n\n`);
664
+ process.stdout.write(` # Need a fresh Cloudflare Tunnel URL?\n`);
665
+ process.stdout.write(` npx ${packageJson.name} --remote --force\n\n`);
666
+ process.stdout.write(` # Or bring your own tunnel and pass the URL manually:\n`);
667
+ process.stdout.write(` npx cloudflared tunnel --protocol http2 --url http://localhost:${port}\n`);
668
+ process.stdout.write(` ngrok http ${port}\n`);
669
+ process.stdout.write(` npx ${packageJson.name} --relay=https://<your-tunnel-url>\n\n`);
670
+ process.stdout.write(` # Tailscale (P2P VPN, no tunnel needed)\n`);
671
+ process.stdout.write(` npx ${packageJson.name} --host=<tailscale-ip>\n`);
672
+ }
673
+
674
+ function consumeFlag(name) {
675
+ const idx = process.argv.indexOf(`--${name}`);
676
+ if (idx === -1) return false;
677
+ process.argv.splice(idx, 1);
678
+ return true;
679
+ }
680
+
681
+ function readNpmConfigFlag(name) {
682
+ const value = process.env[`npm_config_${name.replace(/-/g, '_')}`];
683
+ return value === '' || value === 'true';
684
+ }
685
+
686
+ function parseNamedArg(name, errorHint) {
687
+ const inlinePrefix = `--${name}=`;
688
+ const inlineArg = process.argv.find((arg) => arg.startsWith(inlinePrefix));
689
+ if (inlineArg) {
690
+ const idx = process.argv.indexOf(inlineArg);
691
+ process.argv.splice(idx, 1);
692
+ return inlineArg.slice(inlinePrefix.length);
693
+ }
694
+
695
+ const idx = process.argv.indexOf(`--${name}`);
696
+ if (idx === -1) return null;
697
+ const value = process.argv[idx + 1];
698
+ if (!value || value.startsWith('-')) {
699
+ fail(`--${name} requires an argument${errorHint ? ` (e.g. --${name} ${errorHint})` : ''}`);
700
+ }
701
+ process.argv.splice(idx, 2);
702
+ return value;
703
+ }
704
+
705
+ function parseRelayArg() {
706
+ return parseNamedArg('relay', 'https://abc.trycloudflare.com');
707
+ }
708
+
709
+ function loadRelayConfig() {
710
+ try {
711
+ const data = JSON.parse(readFileSync(relayConfigPath, 'utf8'));
712
+ return data.relayUrl || '';
713
+ } catch {
714
+ return '';
715
+ }
716
+ }
717
+
718
+ function saveRelayConfig(relayUrl) {
719
+ mkdirSync(vibeletDir, { recursive: true });
720
+ writeFileSync(relayConfigPath, JSON.stringify({ relayUrl }, null, 2) + '\n', 'utf8');
721
+ }
722
+
723
+ function clearRelayConfig() {
724
+ rmSync(relayConfigPath, { force: true });
725
+ }
726
+
727
+ // ─── Tunnel management ──────────────────────────────────────────────────────────
728
+
729
+ function loadTunnelState() {
730
+ try {
731
+ return JSON.parse(readFileSync(tunnelStatePath, 'utf8'));
732
+ } catch {
733
+ return null;
734
+ }
735
+ }
736
+
737
+ function saveTunnelState(pid, url) {
738
+ mkdirSync(vibeletDir, { recursive: true });
739
+ writeFileSync(tunnelStatePath, JSON.stringify({ pid, url }, null, 2) + '\n', 'utf8');
740
+ }
741
+
742
+ function clearTunnelState() {
743
+ rmSync(tunnelStatePath, { force: true });
744
+ }
745
+
746
+ function stopTunnel() {
747
+ const state = loadTunnelState();
748
+ if (state?.pid && isProcessAlive(state.pid)) {
749
+ try { process.kill(state.pid, 'SIGTERM'); } catch { /* already dead */ }
750
+ }
751
+ clearTunnelState();
752
+ }
753
+
754
+ function getAliveTunnel() {
755
+ const state = loadTunnelState();
756
+ if (state?.pid && state?.url && isProcessAlive(state.pid)) {
757
+ return state;
758
+ }
759
+ return null;
760
+ }
761
+
762
+ function readCloudflaredLog(logPath) {
763
+ try {
764
+ return readFileSync(logPath, 'utf8');
765
+ } catch {
766
+ return '';
767
+ }
768
+ }
769
+
770
+ function startTunnel() {
771
+ return new Promise((resolve, reject) => {
772
+ const logPath = join(logDir, 'tunnel.stderr.log');
773
+ mkdirSync(logDir, { recursive: true });
774
+
775
+ const launchSpec = resolveCloudflaredLaunchSpec();
776
+
777
+ // Strategy: start cloudflared with output to log files (so it survives detach),
778
+ // then tail the log to capture the URL.
779
+ // Truncate log so we don't match a stale URL from a previous run.
780
+ writeFileSync(logPath, '', 'utf8');
781
+ const logFd = openSync(logPath, 'a');
782
+ const child = spawn(launchSpec.command, [
783
+ ...launchSpec.args,
784
+ 'tunnel',
785
+ '--protocol',
786
+ 'http2',
787
+ '--url',
788
+ `http://localhost:${port}`,
789
+ ], {
790
+ detached: true,
791
+ stdio: ['ignore', logFd, logFd],
792
+ });
793
+ child.unref();
794
+
795
+ const pid = child.pid;
796
+ let url = null;
797
+
798
+ const timeout = setTimeout(() => {
799
+ if (!url) {
800
+ try { process.kill(pid, 'SIGTERM'); } catch { /* */ }
801
+ reject(new Error(formatCloudflaredFailureMessage({
802
+ launchSpec,
803
+ logContent: readCloudflaredLog(logPath),
804
+ logPath,
805
+ phase: 'timeout',
806
+ })));
807
+ }
808
+ }, launchSpec.urlTimeoutMs);
809
+
810
+ // Poll the log file for the tunnel URL
811
+ const poll = setInterval(() => {
812
+ try {
813
+ const content = readFileSync(logPath, 'utf8');
814
+ const tunnelUrl = extractQuickTunnelUrl(content);
815
+ if (tunnelUrl) {
816
+ url = tunnelUrl;
817
+ clearInterval(poll);
818
+ clearTimeout(timeout);
819
+ saveTunnelState(pid, url);
820
+ resolve({ pid, url });
821
+ return;
822
+ }
823
+ // Check if process died before producing URL
824
+ if (!isProcessAlive(pid)) {
825
+ clearInterval(poll);
826
+ clearTimeout(timeout);
827
+ reject(new Error(formatCloudflaredFailureMessage({
828
+ launchSpec,
829
+ logContent: content,
830
+ logPath,
831
+ phase: 'exit',
832
+ })));
833
+ }
834
+ } catch { /* file not ready yet */ }
835
+ }, 300);
836
+
837
+ child.on('error', (err) => {
838
+ clearInterval(poll);
839
+ clearTimeout(timeout);
840
+ reject(new Error(formatCloudflaredFailureMessage({
841
+ launchSpec,
842
+ logContent: readCloudflaredLog(logPath),
843
+ logPath,
844
+ err,
845
+ phase: 'spawn',
846
+ })));
847
+ });
848
+ });
849
+ }
850
+
851
+ async function main() {
852
+ // Update check: read cached result (sync, instant) and spawn background fetch.
853
+ checkForUpdateFromCache();
854
+ fetchLatestVersionInBackground();
855
+
856
+ consumeFlag('remote') || consumeFlag('tunnel') || readNpmConfigFlag('remote') || readNpmConfigFlag('tunnel');
857
+ const localFlag = consumeFlag('local') || readNpmConfigFlag('local');
858
+ const forceFlag = consumeFlag('force') || readNpmConfigFlag('force');
859
+ const relayArg = parseRelayArg();
860
+ const hostArg = parseNamedArg('host', '100.x.x.x');
861
+ const fallbackHostsArg = parseNamedArg('fallback-hosts', '100.x.x.x,192.168.1.x');
862
+ const command = process.argv[2] ?? 'default';
863
+ const startCommand = isDaemonStartCommand(command);
864
+ // --remote/--tunnel remain accepted for compatibility, but startup commands
865
+ // now default to managed remote access unless another connection target wins.
866
+ const shouldManageTunnel = startCommand
867
+ && !localFlag
868
+ && relayArg === null
869
+ && !hostArg
870
+ && !fallbackHostsArg;
871
+
872
+ if (command === '--help' || command === '-h' || command === 'help') {
873
+ printHelp();
874
+ return;
875
+ }
876
+
877
+ if (command === '--version' || command === '-v' || command === 'version') {
878
+ process.stdout.write(`${packageJson.version}\n`);
879
+ return;
880
+ }
881
+
882
+ if (shouldManageTunnel) {
883
+ const existing = forceFlag ? null : getAliveTunnel();
884
+ if (existing) {
885
+ process.stdout.write(`Reusing tunnel: ${existing.url} (pid ${existing.pid})\n`);
886
+ saveRelayConfig(existing.url);
887
+ } else {
888
+ if (forceFlag) stopTunnel();
889
+ process.stdout.write('Starting Cloudflare Tunnel...\n');
890
+ try {
891
+ const tunnel = await startTunnel();
892
+ process.stdout.write(`Tunnel ready: ${tunnel.url}\n`);
893
+ saveRelayConfig(tunnel.url);
894
+ } catch (err) {
895
+ fail(err.message);
896
+ }
897
+ }
898
+ }
899
+
900
+ // --relay "" clears saved relay; --relay <url> saves it; omitted uses saved value
901
+ if (relayArg !== null) {
902
+ if (relayArg) {
903
+ saveRelayConfig(relayArg);
904
+ } else {
905
+ clearRelayConfig();
906
+ }
907
+ }
908
+ const shouldIgnoreSavedRelay = relayArg === null && (localFlag || Boolean(hostArg) || Boolean(fallbackHostsArg));
909
+ const relayUrl = relayArg !== null
910
+ ? relayArg
911
+ : (shouldIgnoreSavedRelay ? '' : loadRelayConfig());
912
+ if (relayUrl) {
913
+ process.env.VIBELET_RELAY_URL = relayUrl;
914
+ } else {
915
+ delete process.env.VIBELET_RELAY_URL;
916
+ }
917
+ if (hostArg) {
918
+ process.env.VIBELET_CANONICAL_HOST = hostArg;
919
+ } else {
920
+ delete process.env.VIBELET_CANONICAL_HOST;
921
+ }
922
+ if (fallbackHostsArg) {
923
+ process.env.VIBELET_FALLBACK_HOSTS = fallbackHostsArg;
924
+ } else {
925
+ delete process.env.VIBELET_FALLBACK_HOSTS;
926
+ }
927
+ const backend = resolveBackend();
928
+
929
+ if (command === 'stop') {
930
+ process.stdout.write('Stopping vibelet daemon...\n');
931
+ // Always try graceful HTTP shutdown first — gives the daemon time to
932
+ // close sessions and flush logs before the service manager kills it.
933
+ await stopRunningDaemon(backend);
934
+ // Also stop tunnel if running
935
+ const tunnelState = getAliveTunnel();
936
+ if (tunnelState) {
937
+ stopTunnel();
938
+ process.stdout.write('Tunnel stopped.\n');
939
+ }
940
+ process.stdout.write('Daemon stopped.\n');
941
+ return;
942
+ }
943
+
944
+ if (command === 'status') {
945
+ process.stdout.write(`Service (${backend.name}): ${backend.statusLabel()}\n`);
946
+ process.stdout.write(`Runtime: ${existsSync(runtimeDaemonEntryPath) ? runtimeDaemonEntryPath : 'not installed'}\n`);
947
+ const tunnelState = getAliveTunnel();
948
+ if (tunnelState) {
949
+ process.stdout.write(`Tunnel: ${tunnelState.url} (pid ${tunnelState.pid})\n`);
950
+ }
951
+ const savedRelay = loadRelayConfig();
952
+ if (savedRelay) {
953
+ process.stdout.write(`Relay: ${savedRelay}\n`);
954
+ }
955
+ const health = await probeHealth(1_500);
956
+ if (health) {
957
+ process.stdout.write(JSON.stringify(health, null, 2) + '\n');
958
+ } else {
959
+ process.stdout.write('Daemon is not responding.\n');
960
+ }
961
+ return;
962
+ }
963
+
964
+ if (command === 'logs') {
965
+ process.stdout.write(`Stdout: ${stdoutLogPath}\n`);
966
+ process.stdout.write(`Stderr: ${stderrLogPath}\n\n`);
967
+ const logFiles = [stdoutLogPath, stderrLogPath].filter((filePath) => existsSync(filePath));
968
+ if (logFiles.length === 0) {
969
+ process.stdout.write('No daemon logs have been written yet.\n');
970
+ return;
971
+ }
972
+ if (process.platform === 'win32') {
973
+ for (const logFile of logFiles) {
974
+ const content = readFileSync(logFile, 'utf8');
975
+ const lines = content.split('\n').slice(-80).join('\n');
976
+ process.stdout.write(`==> ${logFile} <==\n${lines}\n`);
977
+ }
978
+ } else {
979
+ const result = spawnSync('tail', ['-n', '80', ...logFiles], { encoding: 'utf8' });
980
+ if (result.stdout) process.stdout.write(result.stdout);
981
+ if (result.stderr) process.stderr.write(result.stderr);
982
+ }
983
+ return;
984
+ }
985
+
986
+ if (command === 'restart') {
987
+ process.stdout.write('Restarting vibelet daemon...\n');
988
+ await stopRunningDaemon(backend);
989
+ process.stdout.write('Daemon stopped. Starting...\n');
990
+ ensureRuntimeInstalled();
991
+ backend.install();
992
+ backend.start();
993
+ await printPairingSummary();
994
+ return;
995
+ }
996
+
997
+ if (command === 'reset') {
998
+ const healthyDaemon = await probeHealth(1_500);
999
+ const hasExplicitConfigOverrides = !doesHealthMatchRequestedConnectionConfig({
1000
+ health: healthyDaemon,
1001
+ relayUrl,
1002
+ canonicalHost: hostArg || '',
1003
+ fallbackHosts: fallbackHostsArg || '',
1004
+ localMode: localFlag,
1005
+ }) && (localFlag || Boolean(relayUrl) || Boolean(hostArg) || Boolean(fallbackHostsArg));
1006
+ if (healthyDaemon && hasExplicitConfigOverrides) {
1007
+ await stopRunningDaemon(backend);
1008
+ }
1009
+ ensureRuntimeInstalled();
1010
+ backend.install();
1011
+ backend.start();
1012
+ await waitForHealth();
1013
+ await postJson('/pair/reset');
1014
+ await printPairingSummary();
1015
+ return;
1016
+ }
1017
+
1018
+ if (command !== 'default' && command !== 'start') {
1019
+ printHelp();
1020
+ fail(`Unknown command: ${command}`);
1021
+ }
1022
+
1023
+ const healthyDaemon = await probeHealth(1_500);
1024
+ const hasExplicitConfigOverrides = !doesHealthMatchRequestedConnectionConfig({
1025
+ health: healthyDaemon,
1026
+ relayUrl,
1027
+ canonicalHost: hostArg || '',
1028
+ fallbackHosts: fallbackHostsArg || '',
1029
+ localMode: localFlag,
1030
+ }) && (localFlag || Boolean(relayUrl) || Boolean(hostArg) || Boolean(fallbackHostsArg));
1031
+ const existingHealth = shouldReuseHealthyDaemon({
1032
+ command,
1033
+ daemonHealthy: Boolean(healthyDaemon),
1034
+ hasExplicitConfigOverrides,
1035
+ }) ? healthyDaemon : null;
1036
+
1037
+ if (existingHealth) {
1038
+ process.stdout.write('Vibelet daemon is already running.\n');
1039
+ process.stdout.write('Reusing the current runtime so active sessions stay alive.\n');
1040
+ process.stdout.write('Run `npx vibelet restart` to apply freshly built daemon code.\n\n');
1041
+ await printPairingSummary(existingHealth);
1042
+ return;
1043
+ }
1044
+
1045
+ if (healthyDaemon && hasExplicitConfigOverrides) {
1046
+ await stopRunningDaemon(backend);
1047
+ }
1048
+
1049
+ ensureRuntimeInstalled();
1050
+ backend.install();
1051
+ backend.start();
1052
+ await printPairingSummary();
1053
+ }
1054
+
1055
+ process.on('SIGINT', () => process.exit(0));
1056
+ process.on('SIGTERM', () => process.exit(0));
1057
+ process.on('exit', printOfficialSite);
1058
+ await main();
1059
+ // On Windows + Node 24, process.exit(0) can trigger a libuv assertion
1060
+ // (UV_HANDLE_CLOSING) when detached child handles haven't fully released.
1061
+ // Letting the event loop drain naturally avoids this.
1062
+ if (process.platform !== 'win32') process.exit(0);