busbar-ai 1.0.2 → 1.0.3

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 (192) hide show
  1. package/README.md +120 -115
  2. package/app/.next-cli-build/BUILD_ID +1 -1
  3. package/app/.next-cli-build/app-path-routes-manifest.json +19 -19
  4. package/app/.next-cli-build/build-manifest.json +2 -2
  5. package/app/.next-cli-build/prerender-manifest.json +2 -2
  6. package/app/.next-cli-build/server/app/(dashboard)/dashboard/basic-chat/page_client-reference-manifest.js +1 -1
  7. package/app/.next-cli-build/server/app/(dashboard)/dashboard/cli-tools/[toolId]/page_client-reference-manifest.js +1 -1
  8. package/app/.next-cli-build/server/app/(dashboard)/dashboard/cli-tools/page_client-reference-manifest.js +1 -1
  9. package/app/.next-cli-build/server/app/(dashboard)/dashboard/combos/page_client-reference-manifest.js +1 -1
  10. package/app/.next-cli-build/server/app/(dashboard)/dashboard/console-log/page_client-reference-manifest.js +1 -1
  11. package/app/.next-cli-build/server/app/(dashboard)/dashboard/endpoint/page_client-reference-manifest.js +1 -1
  12. package/app/.next-cli-build/server/app/(dashboard)/dashboard/media-providers/[kind]/[id]/page_client-reference-manifest.js +1 -1
  13. package/app/.next-cli-build/server/app/(dashboard)/dashboard/media-providers/[kind]/page_client-reference-manifest.js +1 -1
  14. package/app/.next-cli-build/server/app/(dashboard)/dashboard/media-providers/combo/[id]/page_client-reference-manifest.js +1 -1
  15. package/app/.next-cli-build/server/app/(dashboard)/dashboard/media-providers/web/page_client-reference-manifest.js +1 -1
  16. package/app/.next-cli-build/server/app/(dashboard)/dashboard/mitm/page_client-reference-manifest.js +1 -1
  17. package/app/.next-cli-build/server/app/(dashboard)/dashboard/page_client-reference-manifest.js +1 -1
  18. package/app/.next-cli-build/server/app/(dashboard)/dashboard/profile/page_client-reference-manifest.js +1 -1
  19. package/app/.next-cli-build/server/app/(dashboard)/dashboard/providers/[id]/page_client-reference-manifest.js +1 -1
  20. package/app/.next-cli-build/server/app/(dashboard)/dashboard/providers/new/page_client-reference-manifest.js +1 -1
  21. package/app/.next-cli-build/server/app/(dashboard)/dashboard/providers/page_client-reference-manifest.js +1 -1
  22. package/app/.next-cli-build/server/app/(dashboard)/dashboard/proxy-pools/page_client-reference-manifest.js +1 -1
  23. package/app/.next-cli-build/server/app/(dashboard)/dashboard/pxpipe/page_client-reference-manifest.js +1 -1
  24. package/app/.next-cli-build/server/app/(dashboard)/dashboard/quota/page_client-reference-manifest.js +1 -1
  25. package/app/.next-cli-build/server/app/(dashboard)/dashboard/skills/page.js +1 -1
  26. package/app/.next-cli-build/server/app/(dashboard)/dashboard/skills/page_client-reference-manifest.js +1 -1
  27. package/app/.next-cli-build/server/app/(dashboard)/dashboard/token-saver/page_client-reference-manifest.js +1 -1
  28. package/app/.next-cli-build/server/app/(dashboard)/dashboard/translator/page_client-reference-manifest.js +1 -1
  29. package/app/.next-cli-build/server/app/(dashboard)/dashboard/usage/page_client-reference-manifest.js +1 -1
  30. package/app/.next-cli-build/server/app/_global-error/page_client-reference-manifest.js +1 -1
  31. package/app/.next-cli-build/server/app/_global-error.html +1 -1
  32. package/app/.next-cli-build/server/app/_global-error.rsc +1 -1
  33. package/app/.next-cli-build/server/app/_global-error.segments/_full.segment.rsc +1 -1
  34. package/app/.next-cli-build/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
  35. package/app/.next-cli-build/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  36. package/app/.next-cli-build/server/app/_not-found/page_client-reference-manifest.js +1 -1
  37. package/app/.next-cli-build/server/app/_not-found.html +1 -1
  38. package/app/.next-cli-build/server/app/_not-found.rsc +3 -3
  39. package/app/.next-cli-build/server/app/_not-found.segments/_full.segment.rsc +3 -3
  40. package/app/.next-cli-build/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +3 -3
  41. package/app/.next-cli-build/server/app/_not-found.segments/_tree.segment.rsc +1 -1
  42. package/app/.next-cli-build/server/app/api/models/route.js +1 -1
  43. package/app/.next-cli-build/server/app/api/providers/route.js +1 -1
  44. package/app/.next-cli-build/server/app/api/translator/console-logs/route.js +1 -1
  45. package/app/.next-cli-build/server/app/api/translator/console-logs/stream/route.js +1 -1
  46. package/app/.next-cli-build/server/app/api/version/route.js +1 -1
  47. package/app/.next-cli-build/server/app/api/version/shutdown/route.js +1 -1
  48. package/app/.next-cli-build/server/app/api/version/update/route.js +1 -1
  49. package/app/.next-cli-build/server/app/callback/page_client-reference-manifest.js +1 -1
  50. package/app/.next-cli-build/server/app/callback.html +1 -1
  51. package/app/.next-cli-build/server/app/callback.rsc +3 -3
  52. package/app/.next-cli-build/server/app/callback.segments/_full.segment.rsc +3 -3
  53. package/app/.next-cli-build/server/app/callback.segments/_tree.segment.rsc +1 -1
  54. package/app/.next-cli-build/server/app/callback.segments/callback/__PAGE__.segment.rsc +3 -3
  55. package/app/.next-cli-build/server/app/dashboard/basic-chat.html +1 -1
  56. package/app/.next-cli-build/server/app/dashboard/basic-chat.rsc +3 -3
  57. package/app/.next-cli-build/server/app/dashboard/basic-chat.segments/!KGRhc2hib2FyZCk/dashboard/basic-chat/__PAGE__.segment.rsc +3 -3
  58. package/app/.next-cli-build/server/app/dashboard/basic-chat.segments/_full.segment.rsc +3 -3
  59. package/app/.next-cli-build/server/app/dashboard/basic-chat.segments/_tree.segment.rsc +1 -1
  60. package/app/.next-cli-build/server/app/dashboard/cli-tools.html +1 -1
  61. package/app/.next-cli-build/server/app/dashboard/cli-tools.rsc +3 -3
  62. package/app/.next-cli-build/server/app/dashboard/cli-tools.segments/!KGRhc2hib2FyZCk/dashboard/cli-tools/__PAGE__.segment.rsc +3 -3
  63. package/app/.next-cli-build/server/app/dashboard/cli-tools.segments/_full.segment.rsc +3 -3
  64. package/app/.next-cli-build/server/app/dashboard/cli-tools.segments/_tree.segment.rsc +1 -1
  65. package/app/.next-cli-build/server/app/dashboard/combos.html +1 -1
  66. package/app/.next-cli-build/server/app/dashboard/combos.rsc +3 -3
  67. package/app/.next-cli-build/server/app/dashboard/combos.segments/!KGRhc2hib2FyZCk/dashboard/combos/__PAGE__.segment.rsc +3 -3
  68. package/app/.next-cli-build/server/app/dashboard/combos.segments/_full.segment.rsc +3 -3
  69. package/app/.next-cli-build/server/app/dashboard/combos.segments/_tree.segment.rsc +1 -1
  70. package/app/.next-cli-build/server/app/dashboard/endpoint.html +1 -1
  71. package/app/.next-cli-build/server/app/dashboard/endpoint.rsc +3 -3
  72. package/app/.next-cli-build/server/app/dashboard/endpoint.segments/!KGRhc2hib2FyZCk/dashboard/endpoint/__PAGE__.segment.rsc +3 -3
  73. package/app/.next-cli-build/server/app/dashboard/endpoint.segments/_full.segment.rsc +3 -3
  74. package/app/.next-cli-build/server/app/dashboard/endpoint.segments/_tree.segment.rsc +1 -1
  75. package/app/.next-cli-build/server/app/dashboard/media-providers/web.html +1 -1
  76. package/app/.next-cli-build/server/app/dashboard/media-providers/web.rsc +3 -3
  77. package/app/.next-cli-build/server/app/dashboard/media-providers/web.segments/!KGRhc2hib2FyZCk/dashboard/media-providers/web/__PAGE__.segment.rsc +3 -3
  78. package/app/.next-cli-build/server/app/dashboard/media-providers/web.segments/_full.segment.rsc +3 -3
  79. package/app/.next-cli-build/server/app/dashboard/media-providers/web.segments/_tree.segment.rsc +1 -1
  80. package/app/.next-cli-build/server/app/dashboard/mitm.html +1 -1
  81. package/app/.next-cli-build/server/app/dashboard/mitm.rsc +3 -3
  82. package/app/.next-cli-build/server/app/dashboard/mitm.segments/!KGRhc2hib2FyZCk/dashboard/mitm/__PAGE__.segment.rsc +3 -3
  83. package/app/.next-cli-build/server/app/dashboard/mitm.segments/_full.segment.rsc +3 -3
  84. package/app/.next-cli-build/server/app/dashboard/mitm.segments/_tree.segment.rsc +1 -1
  85. package/app/.next-cli-build/server/app/dashboard/profile.html +1 -1
  86. package/app/.next-cli-build/server/app/dashboard/profile.rsc +3 -3
  87. package/app/.next-cli-build/server/app/dashboard/profile.segments/!KGRhc2hib2FyZCk/dashboard/profile/__PAGE__.segment.rsc +3 -3
  88. package/app/.next-cli-build/server/app/dashboard/profile.segments/_full.segment.rsc +3 -3
  89. package/app/.next-cli-build/server/app/dashboard/profile.segments/_tree.segment.rsc +1 -1
  90. package/app/.next-cli-build/server/app/dashboard/providers/new.html +1 -1
  91. package/app/.next-cli-build/server/app/dashboard/providers/new.rsc +3 -3
  92. package/app/.next-cli-build/server/app/dashboard/providers/new.segments/!KGRhc2hib2FyZCk/dashboard/providers/new/__PAGE__.segment.rsc +3 -3
  93. package/app/.next-cli-build/server/app/dashboard/providers/new.segments/_full.segment.rsc +3 -3
  94. package/app/.next-cli-build/server/app/dashboard/providers/new.segments/_tree.segment.rsc +1 -1
  95. package/app/.next-cli-build/server/app/dashboard/providers.html +1 -1
  96. package/app/.next-cli-build/server/app/dashboard/providers.rsc +3 -3
  97. package/app/.next-cli-build/server/app/dashboard/providers.segments/!KGRhc2hib2FyZCk/dashboard/providers/__PAGE__.segment.rsc +3 -3
  98. package/app/.next-cli-build/server/app/dashboard/providers.segments/_full.segment.rsc +3 -3
  99. package/app/.next-cli-build/server/app/dashboard/providers.segments/_tree.segment.rsc +1 -1
  100. package/app/.next-cli-build/server/app/dashboard/proxy-pools.html +1 -1
  101. package/app/.next-cli-build/server/app/dashboard/proxy-pools.rsc +3 -3
  102. package/app/.next-cli-build/server/app/dashboard/proxy-pools.segments/!KGRhc2hib2FyZCk/dashboard/proxy-pools/__PAGE__.segment.rsc +3 -3
  103. package/app/.next-cli-build/server/app/dashboard/proxy-pools.segments/_full.segment.rsc +3 -3
  104. package/app/.next-cli-build/server/app/dashboard/proxy-pools.segments/_tree.segment.rsc +1 -1
  105. package/app/.next-cli-build/server/app/dashboard/pxpipe.html +1 -1
  106. package/app/.next-cli-build/server/app/dashboard/pxpipe.rsc +3 -3
  107. package/app/.next-cli-build/server/app/dashboard/pxpipe.segments/!KGRhc2hib2FyZCk/dashboard/pxpipe/__PAGE__.segment.rsc +3 -3
  108. package/app/.next-cli-build/server/app/dashboard/pxpipe.segments/_full.segment.rsc +3 -3
  109. package/app/.next-cli-build/server/app/dashboard/pxpipe.segments/_tree.segment.rsc +1 -1
  110. package/app/.next-cli-build/server/app/dashboard/quota.html +1 -1
  111. package/app/.next-cli-build/server/app/dashboard/quota.rsc +3 -3
  112. package/app/.next-cli-build/server/app/dashboard/quota.segments/!KGRhc2hib2FyZCk/dashboard/quota/__PAGE__.segment.rsc +3 -3
  113. package/app/.next-cli-build/server/app/dashboard/quota.segments/_full.segment.rsc +3 -3
  114. package/app/.next-cli-build/server/app/dashboard/quota.segments/_tree.segment.rsc +1 -1
  115. package/app/.next-cli-build/server/app/dashboard/settings/pricing/page_client-reference-manifest.js +1 -1
  116. package/app/.next-cli-build/server/app/dashboard/settings/pricing.html +1 -1
  117. package/app/.next-cli-build/server/app/dashboard/settings/pricing.rsc +3 -3
  118. package/app/.next-cli-build/server/app/dashboard/settings/pricing.segments/_full.segment.rsc +3 -3
  119. package/app/.next-cli-build/server/app/dashboard/settings/pricing.segments/_tree.segment.rsc +1 -1
  120. package/app/.next-cli-build/server/app/dashboard/settings/pricing.segments/dashboard/settings/pricing/__PAGE__.segment.rsc +3 -3
  121. package/app/.next-cli-build/server/app/dashboard/skills.html +1 -1
  122. package/app/.next-cli-build/server/app/dashboard/skills.rsc +4 -4
  123. package/app/.next-cli-build/server/app/dashboard/skills.segments/!KGRhc2hib2FyZCk/dashboard/skills/__PAGE__.segment.rsc +4 -4
  124. package/app/.next-cli-build/server/app/dashboard/skills.segments/_full.segment.rsc +4 -4
  125. package/app/.next-cli-build/server/app/dashboard/skills.segments/_tree.segment.rsc +1 -1
  126. package/app/.next-cli-build/server/app/dashboard/token-saver.html +1 -1
  127. package/app/.next-cli-build/server/app/dashboard/token-saver.rsc +3 -3
  128. package/app/.next-cli-build/server/app/dashboard/token-saver.segments/!KGRhc2hib2FyZCk/dashboard/token-saver/__PAGE__.segment.rsc +3 -3
  129. package/app/.next-cli-build/server/app/dashboard/token-saver.segments/_full.segment.rsc +3 -3
  130. package/app/.next-cli-build/server/app/dashboard/token-saver.segments/_tree.segment.rsc +1 -1
  131. package/app/.next-cli-build/server/app/dashboard/translator.html +1 -1
  132. package/app/.next-cli-build/server/app/dashboard/translator.rsc +3 -3
  133. package/app/.next-cli-build/server/app/dashboard/translator.segments/!KGRhc2hib2FyZCk/dashboard/translator/__PAGE__.segment.rsc +3 -3
  134. package/app/.next-cli-build/server/app/dashboard/translator.segments/_full.segment.rsc +3 -3
  135. package/app/.next-cli-build/server/app/dashboard/translator.segments/_tree.segment.rsc +1 -1
  136. package/app/.next-cli-build/server/app/dashboard/usage.html +1 -1
  137. package/app/.next-cli-build/server/app/dashboard/usage.rsc +3 -3
  138. package/app/.next-cli-build/server/app/dashboard/usage.segments/!KGRhc2hib2FyZCk/dashboard/usage/__PAGE__.segment.rsc +3 -3
  139. package/app/.next-cli-build/server/app/dashboard/usage.segments/_full.segment.rsc +3 -3
  140. package/app/.next-cli-build/server/app/dashboard/usage.segments/_tree.segment.rsc +1 -1
  141. package/app/.next-cli-build/server/app/dashboard.html +1 -1
  142. package/app/.next-cli-build/server/app/dashboard.rsc +3 -3
  143. package/app/.next-cli-build/server/app/dashboard.segments/!KGRhc2hib2FyZCk/dashboard/__PAGE__.segment.rsc +3 -3
  144. package/app/.next-cli-build/server/app/dashboard.segments/_full.segment.rsc +3 -3
  145. package/app/.next-cli-build/server/app/dashboard.segments/_tree.segment.rsc +1 -1
  146. package/app/.next-cli-build/server/app/index.html +1 -1
  147. package/app/.next-cli-build/server/app/index.rsc +3 -3
  148. package/app/.next-cli-build/server/app/index.segments/__PAGE__.segment.rsc +3 -3
  149. package/app/.next-cli-build/server/app/index.segments/_full.segment.rsc +3 -3
  150. package/app/.next-cli-build/server/app/index.segments/_tree.segment.rsc +1 -1
  151. package/app/.next-cli-build/server/app/landing/page.js +1 -1
  152. package/app/.next-cli-build/server/app/landing/page_client-reference-manifest.js +1 -1
  153. package/app/.next-cli-build/server/app/landing.html +1 -1
  154. package/app/.next-cli-build/server/app/landing.rsc +4 -4
  155. package/app/.next-cli-build/server/app/landing.segments/_full.segment.rsc +4 -4
  156. package/app/.next-cli-build/server/app/landing.segments/_tree.segment.rsc +1 -1
  157. package/app/.next-cli-build/server/app/landing.segments/landing/__PAGE__.segment.rsc +4 -4
  158. package/app/.next-cli-build/server/app/login/page_client-reference-manifest.js +1 -1
  159. package/app/.next-cli-build/server/app/login.html +1 -1
  160. package/app/.next-cli-build/server/app/login.rsc +3 -3
  161. package/app/.next-cli-build/server/app/login.segments/_full.segment.rsc +3 -3
  162. package/app/.next-cli-build/server/app/login.segments/_tree.segment.rsc +1 -1
  163. package/app/.next-cli-build/server/app/login.segments/login/__PAGE__.segment.rsc +3 -3
  164. package/app/.next-cli-build/server/app/page_client-reference-manifest.js +1 -1
  165. package/app/.next-cli-build/server/app-paths-manifest.json +19 -19
  166. package/app/.next-cli-build/server/chunks/1133.js +1 -1
  167. package/app/.next-cli-build/server/chunks/1350.js +1 -1
  168. package/app/.next-cli-build/server/chunks/2318.js +1 -1
  169. package/app/.next-cli-build/server/chunks/2764.js +1 -1
  170. package/app/.next-cli-build/server/chunks/412.js +1 -1
  171. package/app/.next-cli-build/server/chunks/436.js +1 -1
  172. package/app/.next-cli-build/server/chunks/6877.js +1 -1
  173. package/app/.next-cli-build/server/chunks/716.js +1 -1
  174. package/app/.next-cli-build/server/chunks/8238.js +1 -1
  175. package/app/.next-cli-build/server/middleware-build-manifest.js +1 -1
  176. package/app/.next-cli-build/server/pages/404.html +1 -1
  177. package/app/.next-cli-build/server/pages/500.html +1 -1
  178. package/app/.next-cli-build/static/chunks/{1321-440881581f681064.js → 1321-5df4810e8afd778c.js} +1 -1
  179. package/app/.next-cli-build/static/chunks/{5497-c9880cc4ac4d8a42.js → 5497-3037d5b4d70a4cc8.js} +2 -2
  180. package/app/.next-cli-build/static/chunks/app/(dashboard)/dashboard/skills/page-bccc40958c066d7a.js +1 -0
  181. package/app/.next-cli-build/static/chunks/app/landing/page-f3fcddb38b64683f.js +1 -0
  182. package/app/cli/.build-home/AppData/Roaming/9router/db/data.sqlite-shm +0 -0
  183. package/app/cli/.build-home/AppData/Roaming/9router/db/data.sqlite-wal +0 -0
  184. package/app/package.json +2 -1
  185. package/app/public/paypal-qr.png +0 -0
  186. package/app/public/paypal-qr.svg +1 -0
  187. package/cli.js +888 -888
  188. package/package.json +3 -2
  189. package/app/.next-cli-build/static/chunks/app/(dashboard)/dashboard/skills/page-7ce0c03d90f7fa44.js +0 -1
  190. package/app/.next-cli-build/static/chunks/app/landing/page-c2281d0269a6adab.js +0 -1
  191. /package/app/.next-cli-build/static/{IWMKFD-rCH-KfRpvTgTqk → s5jg02dt7_Ke0ZVHd5w0-}/_buildManifest.js +0 -0
  192. /package/app/.next-cli-build/static/{IWMKFD-rCH-KfRpvTgTqk → s5jg02dt7_Ke0ZVHd5w0-}/_ssgManifest.js +0 -0
package/cli.js CHANGED
@@ -1,888 +1,888 @@
1
- #!/usr/bin/env node
2
-
3
- const { spawn, exec, execSync } = require("child_process");
4
- const path = require("path");
5
- const fs = require("fs");
6
- const https = require("https");
7
- const net = require("net");
8
- const os = require("os");
9
-
10
- // Poll until the server accepts TCP connections on port, or timeout — avoids blind fixed waits.
11
- function waitServerReady(port, { timeoutMs = 15000, intervalMs = 150 } = {}) {
12
- const deadline = Date.now() + timeoutMs;
13
- return new Promise((resolve) => {
14
- const tryConnect = () => {
15
- const socket = net.connect({ host: "127.0.0.1", port }, () => {
16
- socket.destroy();
17
- resolve(true);
18
- });
19
- socket.on("error", () => {
20
- socket.destroy();
21
- if (Date.now() >= deadline) return resolve(false);
22
- setTimeout(tryConnect, intervalMs);
23
- });
24
- };
25
- tryConnect();
26
- });
27
- }
28
-
29
- // Native spinner - no external dependency
30
- function createSpinner(text) {
31
- const frames = ["ā ‹", "ā ™", "ā ¹", "ā ø", "ā ¼", "ā “", "ā ¦", "ā §", "ā ‡", "ā "];
32
- let i = 0;
33
- let interval = null;
34
- let currentText = text;
35
- return {
36
- start() {
37
- if (process.stdout.isTTY) {
38
- process.stdout.write(`\r${frames[0]} ${currentText}`);
39
- interval = setInterval(() => {
40
- process.stdout.write(`\r${frames[i++ % frames.length]} ${currentText}`);
41
- }, 80);
42
- }
43
- return this;
44
- },
45
- stop() {
46
- if (interval) {
47
- clearInterval(interval);
48
- interval = null;
49
- }
50
- if (process.stdout.isTTY) {
51
- process.stdout.write("\r\x1b[K");
52
- }
53
- },
54
- succeed(msg) {
55
- this.stop();
56
- console.log(`āœ… ${msg}`);
57
- },
58
- fail(msg) {
59
- this.stop();
60
- console.log(`āŒ ${msg}`);
61
- }
62
- };
63
- }
64
-
65
- const pkg = require("./package.json");
66
- const { ensureSqliteRuntime, buildEnvWithRuntime } = require("./hooks/sqliteRuntime");
67
- const { ensureTrayRuntime } = require("./hooks/trayRuntime");
68
- const args = process.argv.slice(2);
69
-
70
- // Subcommands (`9router xai video …`) run against an already-running gateway
71
- // and bypass the launcher flow (no runtime self-heal, no server spawn).
72
- if (args[0] === "xai" && args[1] === "video") {
73
- const { run } = require("./src/cli/commands/xaiVideo");
74
- run(args.slice(2))
75
- .then((code) => process.exit(code))
76
- .catch((err) => {
77
- console.error(`āŒ ${err?.message || err}`);
78
- process.exit(1);
79
- });
80
- return;
81
- }
82
-
83
- // `9router connect <url>` configures local CLI tools against a remote server, and
84
- // `9router show` prints what they're currently set to — no local server, no
85
- // runtime deps. Usable via `npx 9router …`.
86
- if (args[0] === "connect" || args[0] === "show") {
87
- const { run } = require(args[0] === "show" ? "./src/cli/commands/show" : "./src/cli/commands/connect");
88
- run(args.slice(1))
89
- .then((code) => process.exit(code))
90
- .catch((err) => {
91
- console.error(`āŒ ${err?.message || err}`);
92
- process.exit(1);
93
- });
94
- return;
95
- }
96
-
97
- // Self-heal SQLite runtime deps (sql.js + better-sqlite3) into ~/.9router/runtime
98
- // so the server can resolve them via NODE_PATH. Best-effort — sql.js is required,
99
- // better-sqlite3 is optional. Logs to stderr only on failure.
100
- try { ensureSqliteRuntime({ silent: true }); } catch {}
101
-
102
- // Self-heal tray runtime (systray for macOS/Linux only). Windows skipped.
103
- try { ensureTrayRuntime({ silent: true }); } catch {}
104
-
105
- // Configuration constants
106
- const APP_NAME = pkg.name; // Use from package.json
107
- const INSTALL_CMD_LATEST = `npm i -g ${APP_NAME}@latest --prefer-online`;
108
-
109
- const DEFAULT_PORT = 20128;
110
- const DEFAULT_HOST = "0.0.0.0";
111
-
112
- // First non-internal IPv4 — the address remote peers actually reach when bound to 0.0.0.0.
113
- function getLanIp() {
114
- for (const ifaces of Object.values(os.networkInterfaces())) {
115
- for (const i of ifaces || []) {
116
- if (i.family === "IPv4" && !i.internal) return i.address;
117
- }
118
- }
119
- return null;
120
- }
121
-
122
- // Local URL stays "localhost"; warn separately when bound to all interfaces (network-exposed).
123
- function getDisplayHost() {
124
- return host === DEFAULT_HOST ? "localhost" : host;
125
- }
126
- const MAX_PORT_ATTEMPTS = 10;
127
- // Identifiers for killAllAppProcesses - only kill 9router specifically
128
- const PROCESS_IDENTIFIERS = [
129
- '9router' // Only package name - avoid killing other apps
130
- ];
131
-
132
- // Parse arguments
133
- let port = DEFAULT_PORT;
134
- let host = DEFAULT_HOST;
135
- let noBrowser = false;
136
- let skipUpdate = false;
137
- let showLog = false;
138
- let trayMode = false;
139
-
140
- for (let i = 0; i < args.length; i++) {
141
- if (args[i] === "--port" || args[i] === "-p") {
142
- port = parseInt(args[i + 1], 10) || DEFAULT_PORT;
143
- i++;
144
- } else if (args[i] === "--host" || args[i] === "-H") {
145
- host = args[i + 1] || DEFAULT_HOST;
146
- i++;
147
- } else if (args[i] === "--no-browser" || args[i] === "-n") {
148
- noBrowser = true;
149
- } else if (args[i] === "--log" || args[i] === "-l") {
150
- showLog = true;
151
- } else if (args[i] === "--skip-update") {
152
- skipUpdate = true;
153
- } else if (args[i] === "--tray" || args[i] === "-t") {
154
- trayMode = true;
155
- process.env.TRAY_MODE = "1";
156
- } else if (args[i] === "--help" || args[i] === "-h") {
157
- console.log(`
158
- Usage: ${APP_NAME} [options]
159
-
160
- Options:
161
- -p, --port <port> Port to run the server (default: ${DEFAULT_PORT})
162
- -H, --host <host> Host to bind (default: ${DEFAULT_HOST})
163
- -n, --no-browser Don't open browser automatically
164
- -l, --log Show server logs (default: hidden)
165
- -t, --tray Run in system tray mode (background)
166
- --skip-update Skip auto-update check
167
- -h, --help Show this help message
168
- -v, --version Show version
169
-
170
- Commands:
171
- connect <server-url> Configure Claude Code for a remote 9router server
172
- (npx 9router connect http://host:20128 — no install needed)
173
- show [tool…] Show each CLI tool's current 9router config and models
174
- (e.g. 9router show claude)
175
- xai video --prompt "..." --output video.mp4
176
- Generate a Grok Imagine video via the running gateway
177
- (see: ${APP_NAME} xai video --help)
178
- `);
179
- process.exit(0);
180
- } else if (args[i] === "--version" || args[i] === "-v") {
181
- console.log(pkg.version);
182
- process.exit(0);
183
- }
184
- }
185
-
186
- // Auto-relaunch after update: detached process has no TTY → fallback to tray
187
- if (skipUpdate && !trayMode && !process.stdin.isTTY) {
188
- trayMode = true;
189
- process.env.TRAY_MODE = "1";
190
- }
191
-
192
- // Always use Node.js runtime with absolute path
193
- const RUNTIME = process.execPath;
194
-
195
- // Compare semver versions: returns 1 if a > b, -1 if a < b, 0 if equal
196
- function compareVersions(a, b) {
197
- const partsA = a.split(".").map(Number);
198
- const partsB = b.split(".").map(Number);
199
- for (let i = 0; i < 3; i++) {
200
- if (partsA[i] > partsB[i]) return 1;
201
- if (partsA[i] < partsB[i]) return -1;
202
- }
203
- return 0;
204
- }
205
-
206
- // Get app data dir (matches app/src/lib/dataDir.js convention)
207
- function getAppDataDir() {
208
- return process.platform === "win32"
209
- ? path.join(process.env.APPDATA || "", "9router")
210
- : path.join(os.homedir(), ".9router");
211
- }
212
-
213
- // Kill PID from file (best-effort, removes file after)
214
- function killByPidFile(pidFile) {
215
- try {
216
- if (!fs.existsSync(pidFile)) return;
217
- const pid = parseInt(fs.readFileSync(pidFile, "utf8").trim(), 10);
218
- if (!pid) return;
219
- try {
220
- if (process.platform === "win32") {
221
- execSync(`taskkill /F /T /PID ${pid}`, { stdio: "ignore", windowsHide: true, timeout: 3000 });
222
- } else {
223
- process.kill(pid, "SIGKILL");
224
- }
225
- } catch { }
226
- try { fs.unlinkSync(pidFile); } catch { }
227
- } catch { }
228
- }
229
-
230
- // Kill tunnel processes (cloudflared/tailscale) by their PID files
231
- function killTunnelByPidFile() {
232
- const tunnelDir = path.join(getAppDataDir(), "tunnel");
233
- killByPidFile(path.join(tunnelDir, "cloudflared.pid"));
234
- killByPidFile(path.join(tunnelDir, "tailscale.pid"));
235
- }
236
-
237
- // Kill cloudflared whose --url targets this app's port (covers stale PID file case)
238
- function killCloudflaredByAppPort(appPort) {
239
- if (!appPort) return [];
240
- const portMatchers = [`localhost:${appPort}`, `127.0.0.1:${appPort}`];
241
- const pids = [];
242
- try {
243
- if (process.platform === "win32") {
244
- const psCmd = `powershell -NonInteractive -WindowStyle Hidden -Command "Get-WmiObject Win32_Process -Filter 'Name=\\"cloudflared.exe\\"' | Select-Object ProcessId,CommandLine | ConvertTo-Csv -NoTypeInformation"`;
245
- const output = execSync(psCmd, { encoding: "utf8", windowsHide: true, timeout: 5000 });
246
- const lines = output.split("\n").slice(1).filter(l => l.trim());
247
- lines.forEach(line => {
248
- if (portMatchers.some(m => line.includes(m))) {
249
- const match = line.match(/^"(\d+)"/);
250
- if (match && match[1]) pids.push(match[1]);
251
- }
252
- });
253
- } else {
254
- const output = execSync("ps -eo pid,command 2>/dev/null", { encoding: "utf8", timeout: 5000 });
255
- output.split("\n").forEach(line => {
256
- if (line.includes("cloudflared") && portMatchers.some(m => line.includes(m))) {
257
- const parts = line.trim().split(/\s+/);
258
- const pid = parts[0];
259
- if (pid && !isNaN(pid)) pids.push(pid);
260
- }
261
- });
262
- }
263
- } catch { }
264
- return pids;
265
- }
266
-
267
- // Kill all 9router processes
268
- function killAllAppProcesses(appPort) {
269
- return new Promise((resolve) => {
270
- try {
271
- // Background: MITM + tunnel/cloudflared run on separate ports/processes —
272
- // killing them doesn't free the app port, so don't block the critical path.
273
- // Server-side MITM manager has stale-lock recovery and starts deferred (~3s).
274
- setImmediate(() => {
275
- try { killProxyByPidFile(); } catch {}
276
- try { killTunnelByPidFile(); } catch {}
277
- try { killCloudflaredByAppPort(appPort); } catch {}
278
- });
279
-
280
- const platform = process.platform;
281
- let pids = [];
282
-
283
- if (platform === "win32") {
284
- // Windows: use WMI to get full CommandLine (tasklist /V doesn't include it)
285
- try {
286
- const psCmd = `powershell -NonInteractive -WindowStyle Hidden -Command "Get-WmiObject Win32_Process -Filter 'Name=\\"node.exe\\"' | Select-Object ProcessId,CommandLine | ConvertTo-Csv -NoTypeInformation"`;
287
- const output = execSync(psCmd, {
288
- encoding: "utf8",
289
- windowsHide: true,
290
- timeout: 5000
291
- });
292
- const lines = output.split("\n").slice(1).filter(l => l.trim());
293
- lines.forEach(line => {
294
- // Whitelist: real node process running 9router/cli.js, or next-server.
295
- // Avoids killing editors/grep/strace/cursor that just have "9router" in cmdline.
296
- const cmd = line.toLowerCase();
297
- const isAppProcess =
298
- (cmd.includes("node") && cmd.includes("9router") && (cmd.includes("cli.js") || cmd.includes("\\9router") || cmd.includes("/9router")))
299
- || cmd.includes("next-server");
300
- if (isAppProcess) {
301
- const match = line.match(/^"(\d+)"/);
302
- if (match && match[1] && match[1] !== process.pid.toString()) {
303
- pids.push(match[1]);
304
- }
305
- }
306
- });
307
- } catch (e) {
308
- // No processes found or error - continue
309
- }
310
- } else {
311
- // macOS/Linux: use ps to find all matching processes
312
- try {
313
- const output = execSync('ps aux 2>/dev/null', {
314
- encoding: 'utf8',
315
- timeout: 5000
316
- });
317
- const lines = output.split('\n');
318
-
319
- lines.forEach(line => {
320
- // Whitelist: real node process running 9router/cli.js, or next-server.
321
- // Avoids killing grep/strace/editors/cursor that incidentally match "9router".
322
- const cmd = line.toLowerCase();
323
- const isAppProcess =
324
- (cmd.includes("node") && cmd.includes("9router") && (cmd.includes("cli.js") || cmd.includes("/9router")))
325
- || cmd.includes("next-server");
326
- if (isAppProcess) {
327
- const parts = line.trim().split(/\s+/);
328
- const pid = parts[1];
329
- if (pid && !isNaN(pid) && pid !== process.pid.toString()) {
330
- pids.push(pid);
331
- }
332
- }
333
- });
334
- } catch (e) {
335
- // No processes found or error - continue
336
- }
337
- }
338
-
339
- // Kill all found processes
340
- if (pids.length > 0) {
341
- pids.forEach(pid => {
342
- try {
343
- if (platform === "win32") {
344
- execSync(`taskkill /F /PID ${pid} 2>nul`, { stdio: 'ignore', shell: true, windowsHide: true, timeout: 3000 });
345
- } else {
346
- execSync(`kill -9 ${pid} 2>/dev/null`, { stdio: 'ignore', timeout: 3000 });
347
- }
348
- } catch (err) {
349
- // Process already dead or can't kill - continue
350
- }
351
- });
352
-
353
- // Wait for processes to fully terminate
354
- setTimeout(() => resolve(), 1000);
355
- } else {
356
- resolve();
357
- }
358
- } catch (err) {
359
- // Silent fail - continue anyway
360
- resolve();
361
- }
362
- });
363
- }
364
-
365
- // Sleep helper using SharedArrayBuffer wait (sync, no busy-loop)
366
- function sleepSync(ms) {
367
- try { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); } catch { /* ignore */ }
368
- }
369
-
370
- // Wait until process dies or timeout reached
371
- function waitForExit(pid, timeoutMs) {
372
- const deadline = Date.now() + timeoutMs;
373
- while (Date.now() < deadline) {
374
- try { process.kill(pid, 0); } catch { return true; }
375
- sleepSync(100);
376
- }
377
- return false;
378
- }
379
-
380
- // Kill MIT server by PID file (runs privileged, needs special handling)
381
- // Sends SIGTERM first so MIT can clean up host entries before dying.
382
- function killProxyByPidFile() {
383
- try {
384
- const pidFile = path.join(getAppDataDir(), "mitm", ".mitm.pid");
385
- if (!fs.existsSync(pidFile)) return;
386
- const pid = parseInt(fs.readFileSync(pidFile, "utf8").trim(), 10);
387
- if (!pid) return;
388
-
389
- if (process.platform === "win32") {
390
- // Graceful first (lets server cleanup hosts), then force
391
- try { execSync(`taskkill /T /PID ${pid}`, { stdio: "ignore", windowsHide: true, timeout: 2000 }); } catch { }
392
- if (!waitForExit(pid, 1500)) {
393
- try { execSync(`taskkill /F /T /PID ${pid}`, { stdio: "ignore", windowsHide: true, timeout: 3000 }); } catch { }
394
- }
395
- // Last-resort: PowerShell Stop-Process (sometimes succeeds where taskkill fails on admin processes)
396
- if (!waitForExit(pid, 500)) {
397
- try { execSync(`powershell -NonInteractive -WindowStyle Hidden -Command "Stop-Process -Id ${pid} -Force"`, { stdio: "ignore", windowsHide: true, timeout: 3000 }); } catch { }
398
- }
399
- } else {
400
- // SIGTERM via cached sudo token first
401
- try { execSync(`sudo -n kill -TERM ${pid} 2>/dev/null`, { stdio: "ignore", timeout: 2000 }); }
402
- catch { try { process.kill(pid, "SIGTERM"); } catch { } }
403
- if (!waitForExit(pid, 1500)) {
404
- try { execSync(`sudo -n kill -9 ${pid} 2>/dev/null`, { stdio: "ignore", timeout: 2000 }); }
405
- catch { try { process.kill(pid, "SIGKILL"); } catch { } }
406
- }
407
- }
408
- try { fs.unlinkSync(pidFile); } catch { }
409
- } catch { }
410
- }
411
-
412
- // Kill any process on specific port
413
- function killProcessOnPort(port) {
414
- return new Promise((resolve) => {
415
- try {
416
- const platform = process.platform;
417
- let pid;
418
-
419
- if (platform === "win32") {
420
- try {
421
- const output = execSync(`netstat -ano | findstr :${port}`, {
422
- encoding: 'utf8',
423
- shell: true,
424
- windowsHide: true,
425
- timeout: 5000
426
- }).trim();
427
- const lines = output.split('\n').filter(l => l.includes('LISTENING'));
428
- if (lines.length > 0) {
429
- pid = lines[0].trim().split(/\s+/).pop();
430
- execSync(`taskkill /F /PID ${pid} 2>nul`, { stdio: 'ignore', shell: true, windowsHide: true, timeout: 3000 });
431
- }
432
- } catch (e) {
433
- // Port is free or error
434
- }
435
- } else {
436
- // macOS/Linux
437
- try {
438
- const pidOutput = execSync(`lsof -ti:${port}`, {
439
- encoding: 'utf8',
440
- stdio: ['pipe', 'pipe', 'ignore']
441
- }).trim();
442
- if (pidOutput) {
443
- pid = pidOutput.split('\n')[0];
444
- execSync(`kill -9 ${pid} 2>/dev/null`, { stdio: 'ignore', timeout: 3000 });
445
- }
446
- } catch (e) {
447
- // Port is free or error
448
- }
449
- }
450
-
451
- // Wait for port to be released
452
- setTimeout(() => resolve(), 500);
453
- } catch (err) {
454
- // Silent fail - continue anyway
455
- resolve();
456
- }
457
- });
458
- }
459
-
460
-
461
- // Detect if running in restricted environment (Codespaces, Docker)
462
- function isRestrictedEnvironment() {
463
- // Check for Codespaces
464
- if (process.env.CODESPACES === "true" || process.env.GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN) {
465
- return "GitHub Codespaces";
466
- }
467
-
468
- // Check for Docker
469
- if (fs.existsSync("/.dockerenv") || (fs.existsSync("/proc/1/cgroup") && fs.readFileSync("/proc/1/cgroup", "utf8").includes("docker"))) {
470
- return "Docker";
471
- }
472
-
473
- return null;
474
- }
475
-
476
- // Check if new version available, return latest version or null
477
- function checkForUpdate() {
478
- return new Promise((resolve) => {
479
- if (skipUpdate) {
480
- resolve(null);
481
- return;
482
- }
483
-
484
- const spinner = createSpinner("Checking for updates...").start();
485
- let resolved = false;
486
-
487
- const safetyTimeout = setTimeout(() => {
488
- if (!resolved) {
489
- resolved = true;
490
- spinner.stop();
491
- resolve(null);
492
- }
493
- }, 8000);
494
-
495
- const done = (version) => {
496
- if (resolved) return;
497
- resolved = true;
498
- clearTimeout(safetyTimeout);
499
- spinner.stop();
500
- resolve(version);
501
- };
502
-
503
- const req = https.get(`https://registry.npmjs.org/${pkg.name}/latest`, { timeout: 3000 }, (res) => {
504
- let data = "";
505
- res.on("data", chunk => data += chunk);
506
- res.on("end", () => {
507
- try {
508
- const latest = JSON.parse(data);
509
- if (latest.version && compareVersions(latest.version, pkg.version) > 0) {
510
- done(latest.version);
511
- } else {
512
- done(null);
513
- }
514
- } catch (e) {
515
- done(null);
516
- }
517
- });
518
- });
519
-
520
- req.on("error", () => done(null));
521
- req.on("timeout", () => { req.destroy(); done(null); });
522
- });
523
- }
524
-
525
- // Open browser
526
- function openBrowser(url) {
527
- const platform = process.platform;
528
- let cmd;
529
-
530
- if (platform === "darwin") {
531
- cmd = `open "${url}"`;
532
- } else if (platform === "win32") {
533
- cmd = `start "" "${url}"`;
534
- } else {
535
- cmd = `xdg-open "${url}"`;
536
- }
537
-
538
- exec(cmd, { windowsHide: true }, (err) => {
539
- if (err) {
540
- console.log(`Open browser manually: ${url}`);
541
- }
542
- });
543
- }
544
-
545
- // Find standalone server (bundled in bin/app for published package).
546
- // Prefer custom-server.js (injects real socket IP) when present.
547
- const standaloneDir = path.join(__dirname, "app");
548
- const customServerPath = path.join(standaloneDir, "custom-server.js");
549
- const serverPath = fs.existsSync(customServerPath)
550
- ? customServerPath
551
- : path.join(standaloneDir, "server.js");
552
-
553
- if (!fs.existsSync(serverPath)) {
554
- console.error("Error: Standalone build not found.");
555
- console.error("Please run 'npm run build:cli' first.");
556
- process.exit(1);
557
- }
558
-
559
- // Start server immediately; run update check in parallel (not on the critical path).
560
- const updatePromise = checkForUpdate();
561
- killAllAppProcesses(port)
562
- .then(() => killProcessOnPort(port))
563
- .then(() => startServer(updatePromise));
564
-
565
- // Show interface selection menu
566
- async function showInterfaceMenu(latestVersion) {
567
- const { selectMenu } = require("./src/cli/utils/input");
568
- const { clearScreen } = require("./src/cli/utils/display");
569
- const { getEndpoint } = require("./src/cli/utils/endpoint");
570
-
571
- clearScreen();
572
-
573
- const displayHost = getDisplayHost();
574
-
575
- // Detect tunnel/local mode for server URL display
576
- let serverUrl;
577
- try {
578
- const { endpoint, tunnelEnabled } = await getEndpoint(port);
579
- serverUrl = tunnelEnabled ? endpoint.replace(/\/v1$/, "") : `http://${displayHost}:${port}`;
580
- } catch (e) {
581
- serverUrl = `http://${displayHost}:${port}`;
582
- }
583
-
584
- const subtitle = `šŸš€ Server: \x1b[32m${serverUrl}\x1b[0m`;
585
-
586
- const menuItems = [];
587
-
588
- if (latestVersion) {
589
- menuItems.push({ label: `Update to v${latestVersion} (current: v${pkg.version})`, icon: "⬆" });
590
- }
591
-
592
- menuItems.push(
593
- { label: "Web UI (Open in Browser)", icon: "🌐" },
594
- { label: "Terminal UI (Interactive CLI)", icon: "šŸ’»" },
595
- { label: "Hide to Tray (Background)", icon: "šŸ””" },
596
- { label: "Exit", icon: "🚪" }
597
- );
598
-
599
- const selected = await selectMenu(`Choose Interface (v${pkg.version})`, menuItems, 0, subtitle);
600
-
601
- const offset = latestVersion ? 1 : 0;
602
-
603
- if (latestVersion && selected === 0) return "update";
604
- if (selected === offset) return "web";
605
- if (selected === offset + 1) return "terminal";
606
- if (selected === offset + 2) return "hide";
607
- return "exit";
608
- }
609
-
610
- const MAX_RESTARTS = 2;
611
- const RESTART_RESET_MS = 30000; // Reset counter if alive > 30s
612
-
613
- function startServer(updatePromise) {
614
- // Accept either a Promise (parallel update check) or a resolved value.
615
- const latestVersionPromise = Promise.resolve(updatePromise);
616
- const displayHost = getDisplayHost();
617
- const url = `http://${displayHost}:${port}/dashboard`;
618
- // Surface real network exposure when bound to all interfaces (default 0.0.0.0).
619
- if (host === DEFAULT_HOST) {
620
- const lanIp = getLanIp();
621
- if (lanIp) console.log(`\x1b[33m⚠ Network-exposed: reachable at http://${lanIp}:${port} (bound 0.0.0.0). Use --host 127.0.0.1 for local-only.\x1b[0m`);
622
- }
623
-
624
- let restartCount = 0;
625
- let serverStartTime = Date.now();
626
-
627
- const CRASH_LOG_LINES = 50;
628
- let crashLog = [];
629
-
630
- function spawnServer() {
631
- serverStartTime = Date.now();
632
- crashLog = [];
633
- const child = spawn(RUNTIME, ["--dns-result-order=ipv4first", "--max-old-space-size=6144", serverPath], {
634
- cwd: standaloneDir,
635
- stdio: showLog ? "inherit" : ["ignore", "ignore", "pipe"],
636
- detached: true,
637
- windowsHide: true,
638
- env: {
639
- ...buildEnvWithRuntime(process.env),
640
- PORT: port.toString(),
641
- HOSTNAME: host
642
- }
643
- });
644
- if (!showLog && child.stderr) {
645
- child.stderr.on("data", (data) => {
646
- const lines = data.toString().split("\n").filter(Boolean);
647
- crashLog.push(...lines);
648
- if (crashLog.length > CRASH_LOG_LINES) crashLog = crashLog.slice(-CRASH_LOG_LINES);
649
- });
650
- }
651
- return child;
652
- }
653
-
654
- let server = spawnServer();
655
-
656
- // Cleanup function - force kill server process
657
- let isCleaningUp = false;
658
- function cleanup() {
659
- if (isCleaningUp) return;
660
- isCleaningUp = true;
661
- try {
662
- // Kill tray if running
663
- try {
664
- const { killTray } = require("./src/cli/tray/tray");
665
- killTray();
666
- } catch (e) { }
667
- // Kill MIT server (privileged process) via PID file
668
- killProxyByPidFile();
669
- // Kill cloudflared/tailscale via PID file (only this app's tunnel)
670
- killTunnelByPidFile();
671
- // Kill server process directly
672
- if (server.pid) {
673
- process.kill(server.pid, "SIGKILL");
674
- }
675
- // Also try to kill process group
676
- process.kill(-server.pid, "SIGKILL");
677
- } catch (e) { }
678
- }
679
-
680
- // Suppress all errors during shutdown (systray lib throws JSON parse errors)
681
- let isShuttingDown = false;
682
- process.on("uncaughtException", (err) => {
683
- if (isShuttingDown) return;
684
- console.error("Error:", err.message);
685
- });
686
-
687
- // Handle all exit scenarios
688
- process.on("SIGINT", () => {
689
- if (isShuttingDown) return;
690
- isShuttingDown = true;
691
- console.log("\nExiting...");
692
- cleanup();
693
- setTimeout(() => process.exit(0), 100);
694
- });
695
- process.on("SIGTERM", () => {
696
- if (isShuttingDown) return;
697
- isShuttingDown = true;
698
- cleanup();
699
- setTimeout(() => process.exit(0), 100);
700
- });
701
- process.on("SIGHUP", () => {
702
- if (isShuttingDown) return;
703
- isShuttingDown = true;
704
- cleanup();
705
- setTimeout(() => process.exit(0), 100);
706
- });
707
-
708
- // Initialize tray icon (runs alongside TUI)
709
- const initTrayIcon = () => {
710
- try {
711
- const { initTray } = require("./src/cli/tray/tray");
712
- initTray({
713
- port,
714
- onQuit: () => {
715
- isShuttingDown = true;
716
- console.log("\nšŸ‘‹ Shutting down from tray...");
717
- cleanup();
718
- setTimeout(() => process.exit(0), 100);
719
- },
720
- onOpenDashboard: () => openBrowser(url)
721
- });
722
- } catch (err) {
723
- // Tray not available - continue without it
724
- }
725
- };
726
-
727
- // Tray-only mode: no TUI, just tray icon
728
- if (trayMode) {
729
- // Ignore SIGHUP so macOS terminal close doesn't kill the background tray process
730
- process.removeAllListeners("SIGHUP");
731
- process.on("SIGHUP", () => {});
732
-
733
- console.log(`\nšŸš€ ${pkg.name} v${pkg.version}`);
734
- console.log(`Server: http://${displayHost}:${port}`);
735
-
736
- waitServerReady(port).then(() => {
737
- initTrayIcon();
738
- console.log("\nšŸ’” Router is now running in system tray. Close this terminal if you want.");
739
- console.log(" Right-click tray icon to open dashboard or quit.\n");
740
- });
741
-
742
- return;
743
- }
744
-
745
- // Wait for server to be ready, then show interface menu loop + tray
746
- waitServerReady(port).then(async () => {
747
- // Resolve parallel update check (already running); don't block server start on it.
748
- const latestVersion = await latestVersionPromise;
749
- // Start tray icon alongside TUI
750
- initTrayIcon();
751
-
752
- try {
753
- while (true) {
754
- const choice = await showInterfaceMenu(latestVersion);
755
-
756
- if (choice === "update") {
757
- isShuttingDown = true;
758
- const { clearScreen } = require("./src/cli/utils/display");
759
- clearScreen();
760
- console.log(`\n⬆ Update v${pkg.version} → v${latestVersion}\n`);
761
- console.log(`Run this after exit:\n`);
762
- console.log(` \x1b[33m${INSTALL_CMD_LATEST}\x1b[0m\n`);
763
- cleanup();
764
- await killAllAppProcesses(port);
765
- await killProcessOnPort(port);
766
- setTimeout(() => process.exit(0), 200);
767
- return;
768
- } else if (choice === "web") {
769
- openBrowser(url);
770
- // Wait for user to come back
771
- const { pause } = require("./src/cli/utils/input");
772
- await pause("\nPress Enter to go back to menu...");
773
- } else if (choice === "terminal") {
774
- // Start Terminal UI - it will return when user selects Back
775
- const { startTerminalUI } = require("./src/cli/terminalUI");
776
- await startTerminalUI(port);
777
- // Loop continues, show menu again
778
- } else if (choice === "hide") {
779
- const { clearScreen } = require("./src/cli/utils/display");
780
- clearScreen();
781
-
782
- // Enable auto startup on OS boot
783
- try {
784
- const { enableAutoStart } = require("./src/cli/tray/autostart");
785
- enableAutoStart(__filename);
786
- } catch (e) { }
787
-
788
- if (process.platform === "darwin") {
789
- // macOS: keep current process alive — spawning a detached child puts
790
- // it outside the login session so NSStatusItem silently fails.
791
- process.removeAllListeners("SIGHUP");
792
- process.on("SIGHUP", () => {});
793
-
794
- console.log(`\nā³ Switching to tray mode... (icon already visible in menu bar)`);
795
- console.log(`šŸ”” 9Router is running in tray (PID: ${process.pid})`);
796
- console.log(` Server: http://${displayHost}:${port}`);
797
- console.log(`\nšŸ’” You can close this terminal. Right-click tray icon to quit.\n`);
798
-
799
- // Tray already init'd at startup — just keep event loop alive.
800
- return;
801
- }
802
-
803
- // Windows/Linux: spawn detached bgProcess (systray works fine in child)
804
- console.log(`\nā³ Starting background process... (tray icon will appear in ~3s)`);
805
-
806
- const bgProcess = spawn(process.execPath, ["--dns-result-order=ipv4first", __filename, "--tray", "--skip-update", "-p", port.toString()], {
807
- detached: true,
808
- stdio: "ignore",
809
- windowsHide: true,
810
- env: { ...process.env }
811
- });
812
- bgProcess.unref();
813
-
814
- console.log(`šŸ”” 9Router is now running in background (PID: ${bgProcess.pid})`);
815
- console.log(` Server: http://${displayHost}:${port}`);
816
- console.log(`\nšŸ’” You can close this terminal. Right-click tray icon to quit.\n`);
817
-
818
- // cleanup() kills server so bgProcess can claim the port fresh
819
- cleanup();
820
- process.exit(0);
821
- } else if (choice === "exit") {
822
- isShuttingDown = true;
823
- console.log("\nExiting...");
824
- cleanup();
825
- setTimeout(() => process.exit(0), 100);
826
- }
827
- }
828
- } catch (err) {
829
- console.error("Error:", err.message);
830
- cleanup();
831
- process.exit(1);
832
- }
833
- });
834
-
835
- function attachServerEvents() {
836
- server.on("error", (err) => {
837
- console.error("Failed to start server:", err.message);
838
- if (!isShuttingDown) tryRestart();
839
- else { cleanup(); process.exit(1); }
840
- });
841
-
842
- server.on("close", (code) => {
843
- if (isShuttingDown || code === 0) {
844
- process.exit(code || 0);
845
- return;
846
- }
847
- tryRestart(code);
848
- });
849
- }
850
-
851
- function tryRestart(code) {
852
- const aliveMs = Date.now() - serverStartTime;
853
- // Reset counter if last run was stable
854
- if (aliveMs >= RESTART_RESET_MS) restartCount = 0;
855
-
856
- if (restartCount >= MAX_RESTARTS) {
857
- console.error(`\nāš ļø Server crashed ${MAX_RESTARTS} times. Disabling MIT and restarting...`);
858
- try {
859
- const dbPath = path.join(os.homedir(), process.platform === "win32" ? path.join("AppData", "Roaming", "9router", "db.json") : path.join(".9router", "db.json"));
860
- if (fs.existsSync(dbPath)) {
861
- const db = JSON.parse(fs.readFileSync(dbPath, "utf-8"));
862
- if (db.settings) db.settings.mitmEnabled = false;
863
- fs.writeFileSync(dbPath, JSON.stringify(db, null, 2));
864
- }
865
- } catch { /* best effort */ }
866
- restartCount = 0;
867
- server = spawnServer();
868
- attachServerEvents();
869
- return;
870
- }
871
-
872
- restartCount++;
873
- const delay = Math.min(1000 * restartCount, 10000);
874
- console.error(`\nāš ļø Server exited (code=${code ?? "unknown"}). Restarting in ${delay / 1000}s... (${restartCount}/${MAX_RESTARTS})`);
875
- if (crashLog.length) {
876
- console.error("\n--- Server crash log ---");
877
- crashLog.forEach(l => console.error(l));
878
- console.error("--- End crash log ---\n");
879
- }
880
-
881
- setTimeout(() => {
882
- server = spawnServer();
883
- attachServerEvents();
884
- }, delay);
885
- }
886
-
887
- attachServerEvents();
888
- }
1
+ #!/usr/bin/env node
2
+
3
+ const { spawn, exec, execSync } = require("child_process");
4
+ const path = require("path");
5
+ const fs = require("fs");
6
+ const https = require("https");
7
+ const net = require("net");
8
+ const os = require("os");
9
+
10
+ // Poll until the server accepts TCP connections on port, or timeout — avoids blind fixed waits.
11
+ function waitServerReady(port, { timeoutMs = 15000, intervalMs = 150 } = {}) {
12
+ const deadline = Date.now() + timeoutMs;
13
+ return new Promise((resolve) => {
14
+ const tryConnect = () => {
15
+ const socket = net.connect({ host: "127.0.0.1", port }, () => {
16
+ socket.destroy();
17
+ resolve(true);
18
+ });
19
+ socket.on("error", () => {
20
+ socket.destroy();
21
+ if (Date.now() >= deadline) return resolve(false);
22
+ setTimeout(tryConnect, intervalMs);
23
+ });
24
+ };
25
+ tryConnect();
26
+ });
27
+ }
28
+
29
+ // Native spinner - no external dependency
30
+ function createSpinner(text) {
31
+ const frames = ["ā ‹", "ā ™", "ā ¹", "ā ø", "ā ¼", "ā “", "ā ¦", "ā §", "ā ‡", "ā "];
32
+ let i = 0;
33
+ let interval = null;
34
+ let currentText = text;
35
+ return {
36
+ start() {
37
+ if (process.stdout.isTTY) {
38
+ process.stdout.write(`\r${frames[0]} ${currentText}`);
39
+ interval = setInterval(() => {
40
+ process.stdout.write(`\r${frames[i++ % frames.length]} ${currentText}`);
41
+ }, 80);
42
+ }
43
+ return this;
44
+ },
45
+ stop() {
46
+ if (interval) {
47
+ clearInterval(interval);
48
+ interval = null;
49
+ }
50
+ if (process.stdout.isTTY) {
51
+ process.stdout.write("\r\x1b[K");
52
+ }
53
+ },
54
+ succeed(msg) {
55
+ this.stop();
56
+ console.log(`āœ… ${msg}`);
57
+ },
58
+ fail(msg) {
59
+ this.stop();
60
+ console.log(`āŒ ${msg}`);
61
+ }
62
+ };
63
+ }
64
+
65
+ const pkg = require("./package.json");
66
+ const { ensureSqliteRuntime, buildEnvWithRuntime } = require("./hooks/sqliteRuntime");
67
+ const { ensureTrayRuntime } = require("./hooks/trayRuntime");
68
+ const args = process.argv.slice(2);
69
+
70
+ // Subcommands (`9router xai video …`) run against an already-running gateway
71
+ // and bypass the launcher flow (no runtime self-heal, no server spawn).
72
+ if (args[0] === "xai" && args[1] === "video") {
73
+ const { run } = require("./src/cli/commands/xaiVideo");
74
+ run(args.slice(2))
75
+ .then((code) => process.exit(code))
76
+ .catch((err) => {
77
+ console.error(`āŒ ${err?.message || err}`);
78
+ process.exit(1);
79
+ });
80
+ return;
81
+ }
82
+
83
+ // `9router connect <url>` configures local CLI tools against a remote server, and
84
+ // `9router show` prints what they're currently set to — no local server, no
85
+ // runtime deps. Usable via `npx 9router …`.
86
+ if (args[0] === "connect" || args[0] === "show") {
87
+ const { run } = require(args[0] === "show" ? "./src/cli/commands/show" : "./src/cli/commands/connect");
88
+ run(args.slice(1))
89
+ .then((code) => process.exit(code))
90
+ .catch((err) => {
91
+ console.error(`āŒ ${err?.message || err}`);
92
+ process.exit(1);
93
+ });
94
+ return;
95
+ }
96
+
97
+ // Self-heal SQLite runtime deps (sql.js + better-sqlite3) into ~/.9router/runtime
98
+ // so the server can resolve them via NODE_PATH. Best-effort — sql.js is required,
99
+ // better-sqlite3 is optional. Logs to stderr only on failure.
100
+ try { ensureSqliteRuntime({ silent: true }); } catch {}
101
+
102
+ // Self-heal tray runtime (systray for macOS/Linux only). Windows skipped.
103
+ try { ensureTrayRuntime({ silent: true }); } catch {}
104
+
105
+ // Configuration constants
106
+ const APP_NAME = pkg.name; // Use from package.json
107
+ const INSTALL_CMD_LATEST = `npm i -g ${APP_NAME}@latest --prefer-online`;
108
+
109
+ const DEFAULT_PORT = 20128;
110
+ const DEFAULT_HOST = "0.0.0.0";
111
+
112
+ // First non-internal IPv4 — the address remote peers actually reach when bound to 0.0.0.0.
113
+ function getLanIp() {
114
+ for (const ifaces of Object.values(os.networkInterfaces())) {
115
+ for (const i of ifaces || []) {
116
+ if (i.family === "IPv4" && !i.internal) return i.address;
117
+ }
118
+ }
119
+ return null;
120
+ }
121
+
122
+ // Local URL stays "localhost"; warn separately when bound to all interfaces (network-exposed).
123
+ function getDisplayHost() {
124
+ return host === DEFAULT_HOST ? "localhost" : host;
125
+ }
126
+ const MAX_PORT_ATTEMPTS = 10;
127
+ // Identifiers for killAllAppProcesses - only kill 9router specifically
128
+ const PROCESS_IDENTIFIERS = [
129
+ '9router', 'busbar' // Only package name - avoid killing other apps
130
+ ];
131
+
132
+ // Parse arguments
133
+ let port = DEFAULT_PORT;
134
+ let host = DEFAULT_HOST;
135
+ let noBrowser = false;
136
+ let skipUpdate = false;
137
+ let showLog = false;
138
+ let trayMode = false;
139
+
140
+ for (let i = 0; i < args.length; i++) {
141
+ if (args[i] === "--port" || args[i] === "-p") {
142
+ port = parseInt(args[i + 1], 10) || DEFAULT_PORT;
143
+ i++;
144
+ } else if (args[i] === "--host" || args[i] === "-H") {
145
+ host = args[i + 1] || DEFAULT_HOST;
146
+ i++;
147
+ } else if (args[i] === "--no-browser" || args[i] === "-n") {
148
+ noBrowser = true;
149
+ } else if (args[i] === "--log" || args[i] === "-l") {
150
+ showLog = true;
151
+ } else if (args[i] === "--skip-update") {
152
+ skipUpdate = true;
153
+ } else if (args[i] === "--tray" || args[i] === "-t") {
154
+ trayMode = true;
155
+ process.env.TRAY_MODE = "1";
156
+ } else if (args[i] === "--help" || args[i] === "-h") {
157
+ console.log(`
158
+ Usage: ${APP_NAME} [options]
159
+
160
+ Options:
161
+ -p, --port <port> Port to run the server (default: ${DEFAULT_PORT})
162
+ -H, --host <host> Host to bind (default: ${DEFAULT_HOST})
163
+ -n, --no-browser Don't open browser automatically
164
+ -l, --log Show server logs (default: hidden)
165
+ -t, --tray Run in system tray mode (background)
166
+ --skip-update Skip auto-update check
167
+ -h, --help Show this help message
168
+ -v, --version Show version
169
+
170
+ Commands:
171
+ connect <server-url> Configure Claude Code for a remote 9router server
172
+ (npx 9router connect http://host:20128 — no install needed)
173
+ show [tool…] Show each CLI tool's current 9router config and models
174
+ (e.g. 9router show claude)
175
+ xai video --prompt "..." --output video.mp4
176
+ Generate a Grok Imagine video via the running gateway
177
+ (see: ${APP_NAME} xai video --help)
178
+ `);
179
+ process.exit(0);
180
+ } else if (args[i] === "--version" || args[i] === "-v") {
181
+ console.log(pkg.version);
182
+ process.exit(0);
183
+ }
184
+ }
185
+
186
+ // Auto-relaunch after update: detached process has no TTY → fallback to tray
187
+ if (skipUpdate && !trayMode && !process.stdin.isTTY) {
188
+ trayMode = true;
189
+ process.env.TRAY_MODE = "1";
190
+ }
191
+
192
+ // Always use Node.js runtime with absolute path
193
+ const RUNTIME = process.execPath;
194
+
195
+ // Compare semver versions: returns 1 if a > b, -1 if a < b, 0 if equal
196
+ function compareVersions(a, b) {
197
+ const partsA = a.split(".").map(Number);
198
+ const partsB = b.split(".").map(Number);
199
+ for (let i = 0; i < 3; i++) {
200
+ if (partsA[i] > partsB[i]) return 1;
201
+ if (partsA[i] < partsB[i]) return -1;
202
+ }
203
+ return 0;
204
+ }
205
+
206
+ // Get app data dir (matches app/src/lib/dataDir.js convention)
207
+ function getAppDataDir() {
208
+ return process.platform === "win32"
209
+ ? path.join(process.env.APPDATA || "", "9router")
210
+ : path.join(os.homedir(), ".9router");
211
+ }
212
+
213
+ // Kill PID from file (best-effort, removes file after)
214
+ function killByPidFile(pidFile) {
215
+ try {
216
+ if (!fs.existsSync(pidFile)) return;
217
+ const pid = parseInt(fs.readFileSync(pidFile, "utf8").trim(), 10);
218
+ if (!pid) return;
219
+ try {
220
+ if (process.platform === "win32") {
221
+ execSync(`taskkill /F /T /PID ${pid}`, { stdio: "ignore", windowsHide: true, timeout: 3000 });
222
+ } else {
223
+ process.kill(pid, "SIGKILL");
224
+ }
225
+ } catch { }
226
+ try { fs.unlinkSync(pidFile); } catch { }
227
+ } catch { }
228
+ }
229
+
230
+ // Kill tunnel processes (cloudflared/tailscale) by their PID files
231
+ function killTunnelByPidFile() {
232
+ const tunnelDir = path.join(getAppDataDir(), "tunnel");
233
+ killByPidFile(path.join(tunnelDir, "cloudflared.pid"));
234
+ killByPidFile(path.join(tunnelDir, "tailscale.pid"));
235
+ }
236
+
237
+ // Kill cloudflared whose --url targets this app's port (covers stale PID file case)
238
+ function killCloudflaredByAppPort(appPort) {
239
+ if (!appPort) return [];
240
+ const portMatchers = [`localhost:${appPort}`, `127.0.0.1:${appPort}`];
241
+ const pids = [];
242
+ try {
243
+ if (process.platform === "win32") {
244
+ const psCmd = `powershell -NonInteractive -WindowStyle Hidden -Command "Get-WmiObject Win32_Process -Filter 'Name=\\"cloudflared.exe\\"' | Select-Object ProcessId,CommandLine | ConvertTo-Csv -NoTypeInformation"`;
245
+ const output = execSync(psCmd, { encoding: "utf8", windowsHide: true, timeout: 5000 });
246
+ const lines = output.split("\n").slice(1).filter(l => l.trim());
247
+ lines.forEach(line => {
248
+ if (portMatchers.some(m => line.includes(m))) {
249
+ const match = line.match(/^"(\d+)"/);
250
+ if (match && match[1]) pids.push(match[1]);
251
+ }
252
+ });
253
+ } else {
254
+ const output = execSync("ps -eo pid,command 2>/dev/null", { encoding: "utf8", timeout: 5000 });
255
+ output.split("\n").forEach(line => {
256
+ if (line.includes("cloudflared") && portMatchers.some(m => line.includes(m))) {
257
+ const parts = line.trim().split(/\s+/);
258
+ const pid = parts[0];
259
+ if (pid && !isNaN(pid)) pids.push(pid);
260
+ }
261
+ });
262
+ }
263
+ } catch { }
264
+ return pids;
265
+ }
266
+
267
+ // Kill all 9router processes
268
+ function killAllAppProcesses(appPort) {
269
+ return new Promise((resolve) => {
270
+ try {
271
+ // Background: MITM + tunnel/cloudflared run on separate ports/processes —
272
+ // killing them doesn't free the app port, so don't block the critical path.
273
+ // Server-side MITM manager has stale-lock recovery and starts deferred (~3s).
274
+ setImmediate(() => {
275
+ try { killProxyByPidFile(); } catch {}
276
+ try { killTunnelByPidFile(); } catch {}
277
+ try { killCloudflaredByAppPort(appPort); } catch {}
278
+ });
279
+
280
+ const platform = process.platform;
281
+ let pids = [];
282
+
283
+ if (platform === "win32") {
284
+ // Windows: use WMI to get full CommandLine (tasklist /V doesn't include it)
285
+ try {
286
+ const psCmd = `powershell -NonInteractive -WindowStyle Hidden -Command "Get-WmiObject Win32_Process -Filter 'Name=\\"node.exe\\"' | Select-Object ProcessId,CommandLine | ConvertTo-Csv -NoTypeInformation"`;
287
+ const output = execSync(psCmd, {
288
+ encoding: "utf8",
289
+ windowsHide: true,
290
+ timeout: 5000
291
+ });
292
+ const lines = output.split("\n").slice(1).filter(l => l.trim());
293
+ lines.forEach(line => {
294
+ // Whitelist: real node process running 9router/cli.js, or next-server.
295
+ // Avoids killing editors/grep/strace/cursor that just have "9router" in cmdline.
296
+ const cmd = line.toLowerCase();
297
+ const isAppProcess =
298
+ (cmd.includes("node") && cmd.includes("9router") && (cmd.includes("cli.js") || cmd.includes("\\9router") || cmd.includes("/9router")))
299
+ || cmd.includes("next-server");
300
+ if (isAppProcess) {
301
+ const match = line.match(/^"(\d+)"/);
302
+ if (match && match[1] && match[1] !== process.pid.toString()) {
303
+ pids.push(match[1]);
304
+ }
305
+ }
306
+ });
307
+ } catch (e) {
308
+ // No processes found or error - continue
309
+ }
310
+ } else {
311
+ // macOS/Linux: use ps to find all matching processes
312
+ try {
313
+ const output = execSync('ps aux 2>/dev/null', {
314
+ encoding: 'utf8',
315
+ timeout: 5000
316
+ });
317
+ const lines = output.split('\n');
318
+
319
+ lines.forEach(line => {
320
+ // Whitelist: real node process running 9router/cli.js, or next-server.
321
+ // Avoids killing grep/strace/editors/cursor that incidentally match "9router".
322
+ const cmd = line.toLowerCase();
323
+ const isAppProcess =
324
+ (cmd.includes("node") && cmd.includes("9router") && (cmd.includes("cli.js") || cmd.includes("/9router")))
325
+ || cmd.includes("next-server");
326
+ if (isAppProcess) {
327
+ const parts = line.trim().split(/\s+/);
328
+ const pid = parts[1];
329
+ if (pid && !isNaN(pid) && pid !== process.pid.toString()) {
330
+ pids.push(pid);
331
+ }
332
+ }
333
+ });
334
+ } catch (e) {
335
+ // No processes found or error - continue
336
+ }
337
+ }
338
+
339
+ // Kill all found processes
340
+ if (pids.length > 0) {
341
+ pids.forEach(pid => {
342
+ try {
343
+ if (platform === "win32") {
344
+ execSync(`taskkill /F /PID ${pid} 2>nul`, { stdio: 'ignore', shell: true, windowsHide: true, timeout: 3000 });
345
+ } else {
346
+ execSync(`kill -9 ${pid} 2>/dev/null`, { stdio: 'ignore', timeout: 3000 });
347
+ }
348
+ } catch (err) {
349
+ // Process already dead or can't kill - continue
350
+ }
351
+ });
352
+
353
+ // Wait for processes to fully terminate
354
+ setTimeout(() => resolve(), 1000);
355
+ } else {
356
+ resolve();
357
+ }
358
+ } catch (err) {
359
+ // Silent fail - continue anyway
360
+ resolve();
361
+ }
362
+ });
363
+ }
364
+
365
+ // Sleep helper using SharedArrayBuffer wait (sync, no busy-loop)
366
+ function sleepSync(ms) {
367
+ try { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); } catch { /* ignore */ }
368
+ }
369
+
370
+ // Wait until process dies or timeout reached
371
+ function waitForExit(pid, timeoutMs) {
372
+ const deadline = Date.now() + timeoutMs;
373
+ while (Date.now() < deadline) {
374
+ try { process.kill(pid, 0); } catch { return true; }
375
+ sleepSync(100);
376
+ }
377
+ return false;
378
+ }
379
+
380
+ // Kill MIT server by PID file (runs privileged, needs special handling)
381
+ // Sends SIGTERM first so MIT can clean up host entries before dying.
382
+ function killProxyByPidFile() {
383
+ try {
384
+ const pidFile = path.join(getAppDataDir(), "mitm", ".mitm.pid");
385
+ if (!fs.existsSync(pidFile)) return;
386
+ const pid = parseInt(fs.readFileSync(pidFile, "utf8").trim(), 10);
387
+ if (!pid) return;
388
+
389
+ if (process.platform === "win32") {
390
+ // Graceful first (lets server cleanup hosts), then force
391
+ try { execSync(`taskkill /T /PID ${pid}`, { stdio: "ignore", windowsHide: true, timeout: 2000 }); } catch { }
392
+ if (!waitForExit(pid, 1500)) {
393
+ try { execSync(`taskkill /F /T /PID ${pid}`, { stdio: "ignore", windowsHide: true, timeout: 3000 }); } catch { }
394
+ }
395
+ // Last-resort: PowerShell Stop-Process (sometimes succeeds where taskkill fails on admin processes)
396
+ if (!waitForExit(pid, 500)) {
397
+ try { execSync(`powershell -NonInteractive -WindowStyle Hidden -Command "Stop-Process -Id ${pid} -Force"`, { stdio: "ignore", windowsHide: true, timeout: 3000 }); } catch { }
398
+ }
399
+ } else {
400
+ // SIGTERM via cached sudo token first
401
+ try { execSync(`sudo -n kill -TERM ${pid} 2>/dev/null`, { stdio: "ignore", timeout: 2000 }); }
402
+ catch { try { process.kill(pid, "SIGTERM"); } catch { } }
403
+ if (!waitForExit(pid, 1500)) {
404
+ try { execSync(`sudo -n kill -9 ${pid} 2>/dev/null`, { stdio: "ignore", timeout: 2000 }); }
405
+ catch { try { process.kill(pid, "SIGKILL"); } catch { } }
406
+ }
407
+ }
408
+ try { fs.unlinkSync(pidFile); } catch { }
409
+ } catch { }
410
+ }
411
+
412
+ // Kill any process on specific port
413
+ function killProcessOnPort(port) {
414
+ return new Promise((resolve) => {
415
+ try {
416
+ const platform = process.platform;
417
+ let pid;
418
+
419
+ if (platform === "win32") {
420
+ try {
421
+ const output = execSync(`netstat -ano | findstr :${port}`, {
422
+ encoding: 'utf8',
423
+ shell: true,
424
+ windowsHide: true,
425
+ timeout: 5000
426
+ }).trim();
427
+ const lines = output.split('\n').filter(l => l.includes('LISTENING'));
428
+ if (lines.length > 0) {
429
+ pid = lines[0].trim().split(/\s+/).pop();
430
+ execSync(`taskkill /F /PID ${pid} 2>nul`, { stdio: 'ignore', shell: true, windowsHide: true, timeout: 3000 });
431
+ }
432
+ } catch (e) {
433
+ // Port is free or error
434
+ }
435
+ } else {
436
+ // macOS/Linux
437
+ try {
438
+ const pidOutput = execSync(`lsof -ti:${port}`, {
439
+ encoding: 'utf8',
440
+ stdio: ['pipe', 'pipe', 'ignore']
441
+ }).trim();
442
+ if (pidOutput) {
443
+ pid = pidOutput.split('\n')[0];
444
+ execSync(`kill -9 ${pid} 2>/dev/null`, { stdio: 'ignore', timeout: 3000 });
445
+ }
446
+ } catch (e) {
447
+ // Port is free or error
448
+ }
449
+ }
450
+
451
+ // Wait for port to be released
452
+ setTimeout(() => resolve(), 500);
453
+ } catch (err) {
454
+ // Silent fail - continue anyway
455
+ resolve();
456
+ }
457
+ });
458
+ }
459
+
460
+
461
+ // Detect if running in restricted environment (Codespaces, Docker)
462
+ function isRestrictedEnvironment() {
463
+ // Check for Codespaces
464
+ if (process.env.CODESPACES === "true" || process.env.GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN) {
465
+ return "GitHub Codespaces";
466
+ }
467
+
468
+ // Check for Docker
469
+ if (fs.existsSync("/.dockerenv") || (fs.existsSync("/proc/1/cgroup") && fs.readFileSync("/proc/1/cgroup", "utf8").includes("docker"))) {
470
+ return "Docker";
471
+ }
472
+
473
+ return null;
474
+ }
475
+
476
+ // Check if new version available, return latest version or null
477
+ function checkForUpdate() {
478
+ return new Promise((resolve) => {
479
+ if (skipUpdate) {
480
+ resolve(null);
481
+ return;
482
+ }
483
+
484
+ const spinner = createSpinner("Checking for updates...").start();
485
+ let resolved = false;
486
+
487
+ const safetyTimeout = setTimeout(() => {
488
+ if (!resolved) {
489
+ resolved = true;
490
+ spinner.stop();
491
+ resolve(null);
492
+ }
493
+ }, 8000);
494
+
495
+ const done = (version) => {
496
+ if (resolved) return;
497
+ resolved = true;
498
+ clearTimeout(safetyTimeout);
499
+ spinner.stop();
500
+ resolve(version);
501
+ };
502
+
503
+ const req = https.get(`https://registry.npmjs.org/${pkg.name}/latest`, { timeout: 3000 }, (res) => {
504
+ let data = "";
505
+ res.on("data", chunk => data += chunk);
506
+ res.on("end", () => {
507
+ try {
508
+ const latest = JSON.parse(data);
509
+ if (latest.version && compareVersions(latest.version, pkg.version) > 0) {
510
+ done(latest.version);
511
+ } else {
512
+ done(null);
513
+ }
514
+ } catch (e) {
515
+ done(null);
516
+ }
517
+ });
518
+ });
519
+
520
+ req.on("error", () => done(null));
521
+ req.on("timeout", () => { req.destroy(); done(null); });
522
+ });
523
+ }
524
+
525
+ // Open browser
526
+ function openBrowser(url) {
527
+ const platform = process.platform;
528
+ let cmd;
529
+
530
+ if (platform === "darwin") {
531
+ cmd = `open "${url}"`;
532
+ } else if (platform === "win32") {
533
+ cmd = `start "" "${url}"`;
534
+ } else {
535
+ cmd = `xdg-open "${url}"`;
536
+ }
537
+
538
+ exec(cmd, { windowsHide: true }, (err) => {
539
+ if (err) {
540
+ console.log(`Open browser manually: ${url}`);
541
+ }
542
+ });
543
+ }
544
+
545
+ // Find standalone server (bundled in bin/app for published package).
546
+ // Prefer custom-server.js (injects real socket IP) when present.
547
+ const standaloneDir = path.join(__dirname, "app");
548
+ const customServerPath = path.join(standaloneDir, "custom-server.js");
549
+ const serverPath = fs.existsSync(customServerPath)
550
+ ? customServerPath
551
+ : path.join(standaloneDir, "server.js");
552
+
553
+ if (!fs.existsSync(serverPath)) {
554
+ console.error("Error: Standalone build not found.");
555
+ console.error("Please run 'npm run build:cli' first.");
556
+ process.exit(1);
557
+ }
558
+
559
+ // Start server immediately; run update check in parallel (not on the critical path).
560
+ const updatePromise = checkForUpdate();
561
+ killAllAppProcesses(port)
562
+ .then(() => killProcessOnPort(port))
563
+ .then(() => startServer(updatePromise));
564
+
565
+ // Show interface selection menu
566
+ async function showInterfaceMenu(latestVersion) {
567
+ const { selectMenu } = require("./src/cli/utils/input");
568
+ const { clearScreen } = require("./src/cli/utils/display");
569
+ const { getEndpoint } = require("./src/cli/utils/endpoint");
570
+
571
+ clearScreen();
572
+
573
+ const displayHost = getDisplayHost();
574
+
575
+ // Detect tunnel/local mode for server URL display
576
+ let serverUrl;
577
+ try {
578
+ const { endpoint, tunnelEnabled } = await getEndpoint(port);
579
+ serverUrl = tunnelEnabled ? endpoint.replace(/\/v1$/, "") : `http://${displayHost}:${port}`;
580
+ } catch (e) {
581
+ serverUrl = `http://${displayHost}:${port}`;
582
+ }
583
+
584
+ const subtitle = `šŸš€ Server: \x1b[32m${serverUrl}\x1b[0m`;
585
+
586
+ const menuItems = [];
587
+
588
+ if (latestVersion) {
589
+ menuItems.push({ label: `Update to v${latestVersion} (current: v${pkg.version})`, icon: "⬆" });
590
+ }
591
+
592
+ menuItems.push(
593
+ { label: "Web UI (Open in Browser)", icon: "🌐" },
594
+ { label: "Terminal UI (Interactive CLI)", icon: "šŸ’»" },
595
+ { label: "Hide to Tray (Background)", icon: "šŸ””" },
596
+ { label: "Exit", icon: "🚪" }
597
+ );
598
+
599
+ const selected = await selectMenu(`Choose Interface (v${pkg.version})`, menuItems, 0, subtitle);
600
+
601
+ const offset = latestVersion ? 1 : 0;
602
+
603
+ if (latestVersion && selected === 0) return "update";
604
+ if (selected === offset) return "web";
605
+ if (selected === offset + 1) return "terminal";
606
+ if (selected === offset + 2) return "hide";
607
+ return "exit";
608
+ }
609
+
610
+ const MAX_RESTARTS = 2;
611
+ const RESTART_RESET_MS = 30000; // Reset counter if alive > 30s
612
+
613
+ function startServer(updatePromise) {
614
+ // Accept either a Promise (parallel update check) or a resolved value.
615
+ const latestVersionPromise = Promise.resolve(updatePromise);
616
+ const displayHost = getDisplayHost();
617
+ const url = `http://${displayHost}:${port}/dashboard`;
618
+ // Surface real network exposure when bound to all interfaces (default 0.0.0.0).
619
+ if (host === DEFAULT_HOST) {
620
+ const lanIp = getLanIp();
621
+ if (lanIp) console.log(`\x1b[33m⚠ Network-exposed: reachable at http://${lanIp}:${port} (bound 0.0.0.0). Use --host 127.0.0.1 for local-only.\x1b[0m`);
622
+ }
623
+
624
+ let restartCount = 0;
625
+ let serverStartTime = Date.now();
626
+
627
+ const CRASH_LOG_LINES = 50;
628
+ let crashLog = [];
629
+
630
+ function spawnServer() {
631
+ serverStartTime = Date.now();
632
+ crashLog = [];
633
+ const child = spawn(RUNTIME, ["--dns-result-order=ipv4first", "--max-old-space-size=6144", serverPath], {
634
+ cwd: standaloneDir,
635
+ stdio: showLog ? "inherit" : ["ignore", "ignore", "pipe"],
636
+ detached: true,
637
+ windowsHide: true,
638
+ env: {
639
+ ...buildEnvWithRuntime(process.env),
640
+ PORT: port.toString(),
641
+ HOSTNAME: host
642
+ }
643
+ });
644
+ if (!showLog && child.stderr) {
645
+ child.stderr.on("data", (data) => {
646
+ const lines = data.toString().split("\n").filter(Boolean);
647
+ crashLog.push(...lines);
648
+ if (crashLog.length > CRASH_LOG_LINES) crashLog = crashLog.slice(-CRASH_LOG_LINES);
649
+ });
650
+ }
651
+ return child;
652
+ }
653
+
654
+ let server = spawnServer();
655
+
656
+ // Cleanup function - force kill server process
657
+ let isCleaningUp = false;
658
+ function cleanup() {
659
+ if (isCleaningUp) return;
660
+ isCleaningUp = true;
661
+ try {
662
+ // Kill tray if running
663
+ try {
664
+ const { killTray } = require("./src/cli/tray/tray");
665
+ killTray();
666
+ } catch (e) { }
667
+ // Kill MIT server (privileged process) via PID file
668
+ killProxyByPidFile();
669
+ // Kill cloudflared/tailscale via PID file (only this app's tunnel)
670
+ killTunnelByPidFile();
671
+ // Kill server process directly
672
+ if (server.pid) {
673
+ process.kill(server.pid, "SIGKILL");
674
+ }
675
+ // Also try to kill process group
676
+ process.kill(-server.pid, "SIGKILL");
677
+ } catch (e) { }
678
+ }
679
+
680
+ // Suppress all errors during shutdown (systray lib throws JSON parse errors)
681
+ let isShuttingDown = false;
682
+ process.on("uncaughtException", (err) => {
683
+ if (isShuttingDown) return;
684
+ console.error("Error:", err.message);
685
+ });
686
+
687
+ // Handle all exit scenarios
688
+ process.on("SIGINT", () => {
689
+ if (isShuttingDown) return;
690
+ isShuttingDown = true;
691
+ console.log("\nExiting...");
692
+ cleanup();
693
+ setTimeout(() => process.exit(0), 100);
694
+ });
695
+ process.on("SIGTERM", () => {
696
+ if (isShuttingDown) return;
697
+ isShuttingDown = true;
698
+ cleanup();
699
+ setTimeout(() => process.exit(0), 100);
700
+ });
701
+ process.on("SIGHUP", () => {
702
+ if (isShuttingDown) return;
703
+ isShuttingDown = true;
704
+ cleanup();
705
+ setTimeout(() => process.exit(0), 100);
706
+ });
707
+
708
+ // Initialize tray icon (runs alongside TUI)
709
+ const initTrayIcon = () => {
710
+ try {
711
+ const { initTray } = require("./src/cli/tray/tray");
712
+ initTray({
713
+ port,
714
+ onQuit: () => {
715
+ isShuttingDown = true;
716
+ console.log("\nšŸ‘‹ Shutting down from tray...");
717
+ cleanup();
718
+ setTimeout(() => process.exit(0), 100);
719
+ },
720
+ onOpenDashboard: () => openBrowser(url)
721
+ });
722
+ } catch (err) {
723
+ // Tray not available - continue without it
724
+ }
725
+ };
726
+
727
+ // Tray-only mode: no TUI, just tray icon
728
+ if (trayMode) {
729
+ // Ignore SIGHUP so macOS terminal close doesn't kill the background tray process
730
+ process.removeAllListeners("SIGHUP");
731
+ process.on("SIGHUP", () => {});
732
+
733
+ console.log(`\nšŸš€ ${pkg.name} v${pkg.version}`);
734
+ console.log(`Server: http://${displayHost}:${port}`);
735
+
736
+ waitServerReady(port).then(() => {
737
+ initTrayIcon();
738
+ console.log("\nšŸ’” Router is now running in system tray. Close this terminal if you want.");
739
+ console.log(" Right-click tray icon to open dashboard or quit.\n");
740
+ });
741
+
742
+ return;
743
+ }
744
+
745
+ // Wait for server to be ready, then show interface menu loop + tray
746
+ waitServerReady(port).then(async () => {
747
+ // Resolve parallel update check (already running); don't block server start on it.
748
+ const latestVersion = await latestVersionPromise;
749
+ // Start tray icon alongside TUI
750
+ initTrayIcon();
751
+
752
+ try {
753
+ while (true) {
754
+ const choice = await showInterfaceMenu(latestVersion);
755
+
756
+ if (choice === "update") {
757
+ isShuttingDown = true;
758
+ const { clearScreen } = require("./src/cli/utils/display");
759
+ clearScreen();
760
+ console.log(`\n⬆ Update v${pkg.version} → v${latestVersion}\n`);
761
+ console.log(`Run this after exit:\n`);
762
+ console.log(` \x1b[33m${INSTALL_CMD_LATEST}\x1b[0m\n`);
763
+ cleanup();
764
+ await killAllAppProcesses(port);
765
+ await killProcessOnPort(port);
766
+ setTimeout(() => process.exit(0), 200);
767
+ return;
768
+ } else if (choice === "web") {
769
+ openBrowser(url);
770
+ // Wait for user to come back
771
+ const { pause } = require("./src/cli/utils/input");
772
+ await pause("\nPress Enter to go back to menu...");
773
+ } else if (choice === "terminal") {
774
+ // Start Terminal UI - it will return when user selects Back
775
+ const { startTerminalUI } = require("./src/cli/terminalUI");
776
+ await startTerminalUI(port);
777
+ // Loop continues, show menu again
778
+ } else if (choice === "hide") {
779
+ const { clearScreen } = require("./src/cli/utils/display");
780
+ clearScreen();
781
+
782
+ // Enable auto startup on OS boot
783
+ try {
784
+ const { enableAutoStart } = require("./src/cli/tray/autostart");
785
+ enableAutoStart(__filename);
786
+ } catch (e) { }
787
+
788
+ if (process.platform === "darwin") {
789
+ // macOS: keep current process alive — spawning a detached child puts
790
+ // it outside the login session so NSStatusItem silently fails.
791
+ process.removeAllListeners("SIGHUP");
792
+ process.on("SIGHUP", () => {});
793
+
794
+ console.log(`\nā³ Switching to tray mode... (icon already visible in menu bar)`);
795
+ console.log(`šŸ”” 9Router is running in tray (PID: ${process.pid})`);
796
+ console.log(` Server: http://${displayHost}:${port}`);
797
+ console.log(`\nšŸ’” You can close this terminal. Right-click tray icon to quit.\n`);
798
+
799
+ // Tray already init'd at startup — just keep event loop alive.
800
+ return;
801
+ }
802
+
803
+ // Windows/Linux: spawn detached bgProcess (systray works fine in child)
804
+ console.log(`\nā³ Starting background process... (tray icon will appear in ~3s)`);
805
+
806
+ const bgProcess = spawn(process.execPath, ["--dns-result-order=ipv4first", __filename, "--tray", "--skip-update", "-p", port.toString()], {
807
+ detached: true,
808
+ stdio: "ignore",
809
+ windowsHide: true,
810
+ env: { ...process.env }
811
+ });
812
+ bgProcess.unref();
813
+
814
+ console.log(`šŸ”” 9Router is now running in background (PID: ${bgProcess.pid})`);
815
+ console.log(` Server: http://${displayHost}:${port}`);
816
+ console.log(`\nšŸ’” You can close this terminal. Right-click tray icon to quit.\n`);
817
+
818
+ // cleanup() kills server so bgProcess can claim the port fresh
819
+ cleanup();
820
+ process.exit(0);
821
+ } else if (choice === "exit") {
822
+ isShuttingDown = true;
823
+ console.log("\nExiting...");
824
+ cleanup();
825
+ setTimeout(() => process.exit(0), 100);
826
+ }
827
+ }
828
+ } catch (err) {
829
+ console.error("Error:", err.message);
830
+ cleanup();
831
+ process.exit(1);
832
+ }
833
+ });
834
+
835
+ function attachServerEvents() {
836
+ server.on("error", (err) => {
837
+ console.error("Failed to start server:", err.message);
838
+ if (!isShuttingDown) tryRestart();
839
+ else { cleanup(); process.exit(1); }
840
+ });
841
+
842
+ server.on("close", (code) => {
843
+ if (isShuttingDown || code === 0) {
844
+ process.exit(code || 0);
845
+ return;
846
+ }
847
+ tryRestart(code);
848
+ });
849
+ }
850
+
851
+ function tryRestart(code) {
852
+ const aliveMs = Date.now() - serverStartTime;
853
+ // Reset counter if last run was stable
854
+ if (aliveMs >= RESTART_RESET_MS) restartCount = 0;
855
+
856
+ if (restartCount >= MAX_RESTARTS) {
857
+ console.error(`\nāš ļø Server crashed ${MAX_RESTARTS} times. Disabling MIT and restarting...`);
858
+ try {
859
+ const dbPath = path.join(os.homedir(), process.platform === "win32" ? path.join("AppData", "Roaming", "9router", "db.json") : path.join(".9router", "db.json"));
860
+ if (fs.existsSync(dbPath)) {
861
+ const db = JSON.parse(fs.readFileSync(dbPath, "utf-8"));
862
+ if (db.settings) db.settings.mitmEnabled = false;
863
+ fs.writeFileSync(dbPath, JSON.stringify(db, null, 2));
864
+ }
865
+ } catch { /* best effort */ }
866
+ restartCount = 0;
867
+ server = spawnServer();
868
+ attachServerEvents();
869
+ return;
870
+ }
871
+
872
+ restartCount++;
873
+ const delay = Math.min(1000 * restartCount, 10000);
874
+ console.error(`\nāš ļø Server exited (code=${code ?? "unknown"}). Restarting in ${delay / 1000}s... (${restartCount}/${MAX_RESTARTS})`);
875
+ if (crashLog.length) {
876
+ console.error("\n--- Server crash log ---");
877
+ crashLog.forEach(l => console.error(l));
878
+ console.error("--- End crash log ---\n");
879
+ }
880
+
881
+ setTimeout(() => {
882
+ server = spawnServer();
883
+ attachServerEvents();
884
+ }, delay);
885
+ }
886
+
887
+ attachServerEvents();
888
+ }