upfynai-code 2.6.0 → 2.6.1

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 (295) hide show
  1. package/README.md +123 -88
  2. package/bin/cli.js +63 -0
  3. package/package.json +48 -106
  4. package/src/auth.js +115 -0
  5. package/src/config.js +33 -0
  6. package/src/connect.js +314 -0
  7. package/src/launch.js +54 -0
  8. package/src/mcp.js +57 -0
  9. package/src/server.js +54 -0
  10. package/client/dist/api-docs.html +0 -879
  11. package/client/dist/assets/AppContent-C0CyP3g5.js +0 -513
  12. package/client/dist/assets/CanvasPanel-0u9QR7U-.js +0 -34
  13. package/client/dist/assets/CanvasPanel-WhZulBJw.css +0 -1
  14. package/client/dist/assets/DashboardPanel-Dgqw1yZk.js +0 -1
  15. package/client/dist/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
  16. package/client/dist/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
  17. package/client/dist/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
  18. package/client/dist/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
  19. package/client/dist/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
  20. package/client/dist/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
  21. package/client/dist/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
  22. package/client/dist/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
  23. package/client/dist/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
  24. package/client/dist/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
  25. package/client/dist/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
  26. package/client/dist/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
  27. package/client/dist/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
  28. package/client/dist/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
  29. package/client/dist/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
  30. package/client/dist/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
  31. package/client/dist/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
  32. package/client/dist/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
  33. package/client/dist/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
  34. package/client/dist/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
  35. package/client/dist/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
  36. package/client/dist/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
  37. package/client/dist/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
  38. package/client/dist/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
  39. package/client/dist/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
  40. package/client/dist/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
  41. package/client/dist/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
  42. package/client/dist/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
  43. package/client/dist/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
  44. package/client/dist/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
  45. package/client/dist/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
  46. package/client/dist/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
  47. package/client/dist/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
  48. package/client/dist/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
  49. package/client/dist/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
  50. package/client/dist/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
  51. package/client/dist/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
  52. package/client/dist/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
  53. package/client/dist/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
  54. package/client/dist/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
  55. package/client/dist/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
  56. package/client/dist/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
  57. package/client/dist/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
  58. package/client/dist/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
  59. package/client/dist/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
  60. package/client/dist/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
  61. package/client/dist/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
  62. package/client/dist/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
  63. package/client/dist/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
  64. package/client/dist/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
  65. package/client/dist/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
  66. package/client/dist/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
  67. package/client/dist/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
  68. package/client/dist/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
  69. package/client/dist/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
  70. package/client/dist/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
  71. package/client/dist/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
  72. package/client/dist/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
  73. package/client/dist/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
  74. package/client/dist/assets/LoginModal-CZDEzqjK.js +0 -19
  75. package/client/dist/assets/MarkdownPreview-CYdvwJaV.js +0 -1
  76. package/client/dist/assets/Onboarding-DR6NZ4Vz.js +0 -1
  77. package/client/dist/assets/SetupForm-D49gtWY4.js +0 -1
  78. package/client/dist/assets/Tableau10-B-NsZVaP.js +0 -1
  79. package/client/dist/assets/WorkflowsPanel-CqlbEJA_.js +0 -1
  80. package/client/dist/assets/_commonjs-dynamic-modules-TDtrdbi3.js +0 -1
  81. package/client/dist/assets/ar-SA-G6X2FPQ2-BWqa1yBH.js +0 -10
  82. package/client/dist/assets/arc-BegSKqEW.js +0 -1
  83. package/client/dist/assets/array-BKyUJesY.js +0 -1
  84. package/client/dist/assets/az-AZ-76LH7QW2-DrVlbZDP.js +0 -1
  85. package/client/dist/assets/bg-BG-XCXSNQG7-DdunjBgT.js +0 -5
  86. package/client/dist/assets/blockDiagram-38ab4fdb-BKMbwGHu.js +0 -118
  87. package/client/dist/assets/bn-BD-2XOGV67Q-_7DtmvwO.js +0 -5
  88. package/client/dist/assets/c4Diagram-3d4e48cf-hJuiHhSn.js +0 -10
  89. package/client/dist/assets/ca-ES-6MX7JW3Y-BFIrmojG.js +0 -8
  90. package/client/dist/assets/channel-Bur-rRTp.js +0 -1
  91. package/client/dist/assets/classDiagram-70f12bd4-BjiAf9cM.js +0 -2
  92. package/client/dist/assets/classDiagram-v2-f2320105-pwBewejc.js +0 -2
  93. package/client/dist/assets/clone-BtqXeoBJ.js +0 -1
  94. package/client/dist/assets/createText-2e5e7dd3-Dq_acOWe.js +0 -5
  95. package/client/dist/assets/cs-CZ-2BRQDIVT-B-x4F6TJ.js +0 -11
  96. package/client/dist/assets/da-DK-5WZEPLOC-Btlc8Dgn.js +0 -5
  97. package/client/dist/assets/de-DE-XR44H4JA-BVu3ZIoD.js +0 -8
  98. package/client/dist/assets/directory-open-01563666-DWU9wJ6I.js +0 -1
  99. package/client/dist/assets/directory-open-4ed118d0-CunoC1EB.js +0 -1
  100. package/client/dist/assets/edges-e0da2a9e-DH0wVTXR.js +0 -4
  101. package/client/dist/assets/el-GR-BZB4AONW-h2ll8_ZC.js +0 -10
  102. package/client/dist/assets/erDiagram-9861fffd-BYezLIR7.js +0 -51
  103. package/client/dist/assets/es-ES-U4NZUMDT-Cveiulwt.js +0 -9
  104. package/client/dist/assets/eu-ES-A7QVB2H4-DQluL2PY.js +0 -11
  105. package/client/dist/assets/fa-IR-HGAKTJCU-BJtcMBSv.js +0 -8
  106. package/client/dist/assets/fi-FI-Z5N7JZ37-D8NfbVXV.js +0 -6
  107. package/client/dist/assets/file-open-002ab408-DIuFHtCF.js +0 -1
  108. package/client/dist/assets/file-open-7c801643-684qeFg4.js +0 -1
  109. package/client/dist/assets/file-save-3189631c-C1wFhQhH.js +0 -1
  110. package/client/dist/assets/file-save-745eba88-Bb9F9Kg7.js +0 -1
  111. package/client/dist/assets/flowDb-956e92f1-scnUykhM.js +0 -10
  112. package/client/dist/assets/flowDiagram-66a62f08-jVyWsfyU.js +0 -4
  113. package/client/dist/assets/flowDiagram-v2-96b9c2cf-N6xgi25h.js +0 -1
  114. package/client/dist/assets/flowchart-elk-definition-4a651766-gKGX3HqR.js +0 -139
  115. package/client/dist/assets/fr-FR-RHASNOE6-vdj42kC6.js +0 -9
  116. package/client/dist/assets/ganttDiagram-c361ad54-C2CiWFUP.js +0 -257
  117. package/client/dist/assets/gitGraphDiagram-72cf32ee-C59Yz2LK.js +0 -70
  118. package/client/dist/assets/gl-ES-HMX3MZ6V-DQo0TzoP.js +0 -10
  119. package/client/dist/assets/graph-Dx_H43Kv.js +0 -1
  120. package/client/dist/assets/he-IL-6SHJWFNN-DKXK5e33.js +0 -10
  121. package/client/dist/assets/hi-IN-IWLTKZ5I-C2Qgqc0R.js +0 -4
  122. package/client/dist/assets/hu-HU-A5ZG7DT2-Ss-6vX0m.js +0 -7
  123. package/client/dist/assets/id-ID-SAP4L64H-D7Wsg1S2.js +0 -10
  124. package/client/dist/assets/image-blob-reduce.esm-D6s-rqMO.js +0 -7
  125. package/client/dist/assets/index-3862675e-u8Nv7hHC.js +0 -1
  126. package/client/dist/assets/index-BVowJdZF.js +0 -97
  127. package/client/dist/assets/index-ce18TYkg.js +0 -27
  128. package/client/dist/assets/index-kQoJx-bc.css +0 -1
  129. package/client/dist/assets/infoDiagram-f8f76790-LmoJYsxo.js +0 -7
  130. package/client/dist/assets/init-Gi6I4Gst.js +0 -1
  131. package/client/dist/assets/it-IT-JPQ66NNP-CAPTVl7M.js +0 -11
  132. package/client/dist/assets/ja-JP-DBVTYXUO-eNVPawR2.js +0 -8
  133. package/client/dist/assets/journeyDiagram-49397b02-BaJqehpR.js +0 -139
  134. package/client/dist/assets/kaa-6HZHGXH3-tpuNkKhS.js +0 -1
  135. package/client/dist/assets/kab-KAB-ZGHBKWFO-Dp83kx4x.js +0 -8
  136. package/client/dist/assets/kk-KZ-P5N5QNE5-B9IlC6YN.js +0 -1
  137. package/client/dist/assets/km-KH-HSX4SM5Z-B_KMYaMj.js +0 -11
  138. package/client/dist/assets/ko-KR-MTYHY66A-yebnUNdb.js +0 -9
  139. package/client/dist/assets/ku-TR-6OUDTVRD-BR6fh6-5.js +0 -9
  140. package/client/dist/assets/layout-DLl5Jwcl.js +0 -1
  141. package/client/dist/assets/line-FpB7omSK.js +0 -1
  142. package/client/dist/assets/linear-CkXqUFJ8.js +0 -1
  143. package/client/dist/assets/lt-LT-XHIRWOB4-SutZSWtR.js +0 -3
  144. package/client/dist/assets/lv-LV-5QDEKY6T-DuAxdcZL.js +0 -7
  145. package/client/dist/assets/mindmap-definition-fc14e90a-DyxXOExh.js +0 -425
  146. package/client/dist/assets/mr-IN-CRQNXWMA-DqDUWM_8.js +0 -13
  147. package/client/dist/assets/my-MM-5M5IBNSE-C40kMFMR.js +0 -1
  148. package/client/dist/assets/nb-NO-T6EIAALU-DVij32Ju.js +0 -10
  149. package/client/dist/assets/nl-NL-IS3SIHDZ-rT84mDYq.js +0 -8
  150. package/client/dist/assets/nn-NO-6E72VCQL-BBZXBW8V.js +0 -8
  151. package/client/dist/assets/oc-FR-POXYY2M6-DzjOugOf.js +0 -8
  152. package/client/dist/assets/ordinal-Cboi1Yqb.js +0 -1
  153. package/client/dist/assets/pa-IN-N4M65BXN-DD1iU8_F.js +0 -4
  154. package/client/dist/assets/path-CbwjOpE9.js +0 -1
  155. package/client/dist/assets/pdf-CE_K4jFx.js +0 -12
  156. package/client/dist/assets/pdf.worker-BA9kU3Pw.mjs +0 -61080
  157. package/client/dist/assets/percentages-BXMCSKIN-WVlHS4wx.js +0 -207
  158. package/client/dist/assets/pica-CQIY57Tf.js +0 -7
  159. package/client/dist/assets/pieDiagram-8a3498a8-Dd_85qBH.js +0 -35
  160. package/client/dist/assets/pl-PL-T2D74RX3-ukVXa48G.js +0 -9
  161. package/client/dist/assets/pt-BR-5N22H2LF-BibawarT.js +0 -9
  162. package/client/dist/assets/pt-PT-UZXXM6DQ-So3i9l9w.js +0 -9
  163. package/client/dist/assets/quadrantDiagram-120e2f19-C4dFVDEx.js +0 -7
  164. package/client/dist/assets/requirementDiagram-deff3bca-DrTO7yFl.js +0 -52
  165. package/client/dist/assets/ro-RO-JPDTUUEW-DY0Xq_Hd.js +0 -11
  166. package/client/dist/assets/roundRect-0PYZxl1G.js +0 -1
  167. package/client/dist/assets/ru-RU-B4JR7IUQ-B7u_Zvkd.js +0 -9
  168. package/client/dist/assets/sankeyDiagram-04a897e0-D24gfzuS.js +0 -8
  169. package/client/dist/assets/sequenceDiagram-704730f1-Dgji2XLQ.js +0 -122
  170. package/client/dist/assets/si-LK-N5RQ5JYF-OejsLzQ_.js +0 -1
  171. package/client/dist/assets/sk-SK-C5VTKIMK-_vy2Bt-M.js +0 -6
  172. package/client/dist/assets/sl-SI-NN7IZMDC-DKOl_u2M.js +0 -6
  173. package/client/dist/assets/stateDiagram-587899a1-CJ8eBaiU.js +0 -1
  174. package/client/dist/assets/stateDiagram-v2-d93cdb3a-C5K3l-Nt.js +0 -1
  175. package/client/dist/assets/styles-6aaf32cf-DAKE0jbx.js +0 -207
  176. package/client/dist/assets/styles-9a916d00-LFAJCgEy.js +0 -160
  177. package/client/dist/assets/styles-c10674c1-CllKO8NG.js +0 -116
  178. package/client/dist/assets/subset-shared.chunk-Uy-J87FQ.js +0 -84
  179. package/client/dist/assets/subset-worker.chunk-dvgDvqt9.js +0 -1
  180. package/client/dist/assets/sv-SE-XGPEYMSR-CDCB2ZV5.js +0 -10
  181. package/client/dist/assets/svgDrawCommon-08f97a94-CObOzbFQ.js +0 -1
  182. package/client/dist/assets/ta-IN-2NMHFXQM-DHUNdO69.js +0 -9
  183. package/client/dist/assets/th-TH-HPSO5L25-zI2hnBq3.js +0 -2
  184. package/client/dist/assets/timeline-definition-85554ec2-C2XHRmxK.js +0 -61
  185. package/client/dist/assets/tr-TR-DEFEU3FU-l-6Hu4-D.js +0 -7
  186. package/client/dist/assets/uk-UA-QMV73CPH-CqSOwrl7.js +0 -6
  187. package/client/dist/assets/vendor-codemirror-D_s0aGBu.js +0 -35
  188. package/client/dist/assets/vendor-i18n-DCFGyhQR.js +0 -1
  189. package/client/dist/assets/vendor-icons-Lb69KSFJ.js +0 -646
  190. package/client/dist/assets/vendor-markdown-BXEi_H3G.js +0 -298
  191. package/client/dist/assets/vendor-react-9mUTKBHH.js +0 -67
  192. package/client/dist/assets/vendor-syntax-DnmwQQJF.js +0 -16
  193. package/client/dist/assets/vendor-xterm-CZq1hqo1.js +0 -66
  194. package/client/dist/assets/vendor-xterm-qxJ8_QYu.css +0 -32
  195. package/client/dist/assets/vi-VN-M7AON7JQ-CUL8-mBZ.js +0 -5
  196. package/client/dist/assets/xychartDiagram-e933f94c-1fmf6slj.js +0 -7
  197. package/client/dist/assets/zh-CN-LNUGB5OW-CB5y5VVU.js +0 -10
  198. package/client/dist/assets/zh-HK-E62DVLB3-BHcrrEeJ.js +0 -1
  199. package/client/dist/assets/zh-TW-RAJ6MFWO-DoDUdkaJ.js +0 -9
  200. package/client/dist/clear-cache.html +0 -85
  201. package/client/dist/convert-icons.md +0 -53
  202. package/client/dist/favicon.png +0 -0
  203. package/client/dist/favicon.svg +0 -9
  204. package/client/dist/generate-icons.js +0 -49
  205. package/client/dist/icons/claude-ai-icon.svg +0 -1
  206. package/client/dist/icons/codex-white.svg +0 -3
  207. package/client/dist/icons/codex.svg +0 -3
  208. package/client/dist/icons/cursor-white.svg +0 -12
  209. package/client/dist/icons/cursor.svg +0 -1
  210. package/client/dist/icons/icon-128x128.png +0 -0
  211. package/client/dist/icons/icon-128x128.svg +0 -12
  212. package/client/dist/icons/icon-144x144.png +0 -0
  213. package/client/dist/icons/icon-144x144.svg +0 -12
  214. package/client/dist/icons/icon-152x152.png +0 -0
  215. package/client/dist/icons/icon-152x152.svg +0 -12
  216. package/client/dist/icons/icon-192x192.png +0 -0
  217. package/client/dist/icons/icon-192x192.svg +0 -12
  218. package/client/dist/icons/icon-384x384.png +0 -0
  219. package/client/dist/icons/icon-384x384.svg +0 -12
  220. package/client/dist/icons/icon-512x512.png +0 -0
  221. package/client/dist/icons/icon-512x512.svg +0 -12
  222. package/client/dist/icons/icon-72x72.png +0 -0
  223. package/client/dist/icons/icon-72x72.svg +0 -12
  224. package/client/dist/icons/icon-96x96.png +0 -0
  225. package/client/dist/icons/icon-96x96.svg +0 -12
  226. package/client/dist/icons/icon-template.svg +0 -12
  227. package/client/dist/index.html +0 -128
  228. package/client/dist/logo-128.png +0 -0
  229. package/client/dist/logo-256.png +0 -0
  230. package/client/dist/logo-32.png +0 -0
  231. package/client/dist/logo-512.png +0 -0
  232. package/client/dist/logo-64.png +0 -0
  233. package/client/dist/logo.svg +0 -17
  234. package/client/dist/manifest.json +0 -61
  235. package/client/dist/mcp-docs.html +0 -119
  236. package/client/dist/screenshots/cli-selection.png +0 -0
  237. package/client/dist/screenshots/desktop-main.png +0 -0
  238. package/client/dist/screenshots/mobile-chat.png +0 -0
  239. package/client/dist/screenshots/tools-modal.png +0 -0
  240. package/client/dist/sw.js +0 -19
  241. package/commands/upfynai-connect.md +0 -59
  242. package/commands/upfynai-disconnect.md +0 -31
  243. package/commands/upfynai-doctor.md +0 -99
  244. package/commands/upfynai-export.md +0 -49
  245. package/commands/upfynai-local.md +0 -82
  246. package/commands/upfynai-status.md +0 -75
  247. package/commands/upfynai-stop.md +0 -49
  248. package/commands/upfynai-uninstall.md +0 -58
  249. package/commands/upfynai.md +0 -69
  250. package/scripts/build-client.js +0 -17
  251. package/scripts/fix-node-pty.js +0 -67
  252. package/scripts/install-commands.js +0 -78
  253. package/server/claude-sdk.js +0 -714
  254. package/server/cli-ui.js +0 -785
  255. package/server/cli.js +0 -596
  256. package/server/constants/config.js +0 -31
  257. package/server/cursor-cli.js +0 -270
  258. package/server/database/auth.db +0 -0
  259. package/server/database/db.js +0 -822
  260. package/server/database/init.sql +0 -70
  261. package/server/index.js +0 -2738
  262. package/server/load-env.js +0 -26
  263. package/server/mcp-server.js +0 -621
  264. package/server/middleware/auth.js +0 -181
  265. package/server/openai-codex.js +0 -403
  266. package/server/openrouter.js +0 -137
  267. package/server/projects.js +0 -1742
  268. package/server/relay-client.js +0 -672
  269. package/server/routes/agent.js +0 -1226
  270. package/server/routes/auth.js +0 -266
  271. package/server/routes/cli-auth.js +0 -263
  272. package/server/routes/codex.js +0 -344
  273. package/server/routes/commands.js +0 -598
  274. package/server/routes/cursor.js +0 -807
  275. package/server/routes/dashboard.js +0 -205
  276. package/server/routes/git.js +0 -1151
  277. package/server/routes/mcp-utils.js +0 -48
  278. package/server/routes/mcp.js +0 -535
  279. package/server/routes/payments.js +0 -172
  280. package/server/routes/projects.js +0 -552
  281. package/server/routes/settings.js +0 -261
  282. package/server/routes/taskmaster.js +0 -1928
  283. package/server/routes/user.js +0 -106
  284. package/server/routes/vapi-chat.js +0 -94
  285. package/server/routes/voice.js +0 -194
  286. package/server/routes/webhooks.js +0 -166
  287. package/server/routes/workflows.js +0 -118
  288. package/server/sandbox.js +0 -120
  289. package/server/services/whisperService.js +0 -84
  290. package/server/services/workflowScheduler.js +0 -186
  291. package/server/utils/commandParser.js +0 -303
  292. package/server/utils/gitConfig.js +0 -24
  293. package/server/utils/mcp-detector.js +0 -198
  294. package/server/utils/taskmaster-websocket.js +0 -129
  295. package/shared/modelConstants.js +0 -96
package/server/index.js DELETED
@@ -1,2738 +0,0 @@
1
- #!/usr/bin/env node
2
- // Load environment variables before other imports execute
3
- import './load-env.js';
4
-
5
- // Strip Claude Code session markers so spawned CLI processes don't fail with
6
- // "cannot be launched inside another Claude Code session" errors.
7
- delete process.env.CLAUDECODE;
8
- delete process.env.CLAUDE_CODE;
9
- import crypto from 'crypto';
10
- import fs from 'fs';
11
- import path from 'path';
12
- import jwt from 'jsonwebtoken';
13
- import { fileURLToPath } from 'url';
14
- import { dirname } from 'path';
15
-
16
- const __filename = fileURLToPath(import.meta.url);
17
- const __dirname = dirname(__filename);
18
-
19
- // ANSI color codes for terminal output
20
- const colors = {
21
- reset: '\x1b[0m',
22
- bright: '\x1b[1m',
23
- cyan: '\x1b[36m',
24
- green: '\x1b[32m',
25
- yellow: '\x1b[33m',
26
- blue: '\x1b[34m',
27
- dim: '\x1b[2m',
28
- };
29
-
30
- const c = {
31
- info: (text) => `${colors.cyan}${text}${colors.reset}`,
32
- ok: (text) => `${colors.green}${text}${colors.reset}`,
33
- warn: (text) => `${colors.yellow}${text}${colors.reset}`,
34
- tip: (text) => `${colors.blue}${text}${colors.reset}`,
35
- bright: (text) => `${colors.bright}${text}${colors.reset}`,
36
- dim: (text) => `${colors.dim}${text}${colors.reset}`,
37
- };
38
-
39
- if (!process.env.VERCEL) console.log('PORT from env:', process.env.PORT);
40
-
41
- import express from 'express';
42
- import { WebSocketServer, WebSocket } from 'ws';
43
- import os from 'os';
44
- import http from 'http';
45
- import cors from 'cors';
46
- import cookieParser from 'cookie-parser';
47
- import { promises as fsPromises } from 'fs';
48
- import { spawn } from 'child_process';
49
- // node-pty: conditionally imported (not available on Vercel serverless)
50
- let pty = null;
51
- try {
52
- pty = (await import('node-pty')).default;
53
- } catch (e) {
54
- console.warn('[WARN] node-pty not available. Shell tab requires relay connection.');
55
- }
56
- // Node 22+ has built-in fetch — no need for node-fetch
57
- import mime from 'mime-types';
58
-
59
- import { getProjects, getSessions, getSessionMessages, renameProject, deleteSession, deleteProject, addProjectManually, extractProjectDirectory, clearProjectDirectoryCache } from './projects.js';
60
- import { queryClaudeSDK, abortClaudeSDKSession, isClaudeSDKSessionActive, getActiveClaudeSDKSessions, resolveToolApproval } from './claude-sdk.js';
61
- import { spawnCursor, abortCursorSession, isCursorSessionActive, getActiveCursorSessions } from './cursor-cli.js';
62
- import { queryCodex, abortCodexSession, isCodexSessionActive, getActiveCodexSessions } from './openai-codex.js';
63
- import { queryOpenRouter, OPENROUTER_MODELS } from './openrouter.js';
64
- import { createMcpServer, mountMcpServer } from './mcp-server.js';
65
- import gitRoutes from './routes/git.js';
66
- import authRoutes from './routes/auth.js';
67
- import mcpRoutes from './routes/mcp.js';
68
- import cursorRoutes from './routes/cursor.js';
69
- import taskmasterRoutes from './routes/taskmaster.js';
70
- import mcpUtilsRoutes from './routes/mcp-utils.js';
71
- import commandsRoutes from './routes/commands.js';
72
- import settingsRoutes from './routes/settings.js';
73
- import agentRoutes from './routes/agent.js';
74
- import projectsRoutes, { WORKSPACES_ROOT, validateWorkspacePath } from './routes/projects.js';
75
- import cliAuthRoutes from './routes/cli-auth.js';
76
- import userRoutes from './routes/user.js';
77
- import codexRoutes from './routes/codex.js';
78
- import paymentRoutes from './routes/payments.js';
79
- import webhookRoutes from './routes/webhooks.js';
80
- import workflowRoutes from './routes/workflows.js';
81
- import voiceRoutes from './routes/voice.js';
82
- import dashboardRoutes from './routes/dashboard.js';
83
- import vapiChatRoutes from './routes/vapi-chat.js';
84
- import { initScheduler } from './services/workflowScheduler.js';
85
- import { initializeDatabase, relayTokensDb, subscriptionDb, credentialsDb, userDb } from './database/db.js';
86
- import { validateApiKey, authenticateToken, authenticateWebSocket, JWT_SECRET } from './middleware/auth.js';
87
- import { IS_PLATFORM, IS_LOCAL } from './constants/config.js';
88
- import { sandboxClient } from './sandbox.js';
89
- import { execSync } from 'child_process';
90
-
91
- // File system watchers for provider project/session folders
92
- const PROVIDER_WATCH_PATHS = [
93
- { provider: 'claude', rootPath: path.join(os.homedir(), '.claude', 'projects') },
94
- { provider: 'cursor', rootPath: path.join(os.homedir(), '.cursor', 'chats') },
95
- { provider: 'codex', rootPath: path.join(os.homedir(), '.codex', 'sessions') }
96
- ];
97
- const WATCHER_IGNORED_PATTERNS = [
98
- '**/node_modules/**',
99
- '**/.git/**',
100
- '**/dist/**',
101
- '**/build/**',
102
- '**/*.tmp',
103
- '**/*.swp',
104
- '**/.DS_Store'
105
- ];
106
- const WATCHER_DEBOUNCE_MS = 300;
107
- let projectsWatchers = [];
108
- let projectsWatcherDebounceTimer = null;
109
- const connectedClients = new Set();
110
- let isGetProjectsRunning = false; // Flag to prevent reentrant calls
111
-
112
- // Relay connections: Maps userId → { ws, capabilities, user }
113
- // Connects user's local machine to the hosted server
114
- const relayConnections = new Map();
115
- // Pending relay requests: Maps requestId → { resolve, reject, timeout }
116
- const pendingRelayRequests = new Map();
117
-
118
- // Session-tab locking: Maps sessionId → WebSocket connection
119
- // Prevents the same session from being active in multiple tabs
120
- const sessionLocks = new Map();
121
-
122
- function acquireSessionLock(sessionId, ws) {
123
- if (!sessionId) return true; // New sessions don't need locks yet
124
- const existingWs = sessionLocks.get(sessionId);
125
- if (existingWs && existingWs !== ws && existingWs.readyState === 1) {
126
- return false; // Another tab has this session locked
127
- }
128
- sessionLocks.set(sessionId, ws);
129
- return true;
130
- }
131
-
132
- function releaseSessionLock(sessionId, ws) {
133
- const lockedWs = sessionLocks.get(sessionId);
134
- if (lockedWs === ws) {
135
- sessionLocks.delete(sessionId);
136
- }
137
- }
138
-
139
- function releaseAllLocksForWs(ws) {
140
- for (const [sessionId, lockedWs] of sessionLocks.entries()) {
141
- if (lockedWs === ws) {
142
- sessionLocks.delete(sessionId);
143
- }
144
- }
145
- }
146
-
147
- // Broadcast progress to all connected WebSocket clients
148
- function broadcastProgress(progress) {
149
- const message = JSON.stringify({
150
- type: 'loading_progress',
151
- ...progress
152
- });
153
- connectedClients.forEach(client => {
154
- if (client.readyState === WebSocket.OPEN) {
155
- client.send(message);
156
- }
157
- });
158
- }
159
-
160
- // Setup file system watchers for Claude, Cursor, and Codex project/session folders
161
- async function setupProjectsWatcher() {
162
- const chokidar = (await import('chokidar')).default;
163
-
164
- if (projectsWatcherDebounceTimer) {
165
- clearTimeout(projectsWatcherDebounceTimer);
166
- projectsWatcherDebounceTimer = null;
167
- }
168
-
169
- await Promise.all(
170
- projectsWatchers.map(async (watcher) => {
171
- try {
172
- await watcher.close();
173
- } catch (error) {
174
- // watcher close error handled silently
175
- }
176
- })
177
- );
178
- projectsWatchers = [];
179
-
180
- const debouncedUpdate = (eventType, filePath, provider, rootPath) => {
181
- if (projectsWatcherDebounceTimer) {
182
- clearTimeout(projectsWatcherDebounceTimer);
183
- }
184
-
185
- projectsWatcherDebounceTimer = setTimeout(async () => {
186
- // Prevent reentrant calls
187
- if (isGetProjectsRunning) {
188
- return;
189
- }
190
-
191
- try {
192
- isGetProjectsRunning = true;
193
-
194
- // Clear project directory cache when files change
195
- clearProjectDirectoryCache();
196
-
197
- // Get updated projects list
198
- const updatedProjects = await getProjects(broadcastProgress);
199
-
200
- // Notify all connected clients about the project changes
201
- const updateMessage = JSON.stringify({
202
- type: 'projects_updated',
203
- projects: updatedProjects,
204
- timestamp: new Date().toISOString(),
205
- changeType: eventType,
206
- changedFile: path.relative(rootPath, filePath),
207
- watchProvider: provider
208
- });
209
-
210
- connectedClients.forEach(client => {
211
- if (client.readyState === WebSocket.OPEN) {
212
- client.send(updateMessage);
213
- }
214
- });
215
-
216
- } catch (error) {
217
- // project change error handled silently
218
- } finally {
219
- isGetProjectsRunning = false;
220
- }
221
- }, WATCHER_DEBOUNCE_MS);
222
- };
223
-
224
- for (const { provider, rootPath } of PROVIDER_WATCH_PATHS) {
225
- try {
226
- // chokidar v4 emits ENOENT via the "error" event for missing roots and will not auto-recover.
227
- // Ensure provider folders exist before creating the watcher so watching stays active.
228
- await fsPromises.mkdir(rootPath, { recursive: true });
229
-
230
- // Initialize chokidar watcher with optimized settings
231
- const watcher = chokidar.watch(rootPath, {
232
- ignored: WATCHER_IGNORED_PATTERNS,
233
- persistent: true,
234
- ignoreInitial: true, // Don't fire events for existing files on startup
235
- followSymlinks: false,
236
- depth: 10, // Reasonable depth limit
237
- awaitWriteFinish: {
238
- stabilityThreshold: 100, // Wait 100ms for file to stabilize
239
- pollInterval: 50
240
- }
241
- });
242
-
243
- // Set up event listeners
244
- watcher
245
- .on('add', (filePath) => debouncedUpdate('add', filePath, provider, rootPath))
246
- .on('change', (filePath) => debouncedUpdate('change', filePath, provider, rootPath))
247
- .on('unlink', (filePath) => debouncedUpdate('unlink', filePath, provider, rootPath))
248
- .on('addDir', (dirPath) => debouncedUpdate('addDir', dirPath, provider, rootPath))
249
- .on('unlinkDir', (dirPath) => debouncedUpdate('unlinkDir', dirPath, provider, rootPath))
250
- .on('error', (error) => {
251
- // provider watcher error handled silently
252
- })
253
- .on('ready', () => {
254
- });
255
-
256
- projectsWatchers.push(watcher);
257
- } catch (error) {
258
- // provider watcher setup error handled silently
259
- }
260
- }
261
-
262
- if (projectsWatchers.length === 0) {
263
- // no provider watchers available
264
- }
265
- }
266
-
267
-
268
- const app = express();
269
-
270
- // On Vercel serverless, we don't need an HTTP server or WebSocket server
271
- const server = process.env.VERCEL ? null : http.createServer(app);
272
-
273
- const ptySessionsMap = new Map();
274
- const PTY_SESSION_TIMEOUT = 30 * 60 * 1000;
275
- const SHELL_URL_PARSE_BUFFER_LIMIT = 32768;
276
- const ANSI_ESCAPE_SEQUENCE_REGEX = /\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~]|\][^\x07]*(?:\x07|\x1B\\))/g;
277
- const TRAILING_URL_PUNCTUATION_REGEX = /[)\]}>.,;:!?]+$/;
278
-
279
- function stripAnsiSequences(value = '') {
280
- return value.replace(ANSI_ESCAPE_SEQUENCE_REGEX, '');
281
- }
282
-
283
- function normalizeDetectedUrl(url) {
284
- if (!url || typeof url !== 'string') return null;
285
-
286
- const cleaned = url.trim().replace(TRAILING_URL_PUNCTUATION_REGEX, '');
287
- if (!cleaned) return null;
288
-
289
- try {
290
- const parsed = new URL(cleaned);
291
- if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
292
- return null;
293
- }
294
- return parsed.toString();
295
- } catch {
296
- return null;
297
- }
298
- }
299
-
300
- function extractUrlsFromText(value = '') {
301
- const directMatches = value.match(/https?:\/\/[^\s<>"'`\\\x1b\x07]+/gi) || [];
302
-
303
- // Handle wrapped terminal URLs split across lines by terminal width.
304
- const wrappedMatches = [];
305
- const continuationRegex = /^[A-Za-z0-9\-._~:/?#\[\]@!$&'()*+,;=%]+$/;
306
- const lines = value.split(/\r?\n/);
307
- for (let i = 0; i < lines.length; i++) {
308
- const line = lines[i].trim();
309
- const startMatch = line.match(/https?:\/\/[^\s<>"'`\\\x1b\x07]+/i);
310
- if (!startMatch) continue;
311
-
312
- let combined = startMatch[0];
313
- let j = i + 1;
314
- while (j < lines.length) {
315
- const continuation = lines[j].trim();
316
- if (!continuation) break;
317
- if (!continuationRegex.test(continuation)) break;
318
- combined += continuation;
319
- j++;
320
- }
321
-
322
- wrappedMatches.push(combined.replace(/\r?\n\s*/g, ''));
323
- }
324
-
325
- return Array.from(new Set([...directMatches, ...wrappedMatches]));
326
- }
327
-
328
- function shouldAutoOpenUrlFromOutput(value = '') {
329
- const normalized = value.toLowerCase();
330
- return (
331
- normalized.includes('browser didn\'t open') ||
332
- normalized.includes('open this url') ||
333
- normalized.includes('continue in your browser') ||
334
- normalized.includes('press enter to open') ||
335
- normalized.includes('open_url:')
336
- );
337
- }
338
-
339
- // Single WebSocket server that handles both paths (skip on Vercel serverless)
340
- let wss = null;
341
- if (server) {
342
- wss = new WebSocketServer({
343
- server,
344
- verifyClient: (info, done) => {
345
- const reqUrl = info.req.url || '';
346
- const origin = info.req.headers?.origin || 'no-origin';
347
- console.log(`[WS] Upgrade request: ${reqUrl} from ${origin}`);
348
-
349
- authenticateWebSocket(info.req).then(user => {
350
- if (!user) {
351
- console.log(`[WS] Auth FAILED for ${reqUrl} — no valid token/session`);
352
- done(false, 401, 'Unauthorized');
353
- return;
354
- }
355
- info.req.user = user;
356
- console.log(`[WS] Auth OK: userId=${user.userId} username=${user.username} path=${reqUrl}`);
357
- done(true);
358
- }).catch(err => {
359
- console.error(`[WS] Auth ERROR for ${reqUrl}:`, err.message);
360
- done(false, 500, 'Auth error');
361
- });
362
- }
363
- });
364
- }
365
-
366
- // Make WebSocket server available to routes
367
- app.locals.wss = wss;
368
-
369
- // Security headers — protect against common web attacks
370
- app.use((req, res, next) => {
371
- res.setHeader('X-Content-Type-Options', 'nosniff');
372
- // Allow framing from our own frontend domains (Vercel embeds Railway in an iframe)
373
- const allowedFrameOrigins = (process.env.CORS_ORIGINS || '').split(',').map(s => s.trim()).filter(Boolean);
374
- if (allowedFrameOrigins.length > 0) {
375
- res.setHeader('Content-Security-Policy', `frame-ancestors 'self' ${allowedFrameOrigins.join(' ')}`);
376
- } else {
377
- res.setHeader('X-Frame-Options', 'SAMEORIGIN');
378
- }
379
- res.setHeader('X-XSS-Protection', '1; mode=block');
380
- res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
381
- if (process.env.NODE_ENV === 'production') {
382
- res.setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
383
- }
384
- next();
385
- });
386
-
387
- // CORS: require explicit CORS_ORIGINS in production, restrict to same-origin otherwise
388
- const CORS_ORIGINS = process.env.CORS_ORIGINS
389
- ? process.env.CORS_ORIGINS.split(',').map(o => o.trim())
390
- : (process.env.NODE_ENV === 'production' ? ['https://cli.upfyn.com'] : true);
391
- app.use(cors({ origin: CORS_ORIGINS, credentials: true }));
392
- app.use(cookieParser());
393
- app.use(express.json({
394
- limit: '50mb',
395
- type: (req) => {
396
- // Skip multipart/form-data requests (for file uploads like images)
397
- const contentType = req.headers['content-type'] || '';
398
- if (contentType.includes('multipart/form-data')) {
399
- return false;
400
- }
401
- return contentType.includes('json');
402
- }
403
- }));
404
- app.use(express.urlencoded({ limit: '50mb', extended: true }));
405
-
406
- // Rate limiting for auth endpoints — prevent brute force attacks
407
- const authRateLimitMap = new Map();
408
- const AUTH_RATE_WINDOW = 15 * 60 * 1000; // 15 minutes
409
- const AUTH_RATE_MAX = 10; // max attempts per window
410
-
411
- function authRateLimit(req, res, next) {
412
- const key = req.ip || req.connection.remoteAddress || 'unknown';
413
- const now = Date.now();
414
- const entry = authRateLimitMap.get(key);
415
-
416
- if (entry) {
417
- // Clean expired entries
418
- if (now - entry.windowStart > AUTH_RATE_WINDOW) {
419
- authRateLimitMap.set(key, { windowStart: now, count: 1 });
420
- return next();
421
- }
422
- entry.count++;
423
- if (entry.count > AUTH_RATE_MAX) {
424
- return res.status(429).json({ error: 'Too many attempts. Please try again later.' });
425
- }
426
- } else {
427
- authRateLimitMap.set(key, { windowStart: now, count: 1 });
428
- }
429
- next();
430
- }
431
-
432
- // Clean up stale rate limit entries every 30 minutes
433
- setInterval(() => {
434
- const now = Date.now();
435
- for (const [key, entry] of authRateLimitMap) {
436
- if (now - entry.windowStart > AUTH_RATE_WINDOW) {
437
- authRateLimitMap.delete(key);
438
- }
439
- }
440
- }, 30 * 60 * 1000);
441
-
442
- app.locals.authRateLimit = authRateLimit;
443
-
444
- // Vercel serverless: lazy DB initialization on first request
445
- let dbInitialized = false;
446
- if (process.env.VERCEL) {
447
- app.use(async (req, res, next) => {
448
- if (!dbInitialized) {
449
- try {
450
- await initializeDatabase();
451
- dbInitialized = true;
452
- } catch (err) {
453
- // DB init error handled silently
454
- return res.status(500).json({ error: 'Service temporarily unavailable' });
455
- }
456
- }
457
- next();
458
- });
459
- }
460
-
461
- // Public health check endpoint (no authentication required)
462
- app.get('/health', (req, res) => {
463
- res.json({
464
- status: 'ok',
465
- timestamp: new Date().toISOString()
466
- });
467
- });
468
-
469
- // Optional API key validation (if configured)
470
- app.use('/api', validateApiKey);
471
-
472
- // Authentication routes (public)
473
- app.use('/api/auth', authRoutes);
474
-
475
- // Projects API Routes (protected)
476
- app.use('/api/projects', authenticateToken, projectsRoutes);
477
-
478
- // Git API Routes (protected)
479
- app.use('/api/git', authenticateToken, gitRoutes);
480
-
481
- // MCP API Routes (protected)
482
- app.use('/api/mcp', authenticateToken, mcpRoutes);
483
-
484
- // Cursor API Routes (protected)
485
- app.use('/api/cursor', authenticateToken, cursorRoutes);
486
-
487
- // TaskMaster API Routes (protected)
488
- app.use('/api/taskmaster', authenticateToken, taskmasterRoutes);
489
-
490
- // MCP utilities
491
- app.use('/api/mcp-utils', authenticateToken, mcpUtilsRoutes);
492
-
493
- // Upfyn-Code MCP Server — exposes app capabilities to any MCP client (ChatGPT, Claude Desktop, Cursor, etc.)
494
- const mcpDeps = {
495
- getProjects,
496
- getSessions,
497
- getSessionMessages,
498
- queryClaudeSDK,
499
- abortClaudeSDKSession,
500
- getActiveClaudeSDKSessions,
501
- connectedClients,
502
- };
503
- const mcpAppServer = createMcpServer(mcpDeps);
504
- const mcpServerFactory = () => createMcpServer(mcpDeps);
505
- mountMcpServer(app, mcpAppServer, mcpServerFactory).catch(err => console.error('[MCP] Failed to mount:', err.message));
506
-
507
- // Commands API Routes (protected)
508
- app.use('/api/commands', authenticateToken, commandsRoutes);
509
-
510
- // Settings API Routes (protected)
511
- app.use('/api/settings', authenticateToken, settingsRoutes);
512
-
513
- // CLI Authentication API Routes (protected)
514
- app.use('/api/cli', authenticateToken, cliAuthRoutes);
515
-
516
- // User API Routes (protected)
517
- app.use('/api/user', authenticateToken, userRoutes);
518
-
519
- // Codex API Routes (protected)
520
- app.use('/api/codex', authenticateToken, codexRoutes);
521
-
522
- // Payment & Subscription Routes (protected)
523
- app.use('/api/payments', authenticateToken, paymentRoutes);
524
- app.use('/api/webhooks', authenticateToken, webhookRoutes);
525
- app.use('/api/workflows', authenticateToken, workflowRoutes);
526
- app.use('/api/voice', authenticateToken, voiceRoutes);
527
- app.use('/api/dashboard', authenticateToken, dashboardRoutes);
528
- app.use('/api/vapi', vapiChatRoutes);
529
-
530
- // Agent API Routes (uses API key authentication)
531
- app.use('/api/agent', agentRoutes);
532
-
533
- // Relay token management routes
534
- app.get('/api/relay/tokens', authenticateToken, async (req, res) => {
535
- try {
536
- const tokens = await relayTokensDb.getTokens(req.user.id);
537
- res.json(tokens.map(t => ({ ...t, token: t.token.slice(0, 10) + '...' }))); // mask tokens
538
- } catch (err) {
539
- res.status(500).json({ error: 'Failed to fetch relay tokens' });
540
- }
541
- });
542
-
543
- app.post('/api/relay/tokens', authenticateToken, async (req, res) => {
544
- try {
545
- const name = req.body.name || 'default';
546
- const result = await relayTokensDb.createToken(req.user.id, name);
547
- res.json(result); // returns full token only on creation
548
- } catch (err) {
549
- res.status(500).json({ error: 'Failed to create relay token' });
550
- }
551
- });
552
-
553
- app.delete('/api/relay/tokens/:id', authenticateToken, async (req, res) => {
554
- try {
555
- await relayTokensDb.deleteToken(req.user.id, req.params.id);
556
- res.json({ success: true });
557
- } catch (err) {
558
- res.status(500).json({ error: 'Failed to delete relay token' });
559
- }
560
- });
561
-
562
- app.get('/api/relay/status', authenticateToken, (req, res) => {
563
- // In local mode, always connected — SDK runs directly on this machine
564
- if (IS_LOCAL) {
565
- return res.json({ connected: true, local: true, connectedAt: Date.now() });
566
- }
567
- const relay = relayConnections.get(Number(req.user.id));
568
- res.json({
569
- connected: !!(relay && relay.ws.readyState === 1),
570
- connectedAt: relay?.connectedAt || null
571
- });
572
- });
573
-
574
- // ─── Sandbox API endpoints ──────────────────────────────────────────────────
575
-
576
- // Initialize sandbox for user
577
- app.post('/api/sandbox/init', authenticateToken, async (req, res) => {
578
- try {
579
- const result = await sandboxClient.initSandbox(req.user.id);
580
- res.json(result);
581
- } catch (err) {
582
- res.status(503).json({ error: err.message });
583
- }
584
- });
585
-
586
- // Get sandbox status
587
- app.get('/api/sandbox/status', authenticateToken, async (req, res) => {
588
- try {
589
- const result = await sandboxClient.getStatus(req.user.id);
590
- res.json(result);
591
- } catch (err) {
592
- res.status(503).json({ error: err.message });
593
- }
594
- });
595
-
596
- // Execute command in sandbox
597
- app.post('/api/sandbox/exec', authenticateToken, async (req, res) => {
598
- try {
599
- const { command, cwd, timeout } = req.body;
600
- // Get user's BYOK keys for sandbox env
601
- const userKeys = {};
602
- try {
603
- const anthropicKey = await credentialsDb.getCredentialByType(req.user.id, 'anthropic_key');
604
- if (anthropicKey) userKeys.anthropic_key = anthropicKey.credential_value;
605
- const openaiKey = await credentialsDb.getCredentialByType(req.user.id, 'openai_key');
606
- if (openaiKey) userKeys.openai_key = openaiKey.credential_value;
607
- } catch { /* no keys */ }
608
- const result = await sandboxClient.exec(req.user.id, command, { cwd, timeout, userKeys });
609
- res.json(result);
610
- } catch (err) {
611
- res.status(500).json({ error: err.message });
612
- }
613
- });
614
-
615
- // Read file from sandbox
616
- app.post('/api/sandbox/file/read', authenticateToken, async (req, res) => {
617
- try {
618
- const result = await sandboxClient.readFile(req.user.id, req.body.filePath);
619
- res.json(result);
620
- } catch (err) {
621
- const status = err.message?.includes('Access denied') ? 403
622
- : err.message?.includes('ENOENT') || err.message?.includes('404') ? 404 : 500;
623
- res.status(status).json({ error: err.message });
624
- }
625
- });
626
-
627
- // Write file to sandbox
628
- app.post('/api/sandbox/file/write', authenticateToken, async (req, res) => {
629
- try {
630
- const result = await sandboxClient.writeFile(req.user.id, req.body.filePath, req.body.content);
631
- res.json(result);
632
- } catch (err) {
633
- res.status(500).json({ error: err.message });
634
- }
635
- });
636
-
637
- // File tree from sandbox
638
- app.post('/api/sandbox/file/tree', authenticateToken, async (req, res) => {
639
- try {
640
- const result = await sandboxClient.getFileTree(req.user.id, req.body.dirPath, req.body.depth);
641
- res.json(result);
642
- } catch (err) {
643
- res.status(500).json({ error: err.message });
644
- }
645
- });
646
-
647
- // Git operation in sandbox
648
- app.post('/api/sandbox/git', authenticateToken, async (req, res) => {
649
- try {
650
- const result = await sandboxClient.gitOperation(req.user.id, req.body.gitCommand, req.body.cwd);
651
- res.json(result);
652
- } catch (err) {
653
- res.status(500).json({ error: err.message });
654
- }
655
- });
656
-
657
- // Destroy sandbox
658
- app.delete('/api/sandbox', authenticateToken, async (req, res) => {
659
- try {
660
- const result = await sandboxClient.destroySandbox(req.user.id);
661
- res.json(result);
662
- } catch (err) {
663
- res.status(500).json({ error: err.message });
664
- }
665
- });
666
-
667
- /**
668
- * Detect installed AI CLI agents on the local machine (server-side).
669
- * Used in self-hosted/local mode where no relay is needed.
670
- */
671
- let cachedLocalAgents = null;
672
- let localAgentsCacheTime = 0;
673
- function detectLocalAgents() {
674
- // Cache for 60 seconds
675
- if (cachedLocalAgents && Date.now() - localAgentsCacheTime < 60000) {
676
- return cachedLocalAgents;
677
- }
678
- const isWindows = process.platform === 'win32';
679
- const whichCmd = isWindows ? 'where' : 'which';
680
- const agents = [
681
- { name: 'claude', binary: 'claude', label: 'Claude Code' },
682
- { name: 'codex', binary: 'codex', label: 'OpenAI Codex' },
683
- { name: 'cursor', binary: 'cursor-agent', label: 'Cursor Agent' },
684
- ];
685
- const detected = {};
686
- for (const agent of agents) {
687
- try {
688
- const result = execSync(`${whichCmd} ${agent.binary}`, { stdio: 'pipe', timeout: 5000 }).toString().trim();
689
- detected[agent.name] = { installed: true, path: result.split('\n')[0].trim(), label: agent.label };
690
- } catch {
691
- detected[agent.name] = { installed: false, label: agent.label };
692
- }
693
- }
694
- cachedLocalAgents = detected;
695
- localAgentsCacheTime = Date.now();
696
- return detected;
697
- }
698
-
699
- // Connection status — alias at path the frontend expects
700
- app.get('/api/auth/connection-status', authenticateToken, async (req, res) => {
701
- const relay = relayConnections.get(Number(req.user.id));
702
- const connected = !!(relay && relay.ws.readyState === 1);
703
-
704
- // In local mode, always "connected" — SDK runs directly on this machine
705
- if (IS_LOCAL) {
706
- const agents = detectLocalAgents();
707
- return res.json({
708
- connected: true,
709
- local: true,
710
- connectedAt: Date.now(),
711
- agents,
712
- machine: {
713
- hostname: os.hostname(),
714
- platform: process.platform,
715
- cwd: process.cwd(),
716
- }
717
- });
718
- }
719
-
720
- // Check sandbox service availability when no relay
721
- let sandboxAvailable = false;
722
- let sandboxInfo = null;
723
- if (!connected) {
724
- try {
725
- sandboxAvailable = await sandboxClient.isAvailable();
726
- if (sandboxAvailable) {
727
- sandboxInfo = await sandboxClient.getStatus(req.user.id);
728
- }
729
- } catch { /* sandbox service unreachable */ }
730
- }
731
-
732
- res.json({
733
- connected,
734
- local: false,
735
- connectedAt: relay?.connectedAt || null,
736
- agents: connected ? (relay.agents || null) : null,
737
- machine: connected ? {
738
- hostname: relay.machine,
739
- platform: relay.platform,
740
- cwd: relay.cwd,
741
- version: relay.version,
742
- } : null,
743
- sandbox: {
744
- available: sandboxAvailable,
745
- active: sandboxInfo?.exists || false,
746
- diskUsage: sandboxInfo?.exists ? {
747
- usedMB: sandboxInfo.usedMB,
748
- maxMB: sandboxInfo.maxMB,
749
- } : null,
750
- },
751
- });
752
- });
753
-
754
- // Serve public files (like api-docs.html)
755
- app.use(express.static(path.join(__dirname, '../client/public')));
756
-
757
- // Static files served after API routes
758
- // Add cache control: HTML files should not be cached, but assets can be cached
759
- app.use(express.static(path.join(__dirname, '../client/dist'), {
760
- setHeaders: (res, filePath) => {
761
- if (filePath.endsWith('.html')) {
762
- // Prevent HTML caching to avoid service worker issues after builds
763
- res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
764
- res.setHeader('Pragma', 'no-cache');
765
- res.setHeader('Expires', '0');
766
- } else if (filePath.match(/\.(js|css|woff2?|ttf|eot|svg|png|jpg|jpeg|gif|ico)$/)) {
767
- // Cache static assets for 1 year (they have hashed names)
768
- res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
769
- }
770
- }
771
- }));
772
-
773
- // API Routes (protected)
774
- // /api/config endpoint removed - no longer needed
775
- // Frontend now uses window.location for WebSocket URLs
776
-
777
- // System update endpoint — REMOVED for security (shell command execution risk)
778
- // Use `uc update` from CLI instead
779
-
780
- app.get('/api/projects', authenticateToken, async (req, res) => {
781
- try {
782
- const projects = await getProjects(broadcastProgress);
783
- res.json(projects);
784
- } catch (error) {
785
- res.status(500).json({ error: 'Internal server error' });
786
- }
787
- });
788
-
789
- app.get('/api/projects/:projectName/sessions', authenticateToken, async (req, res) => {
790
- try {
791
- const { limit = 5, offset = 0 } = req.query;
792
- const result = await getSessions(req.params.projectName, parseInt(limit), parseInt(offset));
793
- res.json(result);
794
- } catch (error) {
795
- res.status(500).json({ error: 'Internal server error' });
796
- }
797
- });
798
-
799
- // Get messages for a specific session
800
- app.get('/api/projects/:projectName/sessions/:sessionId/messages', authenticateToken, async (req, res) => {
801
- try {
802
- const { projectName, sessionId } = req.params;
803
- const { limit, offset } = req.query;
804
-
805
- // Parse limit and offset if provided
806
- const parsedLimit = limit ? parseInt(limit, 10) : null;
807
- const parsedOffset = offset ? parseInt(offset, 10) : 0;
808
-
809
- const result = await getSessionMessages(projectName, sessionId, parsedLimit, parsedOffset);
810
-
811
- // Handle both old and new response formats
812
- if (Array.isArray(result)) {
813
- // Backward compatibility: no pagination parameters were provided
814
- res.json({ messages: result });
815
- } else {
816
- // New format with pagination info
817
- res.json(result);
818
- }
819
- } catch (error) {
820
- res.status(500).json({ error: 'Internal server error' });
821
- }
822
- });
823
-
824
- // Rename project endpoint
825
- app.put('/api/projects/:projectName/rename', authenticateToken, async (req, res) => {
826
- try {
827
- const { displayName } = req.body;
828
- await renameProject(req.params.projectName, displayName);
829
- res.json({ success: true });
830
- } catch (error) {
831
- res.status(500).json({ error: 'Internal server error' });
832
- }
833
- });
834
-
835
- // Delete session endpoint
836
- app.delete('/api/projects/:projectName/sessions/:sessionId', authenticateToken, async (req, res) => {
837
- try {
838
- const { projectName, sessionId } = req.params;
839
- // deleting session
840
- await deleteSession(projectName, sessionId);
841
- // session deleted
842
- res.json({ success: true });
843
- } catch (error) {
844
- // session delete error
845
- res.status(500).json({ error: 'Internal server error' });
846
- }
847
- });
848
-
849
- // Delete project endpoint (force=true to delete with sessions)
850
- app.delete('/api/projects/:projectName', authenticateToken, async (req, res) => {
851
- try {
852
- const { projectName } = req.params;
853
- const force = req.query.force === 'true';
854
- await deleteProject(projectName, force);
855
- res.json({ success: true });
856
- } catch (error) {
857
- res.status(500).json({ error: 'Internal server error' });
858
- }
859
- });
860
-
861
- // Create project endpoint
862
- app.post('/api/projects/create', authenticateToken, async (req, res) => {
863
- try {
864
- const { path: projectPath } = req.body;
865
-
866
- if (!projectPath || !projectPath.trim()) {
867
- return res.status(400).json({ error: 'Project path is required' });
868
- }
869
-
870
- const project = await addProjectManually(projectPath.trim());
871
- res.json({ success: true, project });
872
- } catch (error) {
873
- // project creation error handled silently
874
- res.status(500).json({ error: 'Internal server error' });
875
- }
876
- });
877
-
878
- const expandWorkspacePath = (inputPath) => {
879
- if (!inputPath) return inputPath;
880
- if (inputPath === '~') {
881
- return WORKSPACES_ROOT;
882
- }
883
- if (inputPath.startsWith('~/') || inputPath.startsWith('~\\')) {
884
- return path.join(WORKSPACES_ROOT, inputPath.slice(2));
885
- }
886
- return inputPath;
887
- };
888
-
889
- // Browse filesystem endpoint for project suggestions - uses existing getFileTree
890
- app.get('/api/browse-filesystem', authenticateToken, async (req, res) => {
891
- try {
892
- const { path: dirPath } = req.query;
893
-
894
- // Browse filesystem request
895
- // Default to home directory if no path provided
896
- const defaultRoot = WORKSPACES_ROOT;
897
- let targetPath = dirPath ? expandWorkspacePath(dirPath) : defaultRoot;
898
-
899
- // Resolve and normalize the path
900
- targetPath = path.resolve(targetPath);
901
-
902
- // Security check - ensure path is within allowed workspace root
903
- const validation = await validateWorkspacePath(targetPath);
904
- if (!validation.valid) {
905
- return res.status(403).json({ error: validation.error });
906
- }
907
- const resolvedPath = validation.resolvedPath || targetPath;
908
-
909
- // Security check - ensure path is accessible
910
- try {
911
- await fs.promises.access(resolvedPath);
912
- const stats = await fs.promises.stat(resolvedPath);
913
-
914
- if (!stats.isDirectory()) {
915
- return res.status(400).json({ error: 'Path is not a directory' });
916
- }
917
- } catch (err) {
918
- return res.status(404).json({ error: 'Directory not accessible' });
919
- }
920
-
921
- // Use existing getFileTree function with shallow depth (only direct children)
922
- const fileTree = await getFileTree(resolvedPath, 1, 0, false); // maxDepth=1, showHidden=false
923
-
924
- // Filter only directories and format for suggestions
925
- const directories = fileTree
926
- .filter(item => item.type === 'directory')
927
- .map(item => ({
928
- path: item.path,
929
- name: item.name,
930
- type: 'directory'
931
- }))
932
- .sort((a, b) => {
933
- const aHidden = a.name.startsWith('.');
934
- const bHidden = b.name.startsWith('.');
935
- if (aHidden && !bHidden) return 1;
936
- if (!aHidden && bHidden) return -1;
937
- return a.name.localeCompare(b.name);
938
- });
939
-
940
- // Add common directories if browsing home directory
941
- const suggestions = [];
942
- let resolvedWorkspaceRoot = defaultRoot;
943
- try {
944
- resolvedWorkspaceRoot = await fsPromises.realpath(defaultRoot);
945
- } catch (error) {
946
- // Use default root as-is if realpath fails
947
- }
948
- if (resolvedPath === resolvedWorkspaceRoot) {
949
- const commonDirs = ['Desktop', 'Documents', 'Projects', 'Development', 'Dev', 'Code', 'workspace'];
950
- const existingCommon = directories.filter(dir => commonDirs.includes(dir.name));
951
- const otherDirs = directories.filter(dir => !commonDirs.includes(dir.name));
952
-
953
- suggestions.push(...existingCommon, ...otherDirs);
954
- } else {
955
- suggestions.push(...directories);
956
- }
957
-
958
- res.json({
959
- path: resolvedPath,
960
- suggestions: suggestions
961
- });
962
-
963
- } catch (error) {
964
- // filesystem browse error handled silently
965
- res.status(500).json({ error: 'Failed to browse filesystem' });
966
- }
967
- });
968
-
969
- app.post('/api/create-folder', authenticateToken, async (req, res) => {
970
- try {
971
- const { path: folderPath } = req.body;
972
- if (!folderPath) {
973
- return res.status(400).json({ error: 'Path is required' });
974
- }
975
- const expandedPath = expandWorkspacePath(folderPath);
976
- const resolvedInput = path.resolve(expandedPath);
977
- const validation = await validateWorkspacePath(resolvedInput);
978
- if (!validation.valid) {
979
- return res.status(403).json({ error: validation.error });
980
- }
981
- const targetPath = validation.resolvedPath || resolvedInput;
982
- const parentDir = path.dirname(targetPath);
983
- try {
984
- await fs.promises.access(parentDir);
985
- } catch (err) {
986
- return res.status(404).json({ error: 'Parent directory does not exist' });
987
- }
988
- try {
989
- await fs.promises.access(targetPath);
990
- return res.status(409).json({ error: 'Folder already exists' });
991
- } catch (err) {
992
- // Folder doesn't exist, which is what we want
993
- }
994
- try {
995
- await fs.promises.mkdir(targetPath, { recursive: false });
996
- res.json({ success: true, path: targetPath });
997
- } catch (mkdirError) {
998
- if (mkdirError.code === 'EEXIST') {
999
- return res.status(409).json({ error: 'Folder already exists' });
1000
- }
1001
- throw mkdirError;
1002
- }
1003
- } catch (error) {
1004
- // folder creation error handled silently
1005
- res.status(500).json({ error: 'Failed to create folder' });
1006
- }
1007
- });
1008
-
1009
- // Read file content endpoint
1010
- app.get('/api/projects/:projectName/file', authenticateToken, async (req, res) => {
1011
- try {
1012
- const { projectName } = req.params;
1013
- const { filePath } = req.query;
1014
-
1015
-
1016
- // Security: ensure the requested path is inside the project root
1017
- if (!filePath) {
1018
- return res.status(400).json({ error: 'Invalid file path' });
1019
- }
1020
-
1021
- const projectRoot = await extractProjectDirectory(projectName).catch(() => null);
1022
- if (!projectRoot) {
1023
- return res.status(404).json({ error: 'Project not found' });
1024
- }
1025
-
1026
- // Always resolve relative to project root to prevent path traversal
1027
- const resolved = path.resolve(projectRoot, filePath);
1028
- const normalizedRoot = path.resolve(projectRoot) + path.sep;
1029
- if (!resolved.startsWith(normalizedRoot) && resolved !== path.resolve(projectRoot)) {
1030
- return res.status(403).json({ error: 'Access denied' });
1031
- }
1032
-
1033
- const content = await fsPromises.readFile(resolved, 'utf8');
1034
- res.json({ content, path: resolved });
1035
- } catch (error) {
1036
- // file read error handled silently
1037
- if (error.code === 'ENOENT') {
1038
- res.status(404).json({ error: 'File not found' });
1039
- } else if (error.code === 'EACCES') {
1040
- res.status(403).json({ error: 'Permission denied' });
1041
- } else {
1042
- res.status(500).json({ error: 'Internal server error' });
1043
- }
1044
- }
1045
- });
1046
-
1047
- // Serve binary file content endpoint (for images, etc.)
1048
- app.get('/api/projects/:projectName/files/content', authenticateToken, async (req, res) => {
1049
- try {
1050
- const { projectName } = req.params;
1051
- const { path: filePath } = req.query;
1052
-
1053
-
1054
- // Security: ensure the requested path is inside the project root
1055
- if (!filePath) {
1056
- return res.status(400).json({ error: 'Invalid file path' });
1057
- }
1058
-
1059
- const projectRoot = await extractProjectDirectory(projectName).catch(() => null);
1060
- if (!projectRoot) {
1061
- return res.status(404).json({ error: 'Project not found' });
1062
- }
1063
-
1064
- // Resolve path relative to project root to prevent path traversal
1065
- const resolved = path.resolve(projectRoot, filePath);
1066
- const normalizedRoot = path.resolve(projectRoot) + path.sep;
1067
- if (!resolved.startsWith(normalizedRoot) && resolved !== path.resolve(projectRoot)) {
1068
- return res.status(403).json({ error: 'Access denied' });
1069
- }
1070
-
1071
- // Check if file exists
1072
- try {
1073
- await fsPromises.access(resolved);
1074
- } catch (error) {
1075
- return res.status(404).json({ error: 'File not found' });
1076
- }
1077
-
1078
- // Get file extension and set appropriate content type
1079
- const mimeType = mime.lookup(resolved) || 'application/octet-stream';
1080
- res.setHeader('Content-Type', mimeType);
1081
-
1082
- // Stream the file
1083
- const fileStream = fs.createReadStream(resolved);
1084
- fileStream.pipe(res);
1085
-
1086
- fileStream.on('error', (error) => {
1087
- // file streaming error handled silently
1088
- if (!res.headersSent) {
1089
- res.status(500).json({ error: 'Error reading file' });
1090
- }
1091
- });
1092
-
1093
- } catch (error) {
1094
- // binary file serve error handled silently
1095
- if (!res.headersSent) {
1096
- res.status(500).json({ error: 'Internal server error' });
1097
- }
1098
- }
1099
- });
1100
-
1101
- // Save file content endpoint
1102
- app.put('/api/projects/:projectName/file', authenticateToken, async (req, res) => {
1103
- try {
1104
- const { projectName } = req.params;
1105
- const { filePath, content } = req.body;
1106
-
1107
-
1108
- // Security: ensure the requested path is inside the project root
1109
- if (!filePath) {
1110
- return res.status(400).json({ error: 'Invalid file path' });
1111
- }
1112
-
1113
- if (content === undefined) {
1114
- return res.status(400).json({ error: 'Content is required' });
1115
- }
1116
-
1117
- const projectRoot = await extractProjectDirectory(projectName).catch(() => null);
1118
- if (!projectRoot) {
1119
- return res.status(404).json({ error: 'Project not found' });
1120
- }
1121
-
1122
- // Always resolve relative to project root to prevent path traversal
1123
- const resolved = path.resolve(projectRoot, filePath);
1124
- const normalizedRoot = path.resolve(projectRoot) + path.sep;
1125
- if (!resolved.startsWith(normalizedRoot) && resolved !== path.resolve(projectRoot)) {
1126
- return res.status(403).json({ error: 'Access denied' });
1127
- }
1128
-
1129
- // Write the new content
1130
- await fsPromises.writeFile(resolved, content, 'utf8');
1131
-
1132
- res.json({
1133
- success: true,
1134
- path: resolved,
1135
- message: 'File saved successfully'
1136
- });
1137
- } catch (error) {
1138
- // file save error handled silently
1139
- if (error.code === 'ENOENT') {
1140
- res.status(404).json({ error: 'File or directory not found' });
1141
- } else if (error.code === 'EACCES') {
1142
- res.status(403).json({ error: 'Permission denied' });
1143
- } else {
1144
- res.status(500).json({ error: 'Internal server error' });
1145
- }
1146
- }
1147
- });
1148
-
1149
- app.get('/api/projects/:projectName/files', authenticateToken, async (req, res) => {
1150
- try {
1151
-
1152
- // Using fsPromises from import
1153
-
1154
- // Use extractProjectDirectory to get the actual project path
1155
- let actualPath;
1156
- try {
1157
- actualPath = await extractProjectDirectory(req.params.projectName);
1158
- } catch (error) {
1159
- // project dir extraction error handled silently
1160
- // Fallback to simple dash replacement
1161
- actualPath = req.params.projectName.replace(/-/g, '/');
1162
- }
1163
-
1164
- // Check if path exists
1165
- try {
1166
- await fsPromises.access(actualPath);
1167
- } catch (e) {
1168
- return res.status(404).json({ error: `Project path not found: ${actualPath}` });
1169
- }
1170
-
1171
- const files = await getFileTree(actualPath, 10, 0, true);
1172
- const hiddenFiles = files.filter(f => f.name.startsWith('.'));
1173
- res.json(files);
1174
- } catch (error) {
1175
- // file tree error handled silently
1176
- res.status(500).json({ error: 'Internal server error' });
1177
- }
1178
- });
1179
-
1180
- // WebSocket connection handler that routes based on URL path (skip on Vercel)
1181
- if (wss) wss.on('connection', (ws, request) => {
1182
- const url = request.url;
1183
- // Client connected
1184
-
1185
- // Parse URL to get pathname without query parameters
1186
- const urlObj = new URL(url, 'http://localhost');
1187
- const pathname = urlObj.pathname;
1188
-
1189
- if (pathname === '/shell') {
1190
- handleShellConnection(ws, request);
1191
- } else if (pathname === '/ws') {
1192
- handleChatConnection(ws, request);
1193
- } else if (pathname === '/relay') {
1194
- handleRelayConnection(ws, urlObj.searchParams.get('token'), request);
1195
- } else {
1196
- // unknown WebSocket path
1197
- ws.close();
1198
- }
1199
- });
1200
-
1201
- /**
1202
- * WebSocket Writer - Wrapper for WebSocket to match SSEStreamWriter interface
1203
- */
1204
- class WebSocketWriter {
1205
- constructor(ws) {
1206
- this.ws = ws;
1207
- this.sessionId = null;
1208
- this.isWebSocketWriter = true; // Marker for transport detection
1209
- }
1210
-
1211
- send(data) {
1212
- if (this.ws.readyState === 1) { // WebSocket.OPEN
1213
- // Providers send raw objects, we stringify for WebSocket
1214
- this.ws.send(JSON.stringify(data));
1215
- }
1216
- }
1217
-
1218
- setSessionId(sessionId) {
1219
- this.sessionId = sessionId;
1220
- }
1221
-
1222
- getSessionId() {
1223
- return this.sessionId;
1224
- }
1225
- }
1226
-
1227
- /**
1228
- * Look up a user's stored API key for a given provider.
1229
- * Falls back to server env vars if user has none stored.
1230
- * @param {number} userId
1231
- * @param {string} providerType - e.g. 'anthropic_key', 'openai_key', 'openrouter_key', 'google_key'
1232
- * @returns {Promise<string|null>}
1233
- */
1234
- async function getUserProviderKey(userId, providerType) {
1235
- if (!userId) return null;
1236
- try {
1237
- const creds = await credentialsDb.getCredentials(userId, providerType);
1238
- const active = creds.find(c => c.is_active);
1239
- return active?.credential_value || null;
1240
- } catch { return null; }
1241
- }
1242
-
1243
- /**
1244
- * Temporarily set environment variable for an AI SDK call, then restore.
1245
- * @param {string} envKey - e.g. 'ANTHROPIC_API_KEY'
1246
- * @param {string|null} userKey - user's BYOK key, null to skip
1247
- * @param {Function} fn - async function to execute with the key set
1248
- */
1249
- async function withUserApiKey(envKey, userKey, fn) {
1250
- if (!userKey) return fn();
1251
- const prev = process.env[envKey];
1252
- process.env[envKey] = userKey;
1253
- try {
1254
- return await fn();
1255
- } finally {
1256
- if (prev !== undefined) process.env[envKey] = prev;
1257
- else delete process.env[envKey];
1258
- }
1259
- }
1260
-
1261
- // Handle chat WebSocket connections
1262
- function handleChatConnection(ws, request) {
1263
- // chat WebSocket connected
1264
- const wsUser = request?.user || null;
1265
-
1266
- // Add to connected clients for project updates
1267
- connectedClients.add(ws);
1268
-
1269
- // Wrap WebSocket with writer for consistent interface with SSEStreamWriter
1270
- const writer = new WebSocketWriter(ws);
1271
-
1272
- // Track which sessions this WebSocket has locked
1273
- const lockedSessionsForThisWs = new Set();
1274
-
1275
- // Wrap the original writer.send to capture session-created events for auto-locking
1276
- const originalSend = writer.send.bind(writer);
1277
- writer.send = (data) => {
1278
- // When a new session is created, auto-lock it to this WebSocket
1279
- if (data.type === 'session-created' && data.sessionId) {
1280
- sessionLocks.set(data.sessionId, ws);
1281
- lockedSessionsForThisWs.add(data.sessionId);
1282
- // session locked
1283
- }
1284
- originalSend(data);
1285
- };
1286
-
1287
- ws.on('message', async (message) => {
1288
- try {
1289
- const data = JSON.parse(message);
1290
-
1291
- // Handle session lock request (tab claiming a session)
1292
- if (data.type === 'lock-session') {
1293
- const sid = data.sessionId;
1294
- if (!sid) return;
1295
- if (acquireSessionLock(sid, ws)) {
1296
- lockedSessionsForThisWs.add(sid);
1297
- writer.send({ type: 'session-locked', sessionId: sid, success: true });
1298
- // session locked to tab
1299
- } else {
1300
- writer.send({ type: 'session-locked', sessionId: sid, success: false,
1301
- error: 'Session is already open in another tab' });
1302
- // session lock denied
1303
- }
1304
- return;
1305
- }
1306
-
1307
- // Handle session unlock request
1308
- if (data.type === 'unlock-session') {
1309
- const sid = data.sessionId;
1310
- if (sid) {
1311
- releaseSessionLock(sid, ws);
1312
- lockedSessionsForThisWs.delete(sid);
1313
- writer.send({ type: 'session-unlocked', sessionId: sid });
1314
- // session unlocked
1315
- }
1316
- return;
1317
- }
1318
-
1319
- if (data.type === 'claude-command') {
1320
- const sid = data.options?.sessionId;
1321
-
1322
- // Session-tab locking: check if this session is locked by another tab
1323
- if (sid && !acquireSessionLock(sid, ws)) {
1324
- writer.send({
1325
- type: 'session-lock-denied',
1326
- sessionId: sid,
1327
- error: 'This session is already active in another tab. Close it there first.'
1328
- });
1329
- return;
1330
- }
1331
- if (sid) lockedSessionsForThisWs.add(sid);
1332
-
1333
- // Check if user has active relay → route to local machine
1334
- if (hasActiveRelay(wsUser?.userId)) {
1335
- await routeViaRelay(wsUser.userId, 'claude-query', data, writer, {
1336
- response: 'claude-response',
1337
- complete: 'claude-complete',
1338
- error: 'claude-error'
1339
- });
1340
- } else {
1341
- // Fall back to server-side SDK
1342
- const userAnthropicKey = wsUser?.userId
1343
- ? await getUserProviderKey(wsUser.userId, 'anthropic_key')
1344
- : null;
1345
-
1346
- await withUserApiKey('ANTHROPIC_API_KEY', userAnthropicKey, () =>
1347
- queryClaudeSDK(data.command, data.options, writer)
1348
- );
1349
- }
1350
- } else if (data.type === 'cursor-command') {
1351
- // Check if user has active relay → route to local machine
1352
- if (hasActiveRelay(wsUser?.userId)) {
1353
- await routeViaRelay(wsUser.userId, 'cursor-query', data, writer, {
1354
- response: 'cursor-response',
1355
- complete: 'cursor-complete',
1356
- error: 'cursor-error'
1357
- });
1358
- } else {
1359
- await spawnCursor(data.command, data.options, writer);
1360
- }
1361
- } else if (data.type === 'codex-command') {
1362
- // Check if user has active relay → route to local machine
1363
- if (hasActiveRelay(wsUser?.userId)) {
1364
- await routeViaRelay(wsUser.userId, 'codex-query', data, writer, {
1365
- response: 'codex-response',
1366
- complete: 'codex-complete',
1367
- error: 'codex-error'
1368
- });
1369
- } else {
1370
- const userOpenaiKey = wsUser?.userId
1371
- ? await getUserProviderKey(wsUser.userId, 'openai_key')
1372
- : null;
1373
-
1374
- await withUserApiKey('OPENAI_API_KEY', userOpenaiKey, () =>
1375
- queryCodex(data.command, data.options, writer)
1376
- );
1377
- }
1378
- } else if (data.type === 'openrouter-command') {
1379
- // BYOK: OpenRouter requires user's own API key
1380
- const userOrKey = wsUser?.userId
1381
- ? await getUserProviderKey(wsUser.userId, 'openrouter_key')
1382
- : null;
1383
-
1384
- await queryOpenRouter(data.command, {
1385
- ...data.options,
1386
- apiKey: userOrKey,
1387
- }, writer);
1388
- } else if (data.type === 'cursor-resume') {
1389
- // Backward compatibility: treat as cursor-command with resume and no prompt
1390
- // cursor resume session
1391
- await spawnCursor('', {
1392
- sessionId: data.sessionId,
1393
- resume: true,
1394
- cwd: data.options?.cwd
1395
- }, writer);
1396
- } else if (data.type === 'abort-session') {
1397
- // abort session request
1398
- const provider = data.provider || 'claude';
1399
- let success;
1400
-
1401
- if (provider === 'cursor') {
1402
- success = abortCursorSession(data.sessionId);
1403
- } else if (provider === 'codex') {
1404
- success = abortCodexSession(data.sessionId);
1405
- } else {
1406
- // Use Claude Agents SDK
1407
- success = await abortClaudeSDKSession(data.sessionId);
1408
- }
1409
-
1410
- writer.send({
1411
- type: 'session-aborted',
1412
- sessionId: data.sessionId,
1413
- provider,
1414
- success
1415
- });
1416
- } else if (data.type === 'claude-permission-response') {
1417
- // Relay UI approval decisions back into the SDK control flow.
1418
- // This does not persist permissions; it only resolves the in-flight request,
1419
- // introduced so the SDK can resume once the user clicks Allow/Deny.
1420
- if (data.requestId) {
1421
- resolveToolApproval(data.requestId, {
1422
- allow: Boolean(data.allow),
1423
- updatedInput: data.updatedInput,
1424
- message: data.message,
1425
- rememberEntry: data.rememberEntry
1426
- });
1427
- }
1428
- } else if (data.type === 'cursor-abort') {
1429
- // abort cursor session
1430
- const success = abortCursorSession(data.sessionId);
1431
- writer.send({
1432
- type: 'session-aborted',
1433
- sessionId: data.sessionId,
1434
- provider: 'cursor',
1435
- success
1436
- });
1437
- } else if (data.type === 'check-session-status') {
1438
- // Check if a specific session is currently processing
1439
- const provider = data.provider || 'claude';
1440
- const sessionId = data.sessionId;
1441
- let isActive;
1442
-
1443
- if (provider === 'cursor') {
1444
- isActive = isCursorSessionActive(sessionId);
1445
- } else if (provider === 'codex') {
1446
- isActive = isCodexSessionActive(sessionId);
1447
- } else {
1448
- // Use Claude Agents SDK
1449
- isActive = isClaudeSDKSessionActive(sessionId);
1450
- }
1451
-
1452
- writer.send({
1453
- type: 'session-status',
1454
- sessionId,
1455
- provider,
1456
- isProcessing: isActive
1457
- });
1458
- } else if (data.type === 'get-active-sessions') {
1459
- // Get all currently active sessions
1460
- const activeSessions = {
1461
- claude: getActiveClaudeSDKSessions(),
1462
- cursor: getActiveCursorSessions(),
1463
- codex: getActiveCodexSessions()
1464
- };
1465
- writer.send({
1466
- type: 'active-sessions',
1467
- sessions: activeSessions
1468
- });
1469
- }
1470
- } catch (error) {
1471
- // chat WebSocket error
1472
- writer.send({
1473
- type: 'error',
1474
- error: 'An unexpected error occurred'
1475
- });
1476
- }
1477
- });
1478
-
1479
- ws.on('close', () => {
1480
- // Chat client disconnected
1481
- // Release all session locks held by this WebSocket
1482
- releaseAllLocksForWs(ws);
1483
- // Remove from connected clients
1484
- connectedClients.delete(ws);
1485
- });
1486
- }
1487
-
1488
- // Handle relay WebSocket connections (local machine ↔ server bridge)
1489
- async function handleRelayConnection(ws, token, request) {
1490
- if (!token) {
1491
- ws.send(JSON.stringify({ type: 'error', error: 'Relay token required. Use ?token=upfyn_xxx' }));
1492
- ws.close();
1493
- return;
1494
- }
1495
-
1496
- const tokenData = await relayTokensDb.validateToken(token);
1497
- if (!tokenData) {
1498
- ws.send(JSON.stringify({ type: 'error', error: 'Invalid or expired relay token' }));
1499
- ws.close();
1500
- return;
1501
- }
1502
-
1503
- const userId = Number(tokenData.user_id);
1504
- const username = tokenData.username;
1505
-
1506
- // Extract optional headers from relay handshake
1507
- const anthropicApiKey = request?.headers?.['x-anthropic-api-key'] || null;
1508
- const relayVersion = request?.headers?.['x-upfyn-version'] || null;
1509
- const relayMachine = request?.headers?.['x-upfyn-machine'] || null;
1510
- const relayPlatform = request?.headers?.['x-upfyn-platform'] || null;
1511
- const relayCwd = request?.headers?.['x-upfyn-cwd'] || null;
1512
-
1513
- // Store relay connection with API key in memory only (use Number() for consistent Map key type)
1514
- // API key is held per-user in the relay connection, NOT in process.env
1515
- relayConnections.set(userId, {
1516
- ws, user: tokenData, connectedAt: Date.now(), anthropicApiKey,
1517
- version: relayVersion, machine: relayMachine, platform: relayPlatform, cwd: relayCwd,
1518
- agents: null, // populated when client sends agent-capabilities
1519
- lastPong: Date.now(),
1520
- });
1521
-
1522
- ws.send(JSON.stringify({
1523
- type: 'relay-connected',
1524
- message: `Connected as ${username}. Your local machine is now bridged to the server.`
1525
- }));
1526
-
1527
- // Broadcast relay status to browser clients
1528
- for (const client of connectedClients) {
1529
- try {
1530
- if (client.readyState === 1) {
1531
- client.send(JSON.stringify({ type: 'relay-status', userId, connected: true }));
1532
- }
1533
- } catch (e) { /* ignore */ }
1534
- }
1535
-
1536
- ws.on('message', (message) => {
1537
- try {
1538
- const data = JSON.parse(message);
1539
-
1540
- // Relay response from local machine → resolve pending request
1541
- if (data.type === 'relay-response' && data.requestId) {
1542
- const pending = pendingRelayRequests.get(data.requestId);
1543
- if (pending) {
1544
- clearTimeout(pending.timeout);
1545
- pendingRelayRequests.delete(data.requestId);
1546
- pending.resolve(data);
1547
- }
1548
- return;
1549
- }
1550
-
1551
- // Relay stream chunk from local machine → forward to browser WebSocket
1552
- if (data.type === 'relay-stream' && data.requestId) {
1553
- const pending = pendingRelayRequests.get(data.requestId);
1554
- if (pending && pending.onStream) {
1555
- pending.onStream(data.data);
1556
- }
1557
- return;
1558
- }
1559
-
1560
- // Relay complete signal
1561
- if (data.type === 'relay-complete' && data.requestId) {
1562
- const pending = pendingRelayRequests.get(data.requestId);
1563
- if (pending) {
1564
- clearTimeout(pending.timeout);
1565
- pendingRelayRequests.delete(data.requestId);
1566
- pending.resolve(data);
1567
- }
1568
- return;
1569
- }
1570
-
1571
- // Agent capabilities report from relay client
1572
- if (data.type === 'agent-capabilities') {
1573
- const relay = relayConnections.get(userId);
1574
- if (relay) {
1575
- relay.agents = data.agents || {};
1576
- relay.machine = data.machine || relay.machine;
1577
- }
1578
- // Broadcast agent info to browser clients
1579
- for (const client of connectedClients) {
1580
- try {
1581
- if (client.readyState === 1) {
1582
- client.send(JSON.stringify({
1583
- type: 'relay-agents',
1584
- userId,
1585
- agents: data.agents || {},
1586
- machine: data.machine || {}
1587
- }));
1588
- }
1589
- } catch (e) { /* ignore */ }
1590
- }
1591
- return;
1592
- }
1593
-
1594
- // Heartbeat
1595
- if (data.type === 'ping') {
1596
- const relay = relayConnections.get(userId);
1597
- if (relay) relay.lastPong = Date.now();
1598
- ws.send(JSON.stringify({ type: 'pong' }));
1599
- return;
1600
- }
1601
- } catch (e) {
1602
- // relay message processing error
1603
- }
1604
- });
1605
-
1606
- // Server-side heartbeat: ping relay client every 45s, terminate if no pong in 90s
1607
- const relayHeartbeat = setInterval(() => {
1608
- const relay = relayConnections.get(userId);
1609
- if (!relay || relay.ws !== ws) {
1610
- clearInterval(relayHeartbeat);
1611
- return;
1612
- }
1613
- // If no ping received from client in 90s, consider connection stale
1614
- if (Date.now() - relay.lastPong > 90000) {
1615
- clearInterval(relayHeartbeat);
1616
- ws.terminate();
1617
- return;
1618
- }
1619
- // Send server-side ping to keep connection alive through proxies
1620
- try {
1621
- if (ws.readyState === 1) {
1622
- ws.send(JSON.stringify({ type: 'server-ping' }));
1623
- }
1624
- } catch { /* ignore */ }
1625
- }, 45000);
1626
-
1627
- ws.on('close', () => {
1628
- clearInterval(relayHeartbeat);
1629
- relayConnections.delete(userId);
1630
- // Clean up pending requests for this user
1631
- for (const [reqId, pending] of pendingRelayRequests) {
1632
- if (pending.userId === userId) {
1633
- clearTimeout(pending.timeout);
1634
- pending.reject(new Error('Relay disconnected'));
1635
- pendingRelayRequests.delete(reqId);
1636
- }
1637
- }
1638
-
1639
- // Broadcast relay status
1640
- for (const client of connectedClients) {
1641
- try {
1642
- if (client.readyState === 1) {
1643
- client.send(JSON.stringify({ type: 'relay-status', userId, connected: false }));
1644
- }
1645
- } catch (e) { /* ignore */ }
1646
- }
1647
- });
1648
-
1649
- ws.on('error', () => {
1650
- clearInterval(relayHeartbeat);
1651
- });
1652
- }
1653
-
1654
- /**
1655
- * Send a command to a user's relay and wait for response
1656
- * @param {number} userId - User ID
1657
- * @param {string} action - Action type (claude-query, shell-command, file-read, etc.)
1658
- * @param {object} payload - Action payload
1659
- * @param {function} onStream - Optional callback for streaming chunks
1660
- * @param {number} timeoutMs - Timeout in milliseconds (default 5 min)
1661
- * @returns {Promise<object>} Relay response
1662
- */
1663
- function sendRelayCommand(userId, action, payload, onStream = null, timeoutMs = 300000) {
1664
- return new Promise((resolve, reject) => {
1665
- const relay = relayConnections.get(userId);
1666
- if (!relay || relay.ws.readyState !== 1) {
1667
- reject(new Error('No relay connection. Run "uc connect" on your local machine.'));
1668
- return;
1669
- }
1670
-
1671
- const requestId = crypto.randomUUID();
1672
- const timeout = setTimeout(() => {
1673
- pendingRelayRequests.delete(requestId);
1674
- reject(new Error('Relay request timed out'));
1675
- }, timeoutMs);
1676
-
1677
- pendingRelayRequests.set(requestId, { resolve, reject, timeout, userId, onStream });
1678
-
1679
- relay.ws.send(JSON.stringify({
1680
- type: 'relay-command',
1681
- requestId,
1682
- action,
1683
- ...payload
1684
- }));
1685
- });
1686
- }
1687
-
1688
- /**
1689
- * Check if a user has an active relay connection
1690
- */
1691
- function hasActiveRelay(userId) {
1692
- if (!userId) return false;
1693
- const relay = relayConnections.get(Number(userId));
1694
- return relay && relay.ws.readyState === 1;
1695
- }
1696
-
1697
- /**
1698
- * Route a chat command through the user's relay connection to their local machine.
1699
- * Translates relay-stream/relay-complete events into the format the frontend expects.
1700
- *
1701
- * @param {number} userId - User ID
1702
- * @param {string} action - Relay action (claude-query, codex-query, cursor-query)
1703
- * @param {object} data - Original command data from the browser
1704
- * @param {object} writer - WebSocket writer to send events to browser
1705
- * @param {object} eventMap - Maps relay stream data types to chat event types
1706
- */
1707
- async function routeViaRelay(userId, action, data, writer, eventMap = {}) {
1708
- const sessionId = data.options?.sessionId || `relay-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
1709
-
1710
- // Send session-created so the frontend can track this query
1711
- writer.send({ type: 'session-created', sessionId });
1712
-
1713
- // Determine event types from the provider
1714
- const responseType = eventMap.response || 'claude-response';
1715
- const completeType = eventMap.complete || 'claude-complete';
1716
- const errorType = eventMap.error || 'claude-error';
1717
-
1718
- let fullContent = '';
1719
-
1720
- try {
1721
- const result = await sendRelayCommand(
1722
- Number(userId),
1723
- action,
1724
- {
1725
- command: data.command,
1726
- options: data.options || {}
1727
- },
1728
- // onStream callback — translates relay events to chat events
1729
- (streamData) => {
1730
- if (streamData.type === 'claude-response' || streamData.type === 'codex-response' || streamData.type === 'cursor-response') {
1731
- fullContent += streamData.content || '';
1732
- writer.send({
1733
- type: responseType,
1734
- data: {
1735
- type: 'assistant',
1736
- message: {
1737
- type: 'text',
1738
- text: streamData.content || ''
1739
- }
1740
- },
1741
- sessionId
1742
- });
1743
- } else if (streamData.type === 'claude-error' || streamData.type === 'codex-error' || streamData.type === 'cursor-error') {
1744
- writer.send({
1745
- type: responseType,
1746
- data: {
1747
- type: 'assistant',
1748
- message: {
1749
- type: 'text',
1750
- text: streamData.content || ''
1751
- }
1752
- },
1753
- sessionId
1754
- });
1755
- }
1756
- },
1757
- 600000 // 10 minute timeout for AI queries
1758
- );
1759
-
1760
- // Send completion event
1761
- writer.send({
1762
- type: completeType,
1763
- sessionId,
1764
- exitCode: result?.exitCode ?? 0,
1765
- isNewSession: !data.options?.sessionId,
1766
- viaRelay: true
1767
- });
1768
- } catch (error) {
1769
- const isRelayLost = error.message?.includes('Relay disconnected') || error.message?.includes('No relay connection') || error.message?.includes('Relay request timed out');
1770
- writer.send({
1771
- type: errorType,
1772
- error: isRelayLost
1773
- ? 'Your machine disconnected. Please reconnect with "uc connect" and try again.'
1774
- : 'An error occurred while processing your request',
1775
- sessionId,
1776
- relayDisconnected: isRelayLost
1777
- });
1778
- }
1779
- }
1780
-
1781
- // Handle shell WebSocket connections
1782
- function handleShellConnection(ws, request) {
1783
- const shellUserId = request?.user?.id || request?.user?.userId || null;
1784
- if (!pty) {
1785
- ws.send(JSON.stringify({ type: 'output', data: '\r\n[Shell unavailable] node-pty not installed. Use relay connection for shell access.\r\n' }));
1786
- ws.close();
1787
- return;
1788
- }
1789
- // Shell client connected
1790
- let shellProcess = null;
1791
- let ptySessionKey = null;
1792
- let urlDetectionBuffer = '';
1793
- const announcedAuthUrls = new Set();
1794
-
1795
- ws.on('message', async (message) => {
1796
- try {
1797
- const data = JSON.parse(message);
1798
- // Shell message received
1799
-
1800
- if (data.type === 'init') {
1801
- const projectPath = data.projectPath || process.cwd();
1802
- const sessionId = data.sessionId;
1803
- const hasSession = data.hasSession;
1804
- const provider = data.provider || 'claude';
1805
- const initialCommand = data.initialCommand;
1806
- const isPlainShell = data.isPlainShell || (!!initialCommand && !hasSession) || provider === 'plain-shell';
1807
- const shellType = data.shellType || null;
1808
- urlDetectionBuffer = '';
1809
- announcedAuthUrls.clear();
1810
-
1811
- // Login commands (Claude/Cursor auth) should never reuse cached sessions
1812
- const isLoginCommand = initialCommand && (
1813
- initialCommand.includes('setup-token') ||
1814
- initialCommand.includes('cursor-agent login') ||
1815
- initialCommand.includes('auth login')
1816
- );
1817
-
1818
- // Include command hash in session key so different commands get separate sessions
1819
- const commandSuffix = isPlainShell && initialCommand
1820
- ? `_cmd_${Buffer.from(initialCommand).toString('base64').slice(0, 16)}`
1821
- : '';
1822
- ptySessionKey = `${shellUserId || 'anon'}_${projectPath}_${sessionId || 'default'}${commandSuffix}`;
1823
-
1824
- // Kill any existing login session before starting fresh
1825
- if (isLoginCommand) {
1826
- const oldSession = ptySessionsMap.get(ptySessionKey);
1827
- if (oldSession) {
1828
- // cleaning up existing session
1829
- if (oldSession.timeoutId) clearTimeout(oldSession.timeoutId);
1830
- if (oldSession.pty && oldSession.pty.kill) oldSession.pty.kill();
1831
- ptySessionsMap.delete(ptySessionKey);
1832
- }
1833
- }
1834
-
1835
- const existingSession = isLoginCommand ? null : ptySessionsMap.get(ptySessionKey);
1836
- if (existingSession) {
1837
- // reconnecting to existing PTY session
1838
- shellProcess = existingSession.pty;
1839
-
1840
- clearTimeout(existingSession.timeoutId);
1841
-
1842
- ws.send(JSON.stringify({
1843
- type: 'output',
1844
- data: `\x1b[36m[Reconnected to existing session]\x1b[0m\r\n`
1845
- }));
1846
-
1847
- if (existingSession.buffer && existingSession.buffer.length > 0) {
1848
- // sending buffered messages
1849
- existingSession.buffer.forEach(bufferedData => {
1850
- ws.send(JSON.stringify({
1851
- type: 'output',
1852
- data: bufferedData
1853
- }));
1854
- });
1855
- }
1856
-
1857
- existingSession.ws = ws;
1858
-
1859
- return;
1860
- }
1861
-
1862
- // shell start path logged silently
1863
- // shell session started
1864
- // provider info logged silently
1865
- if (initialCommand) {
1866
- // initial command logged silently
1867
- }
1868
-
1869
- // First send a welcome message
1870
- let welcomeMsg;
1871
- if (isPlainShell) {
1872
- welcomeMsg = `\x1b[36mStarting terminal in: ${projectPath}\x1b[0m\r\n`;
1873
- } else {
1874
- const providerName = provider === 'cursor' ? 'Cursor' : 'Claude';
1875
- welcomeMsg = hasSession ?
1876
- `\x1b[36mResuming ${providerName} session ${sessionId} in: ${projectPath}\x1b[0m\r\n` :
1877
- `\x1b[36mStarting new ${providerName} session in: ${projectPath}\x1b[0m\r\n`;
1878
- }
1879
-
1880
- ws.send(JSON.stringify({
1881
- type: 'output',
1882
- data: welcomeMsg
1883
- }));
1884
-
1885
- try {
1886
- // Prepare the shell command adapted to the platform and provider
1887
- let shellCommand;
1888
- if (isPlainShell) {
1889
- // Plain shell mode - run initial command or open interactive shell
1890
- const usesPowerShell = !shellType || shellType === 'powershell';
1891
- if (initialCommand) {
1892
- if (usesPowerShell) {
1893
- shellCommand = `Set-Location -Path "${projectPath}"; ${initialCommand}`;
1894
- } else {
1895
- shellCommand = `cd "${projectPath}" && ${initialCommand}`;
1896
- }
1897
- } else {
1898
- // Interactive shell tab — spawn shell directly (no command wrapper)
1899
- shellCommand = null;
1900
- }
1901
- } else if (provider === 'cursor') {
1902
- // Use cursor-agent command
1903
- if (os.platform() === 'win32') {
1904
- if (hasSession && sessionId) {
1905
- shellCommand = `Set-Location -Path "${projectPath}"; cursor-agent --resume="${sessionId}"`;
1906
- } else {
1907
- shellCommand = `Set-Location -Path "${projectPath}"; cursor-agent`;
1908
- }
1909
- } else {
1910
- if (hasSession && sessionId) {
1911
- shellCommand = `cd "${projectPath}" && cursor-agent --resume="${sessionId}"`;
1912
- } else {
1913
- shellCommand = `cd "${projectPath}" && cursor-agent`;
1914
- }
1915
- }
1916
- } else {
1917
- // Use claude command (default) or initialCommand if provided
1918
- const command = initialCommand || 'claude';
1919
- if (os.platform() === 'win32') {
1920
- if (hasSession && sessionId) {
1921
- // Try to resume session, but with fallback to new session if it fails
1922
- shellCommand = `Set-Location -Path "${projectPath}"; claude --resume ${sessionId}; if ($LASTEXITCODE -ne 0) { claude }`;
1923
- } else {
1924
- shellCommand = `Set-Location -Path "${projectPath}"; ${command}`;
1925
- }
1926
- } else {
1927
- if (hasSession && sessionId) {
1928
- shellCommand = `cd "${projectPath}" && claude --resume ${sessionId} || claude`;
1929
- } else {
1930
- shellCommand = `cd "${projectPath}" && ${command}`;
1931
- }
1932
- }
1933
- }
1934
-
1935
- // shell command logged silently
1936
-
1937
- // Use appropriate shell based on platform and requested shellType
1938
- const shellMap = {
1939
- 'powershell': { cmd: 'powershell.exe', args: ['-Command'] },
1940
- 'cmd': { cmd: 'cmd.exe', args: ['/c'] },
1941
- 'bash': { cmd: 'bash', args: ['-c'] },
1942
- };
1943
- const defaultShell = os.platform() === 'win32'
1944
- ? { cmd: 'powershell.exe', args: ['-Command'] }
1945
- : { cmd: 'bash', args: ['-c'] };
1946
- const selectedShell = (shellType && shellMap[shellType]) || defaultShell;
1947
- const shell = selectedShell.cmd;
1948
- // If shellCommand is null, spawn an interactive shell with no args
1949
- const shellArgs = shellCommand ? [...selectedShell.args, shellCommand] : [];
1950
-
1951
- // Use terminal dimensions from client if provided, otherwise use defaults
1952
- const termCols = data.cols || 80;
1953
- const termRows = data.rows || 24;
1954
- // terminal dimensions logged silently
1955
-
1956
- shellProcess = pty.spawn(shell, shellArgs, {
1957
- name: 'xterm-256color',
1958
- cols: termCols,
1959
- rows: termRows,
1960
- cwd: shellCommand ? os.homedir() : projectPath,
1961
- env: {
1962
- ...process.env,
1963
- TERM: 'xterm-256color',
1964
- COLORTERM: 'truecolor',
1965
- FORCE_COLOR: '3'
1966
- }
1967
- });
1968
-
1969
- // shell process started
1970
-
1971
- ptySessionsMap.set(ptySessionKey, {
1972
- pty: shellProcess,
1973
- ws: ws,
1974
- buffer: [],
1975
- timeoutId: null,
1976
- projectPath,
1977
- sessionId
1978
- });
1979
-
1980
- // Handle data output
1981
- shellProcess.onData((data) => {
1982
- const session = ptySessionsMap.get(ptySessionKey);
1983
- if (!session) return;
1984
-
1985
- if (session.buffer.length < 5000) {
1986
- session.buffer.push(data);
1987
- } else {
1988
- session.buffer.shift();
1989
- session.buffer.push(data);
1990
- }
1991
-
1992
- if (session.ws && session.ws.readyState === WebSocket.OPEN) {
1993
- let outputData = data;
1994
-
1995
- const cleanChunk = stripAnsiSequences(data);
1996
- urlDetectionBuffer = `${urlDetectionBuffer}${cleanChunk}`.slice(-SHELL_URL_PARSE_BUFFER_LIMIT);
1997
-
1998
- outputData = outputData.replace(
1999
- /OPEN_URL:\s*(https?:\/\/[^\s\x1b\x07]+)/g,
2000
- '[INFO] Opening in browser: $1'
2001
- );
2002
-
2003
- const emitAuthUrl = (detectedUrl, autoOpen = false) => {
2004
- const normalizedUrl = normalizeDetectedUrl(detectedUrl);
2005
- if (!normalizedUrl) return;
2006
-
2007
- const isNewUrl = !announcedAuthUrls.has(normalizedUrl);
2008
- if (isNewUrl) {
2009
- announcedAuthUrls.add(normalizedUrl);
2010
- session.ws.send(JSON.stringify({
2011
- type: 'auth_url',
2012
- url: normalizedUrl,
2013
- autoOpen
2014
- }));
2015
- }
2016
-
2017
- };
2018
-
2019
- const normalizedDetectedUrls = extractUrlsFromText(urlDetectionBuffer)
2020
- .map((url) => normalizeDetectedUrl(url))
2021
- .filter(Boolean);
2022
-
2023
- // Prefer the most complete URL if shorter prefix variants are also present.
2024
- const dedupedDetectedUrls = Array.from(new Set(normalizedDetectedUrls)).filter((url, _, urls) =>
2025
- !urls.some((otherUrl) => otherUrl !== url && otherUrl.startsWith(url))
2026
- );
2027
-
2028
- dedupedDetectedUrls.forEach((url) => emitAuthUrl(url, false));
2029
-
2030
- if (shouldAutoOpenUrlFromOutput(cleanChunk) && dedupedDetectedUrls.length > 0) {
2031
- const bestUrl = dedupedDetectedUrls.reduce((longest, current) =>
2032
- current.length > longest.length ? current : longest
2033
- );
2034
- emitAuthUrl(bestUrl, true);
2035
- }
2036
-
2037
- // Send regular output
2038
- session.ws.send(JSON.stringify({
2039
- type: 'output',
2040
- data: outputData
2041
- }));
2042
- }
2043
- });
2044
-
2045
- // Handle process exit
2046
- shellProcess.onExit((exitCode) => {
2047
- // shell process exited
2048
- const session = ptySessionsMap.get(ptySessionKey);
2049
- if (session && session.ws && session.ws.readyState === WebSocket.OPEN) {
2050
- session.ws.send(JSON.stringify({
2051
- type: 'output',
2052
- data: `\r\n\x1b[33mProcess exited with code ${exitCode.exitCode}${exitCode.signal ? ` (${exitCode.signal})` : ''}\x1b[0m\r\n`
2053
- }));
2054
- }
2055
- if (session && session.timeoutId) {
2056
- clearTimeout(session.timeoutId);
2057
- }
2058
- ptySessionsMap.delete(ptySessionKey);
2059
- shellProcess = null;
2060
- });
2061
-
2062
- } catch (spawnError) {
2063
- // process spawn error handled silently
2064
- ws.send(JSON.stringify({
2065
- type: 'output',
2066
- data: `\r\n\x1b[31mError: ${spawnError.message}\x1b[0m\r\n`
2067
- }));
2068
- }
2069
-
2070
- } else if (data.type === 'input') {
2071
- // Send input to shell process
2072
- if (shellProcess && shellProcess.write) {
2073
- try {
2074
- shellProcess.write(data.data);
2075
- } catch (error) {
2076
- // shell write error handled silently
2077
- }
2078
- } else {
2079
- // no active shell process
2080
- }
2081
- } else if (data.type === 'resize') {
2082
- // Handle terminal resize
2083
- if (shellProcess && shellProcess.resize) {
2084
- // terminal resize handled
2085
- shellProcess.resize(data.cols, data.rows);
2086
- }
2087
- }
2088
- } catch (error) {
2089
- // shell WebSocket error
2090
- if (ws.readyState === WebSocket.OPEN) {
2091
- ws.send(JSON.stringify({
2092
- type: 'output',
2093
- data: `\r\n\x1b[31mError: ${error.message}\x1b[0m\r\n`
2094
- }));
2095
- }
2096
- }
2097
- });
2098
-
2099
- ws.on('close', () => {
2100
- // shell client disconnected
2101
-
2102
- if (ptySessionKey) {
2103
- const session = ptySessionsMap.get(ptySessionKey);
2104
- if (session) {
2105
- // PTY session kept alive
2106
- session.ws = null;
2107
-
2108
- session.timeoutId = setTimeout(() => {
2109
- // PTY session timeout
2110
- if (session.pty && session.pty.kill) {
2111
- session.pty.kill();
2112
- }
2113
- ptySessionsMap.delete(ptySessionKey);
2114
- }, PTY_SESSION_TIMEOUT);
2115
- }
2116
- }
2117
- });
2118
-
2119
- ws.on('error', (error) => {
2120
- // shell error
2121
- });
2122
- }
2123
- // Audio transcription endpoint
2124
- app.post('/api/transcribe', authenticateToken, async (req, res) => {
2125
- try {
2126
- const multer = (await import('multer')).default;
2127
- const upload = multer({ storage: multer.memoryStorage() });
2128
-
2129
- // Handle multipart form data
2130
- upload.single('audio')(req, res, async (err) => {
2131
- if (err) {
2132
- return res.status(400).json({ error: 'Failed to process audio file' });
2133
- }
2134
-
2135
- if (!req.file) {
2136
- return res.status(400).json({ error: 'No audio file provided' });
2137
- }
2138
-
2139
- // BYOK: check user's stored OpenAI key first, fall back to server env
2140
- const userOpenaiKey = req.user?.id
2141
- ? await getUserProviderKey(req.user.id, 'openai_key')
2142
- : null;
2143
- const apiKey = userOpenaiKey || process.env.OPENAI_API_KEY;
2144
- if (!apiKey) {
2145
- return res.status(500).json({ error: 'OpenAI API key not configured. Add your key in Settings > AI Providers, or ask the admin to set OPENAI_API_KEY.' });
2146
- }
2147
-
2148
- try {
2149
- // Create form data for OpenAI
2150
- const FormData = (await import('form-data')).default;
2151
- const formData = new FormData();
2152
- formData.append('file', req.file.buffer, {
2153
- filename: req.file.originalname,
2154
- contentType: req.file.mimetype
2155
- });
2156
- formData.append('model', 'whisper-1');
2157
- formData.append('response_format', 'json');
2158
- formData.append('language', 'en');
2159
-
2160
- // Make request to OpenAI
2161
- const response = await fetch('https://api.openai.com/v1/audio/transcriptions', {
2162
- method: 'POST',
2163
- headers: {
2164
- 'Authorization': `Bearer ${apiKey}`,
2165
- ...formData.getHeaders()
2166
- },
2167
- body: formData
2168
- });
2169
-
2170
- if (!response.ok) {
2171
- const errorData = await response.json().catch(() => ({}));
2172
- throw new Error(errorData.error?.message || `Whisper API error: ${response.status}`);
2173
- }
2174
-
2175
- const data = await response.json();
2176
- let transcribedText = data.text || '';
2177
-
2178
- // Check if enhancement mode is enabled
2179
- const mode = req.body.mode || 'default';
2180
-
2181
- // If no transcribed text, return empty
2182
- if (!transcribedText) {
2183
- return res.json({ text: '' });
2184
- }
2185
-
2186
- // If default mode, return transcribed text without enhancement
2187
- if (mode === 'default') {
2188
- return res.json({ text: transcribedText });
2189
- }
2190
-
2191
- // Handle different enhancement modes
2192
- try {
2193
- const OpenAI = (await import('openai')).default;
2194
- const openai = new OpenAI({ apiKey });
2195
-
2196
- let prompt, systemMessage, temperature = 0.7, maxTokens = 800;
2197
-
2198
- switch (mode) {
2199
- case 'prompt':
2200
- systemMessage = 'You are an expert prompt engineer who creates clear, detailed, and effective prompts.';
2201
- prompt = `You are an expert prompt engineer. Transform the following rough instruction into a clear, detailed, and context-aware AI prompt.
2202
-
2203
- Your enhanced prompt should:
2204
- 1. Be specific and unambiguous
2205
- 2. Include relevant context and constraints
2206
- 3. Specify the desired output format
2207
- 4. Use clear, actionable language
2208
- 5. Include examples where helpful
2209
- 6. Consider edge cases and potential ambiguities
2210
-
2211
- Transform this rough instruction into a well-crafted prompt:
2212
- "${transcribedText}"
2213
-
2214
- Enhanced prompt:`;
2215
- break;
2216
-
2217
- case 'vibe':
2218
- case 'instructions':
2219
- case 'architect':
2220
- systemMessage = 'You are a helpful assistant that formats ideas into clear, actionable instructions for AI agents.';
2221
- temperature = 0.5; // Lower temperature for more controlled output
2222
- prompt = `Transform the following idea into clear, well-structured instructions that an AI agent can easily understand and execute.
2223
-
2224
- IMPORTANT RULES:
2225
- - Format as clear, step-by-step instructions
2226
- - Add reasonable implementation details based on common patterns
2227
- - Only include details directly related to what was asked
2228
- - Do NOT add features or functionality not mentioned
2229
- - Keep the original intent and scope intact
2230
- - Use clear, actionable language an agent can follow
2231
-
2232
- Transform this idea into agent-friendly instructions:
2233
- "${transcribedText}"
2234
-
2235
- Agent instructions:`;
2236
- break;
2237
-
2238
- default:
2239
- // No enhancement needed
2240
- break;
2241
- }
2242
-
2243
- // Only make GPT call if we have a prompt
2244
- if (prompt) {
2245
- const completion = await openai.chat.completions.create({
2246
- model: 'gpt-4o-mini',
2247
- messages: [
2248
- { role: 'system', content: systemMessage },
2249
- { role: 'user', content: prompt }
2250
- ],
2251
- temperature: temperature,
2252
- max_tokens: maxTokens
2253
- });
2254
-
2255
- transcribedText = completion.choices[0].message.content || transcribedText;
2256
- }
2257
-
2258
- } catch (gptError) {
2259
- // GPT processing error handled silently
2260
- // Fall back to original transcription if GPT fails
2261
- }
2262
-
2263
- res.json({ text: transcribedText });
2264
-
2265
- } catch (error) {
2266
- // transcription error handled silently
2267
- res.status(500).json({ error: 'Internal server error' });
2268
- }
2269
- });
2270
- } catch (error) {
2271
- // endpoint error handled silently
2272
- res.status(500).json({ error: 'Internal server error' });
2273
- }
2274
- });
2275
-
2276
- // Image upload endpoint
2277
- app.post('/api/projects/:projectName/upload-images', authenticateToken, async (req, res) => {
2278
- try {
2279
- const multer = (await import('multer')).default;
2280
- const path = (await import('path')).default;
2281
- const fs = (await import('fs')).promises;
2282
- const os = (await import('os')).default;
2283
-
2284
- // Configure multer for image uploads
2285
- const storage = multer.diskStorage({
2286
- destination: async (req, file, cb) => {
2287
- const uploadDir = path.join(os.tmpdir(), 'claude-ui-uploads', String(req.user.id));
2288
- await fs.mkdir(uploadDir, { recursive: true });
2289
- cb(null, uploadDir);
2290
- },
2291
- filename: (req, file, cb) => {
2292
- const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
2293
- const sanitizedName = file.originalname.replace(/[^a-zA-Z0-9.-]/g, '_');
2294
- cb(null, uniqueSuffix + '-' + sanitizedName);
2295
- }
2296
- });
2297
-
2298
- const fileFilter = (req, file, cb) => {
2299
- const allowedMimes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/svg+xml'];
2300
- if (allowedMimes.includes(file.mimetype)) {
2301
- cb(null, true);
2302
- } else {
2303
- cb(new Error('Invalid file type. Only JPEG, PNG, GIF, WebP, and SVG are allowed.'));
2304
- }
2305
- };
2306
-
2307
- const upload = multer({
2308
- storage,
2309
- fileFilter,
2310
- limits: {
2311
- fileSize: 5 * 1024 * 1024, // 5MB
2312
- files: 5
2313
- }
2314
- });
2315
-
2316
- // Handle multipart form data
2317
- upload.array('images', 5)(req, res, async (err) => {
2318
- if (err) {
2319
- const uploadError = err.code === 'LIMIT_FILE_SIZE' ? 'File too large (max 5MB)' : err.code === 'LIMIT_FILE_COUNT' ? 'Too many files (max 5)' : 'Invalid file upload';
2320
- return res.status(400).json({ error: uploadError });
2321
- }
2322
-
2323
- if (!req.files || req.files.length === 0) {
2324
- return res.status(400).json({ error: 'No image files provided' });
2325
- }
2326
-
2327
- try {
2328
- // Process uploaded images
2329
- const processedImages = await Promise.all(
2330
- req.files.map(async (file) => {
2331
- // Read file and convert to base64
2332
- const buffer = await fs.readFile(file.path);
2333
- const base64 = buffer.toString('base64');
2334
- const mimeType = file.mimetype;
2335
-
2336
- // Clean up temp file immediately
2337
- await fs.unlink(file.path);
2338
-
2339
- return {
2340
- name: file.originalname,
2341
- data: `data:${mimeType};base64,${base64}`,
2342
- size: file.size,
2343
- mimeType: mimeType
2344
- };
2345
- })
2346
- );
2347
-
2348
- res.json({ images: processedImages });
2349
- } catch (error) {
2350
- // image processing error handled silently
2351
- // Clean up any remaining files
2352
- await Promise.all(req.files.map(f => fs.unlink(f.path).catch(() => { })));
2353
- res.status(500).json({ error: 'Failed to process images' });
2354
- }
2355
- });
2356
- } catch (error) {
2357
- // image upload error handled silently
2358
- res.status(500).json({ error: 'Internal server error' });
2359
- }
2360
- });
2361
-
2362
- // Get token usage for a specific session
2363
- app.get('/api/projects/:projectName/sessions/:sessionId/token-usage', authenticateToken, async (req, res) => {
2364
- try {
2365
- const { projectName, sessionId } = req.params;
2366
- const { provider = 'claude' } = req.query;
2367
- const homeDir = os.homedir();
2368
-
2369
- // Allow only safe characters in sessionId
2370
- const safeSessionId = String(sessionId).replace(/[^a-zA-Z0-9._-]/g, '');
2371
- if (!safeSessionId) {
2372
- return res.status(400).json({ error: 'Invalid sessionId' });
2373
- }
2374
-
2375
- // Handle Cursor sessions - they use SQLite and don't have token usage info
2376
- if (provider === 'cursor') {
2377
- return res.json({
2378
- used: 0,
2379
- total: 0,
2380
- breakdown: { input: 0, cacheCreation: 0, cacheRead: 0 },
2381
- unsupported: true,
2382
- message: 'Token usage tracking not available for Cursor sessions'
2383
- });
2384
- }
2385
-
2386
- // Handle Codex sessions
2387
- if (provider === 'codex') {
2388
- const codexSessionsDir = path.join(homeDir, '.codex', 'sessions');
2389
-
2390
- // Find the session file by searching for the session ID
2391
- const findSessionFile = async (dir) => {
2392
- try {
2393
- const entries = await fsPromises.readdir(dir, { withFileTypes: true });
2394
- for (const entry of entries) {
2395
- const fullPath = path.join(dir, entry.name);
2396
- if (entry.isDirectory()) {
2397
- const found = await findSessionFile(fullPath);
2398
- if (found) return found;
2399
- } else if (entry.name.includes(safeSessionId) && entry.name.endsWith('.jsonl')) {
2400
- return fullPath;
2401
- }
2402
- }
2403
- } catch (error) {
2404
- // Skip directories we can't read
2405
- }
2406
- return null;
2407
- };
2408
-
2409
- const sessionFilePath = await findSessionFile(codexSessionsDir);
2410
-
2411
- if (!sessionFilePath) {
2412
- return res.status(404).json({ error: 'Codex session file not found', sessionId: safeSessionId });
2413
- }
2414
-
2415
- // Read and parse the Codex JSONL file
2416
- let fileContent;
2417
- try {
2418
- fileContent = await fsPromises.readFile(sessionFilePath, 'utf8');
2419
- } catch (error) {
2420
- if (error.code === 'ENOENT') {
2421
- return res.status(404).json({ error: 'Session file not found', path: sessionFilePath });
2422
- }
2423
- throw error;
2424
- }
2425
- const lines = fileContent.trim().split('\n');
2426
- let totalTokens = 0;
2427
- let contextWindow = 200000; // Default for Codex/OpenAI
2428
-
2429
- // Find the latest token_count event with info (scan from end)
2430
- for (let i = lines.length - 1; i >= 0; i--) {
2431
- try {
2432
- const entry = JSON.parse(lines[i]);
2433
-
2434
- // Codex stores token info in event_msg with type: "token_count"
2435
- if (entry.type === 'event_msg' && entry.payload?.type === 'token_count' && entry.payload?.info) {
2436
- const tokenInfo = entry.payload.info;
2437
- if (tokenInfo.total_token_usage) {
2438
- totalTokens = tokenInfo.total_token_usage.total_tokens || 0;
2439
- }
2440
- if (tokenInfo.model_context_window) {
2441
- contextWindow = tokenInfo.model_context_window;
2442
- }
2443
- break; // Stop after finding the latest token count
2444
- }
2445
- } catch (parseError) {
2446
- // Skip lines that can't be parsed
2447
- continue;
2448
- }
2449
- }
2450
-
2451
- return res.json({
2452
- used: totalTokens,
2453
- total: contextWindow
2454
- });
2455
- }
2456
-
2457
- // Handle Claude sessions (default)
2458
- // Extract actual project path
2459
- let projectPath;
2460
- try {
2461
- projectPath = await extractProjectDirectory(projectName);
2462
- } catch (error) {
2463
- // project dir extraction error handled silently
2464
- return res.status(500).json({ error: 'Failed to determine project path' });
2465
- }
2466
-
2467
- // Construct the JSONL file path
2468
- // Claude stores session files in ~/.claude/projects/[encoded-project-path]/[session-id].jsonl
2469
- // The encoding replaces /, spaces, ~, and _ with -
2470
- const encodedPath = projectPath.replace(/[\\/:\s~_]/g, '-');
2471
- const projectDir = path.join(homeDir, '.claude', 'projects', encodedPath);
2472
-
2473
- const jsonlPath = path.join(projectDir, `${safeSessionId}.jsonl`);
2474
-
2475
- // Constrain to projectDir
2476
- const rel = path.relative(path.resolve(projectDir), path.resolve(jsonlPath));
2477
- if (rel.startsWith('..') || path.isAbsolute(rel)) {
2478
- return res.status(400).json({ error: 'Invalid path' });
2479
- }
2480
-
2481
- // Read and parse the JSONL file
2482
- let fileContent;
2483
- try {
2484
- fileContent = await fsPromises.readFile(jsonlPath, 'utf8');
2485
- } catch (error) {
2486
- if (error.code === 'ENOENT') {
2487
- return res.status(404).json({ error: 'Session file not found', path: jsonlPath });
2488
- }
2489
- throw error; // Re-throw other errors to be caught by outer try-catch
2490
- }
2491
- const lines = fileContent.trim().split('\n');
2492
-
2493
- const parsedContextWindow = parseInt(process.env.CONTEXT_WINDOW, 10);
2494
- const contextWindow = Number.isFinite(parsedContextWindow) ? parsedContextWindow : 160000;
2495
- let inputTokens = 0;
2496
- let cacheCreationTokens = 0;
2497
- let cacheReadTokens = 0;
2498
-
2499
- // Find the latest assistant message with usage data (scan from end)
2500
- for (let i = lines.length - 1; i >= 0; i--) {
2501
- try {
2502
- const entry = JSON.parse(lines[i]);
2503
-
2504
- // Only count assistant messages which have usage data
2505
- if (entry.type === 'assistant' && entry.message?.usage) {
2506
- const usage = entry.message.usage;
2507
-
2508
- // Use token counts from latest assistant message only
2509
- inputTokens = usage.input_tokens || 0;
2510
- cacheCreationTokens = usage.cache_creation_input_tokens || 0;
2511
- cacheReadTokens = usage.cache_read_input_tokens || 0;
2512
-
2513
- break; // Stop after finding the latest assistant message
2514
- }
2515
- } catch (parseError) {
2516
- // Skip lines that can't be parsed
2517
- continue;
2518
- }
2519
- }
2520
-
2521
- // Calculate total context usage (excluding output_tokens, as per ccusage)
2522
- const totalUsed = inputTokens + cacheCreationTokens + cacheReadTokens;
2523
-
2524
- res.json({
2525
- used: totalUsed,
2526
- total: contextWindow,
2527
- breakdown: {
2528
- input: inputTokens,
2529
- cacheCreation: cacheCreationTokens,
2530
- cacheRead: cacheReadTokens
2531
- }
2532
- });
2533
- } catch (error) {
2534
- // token usage read error
2535
- res.status(500).json({ error: 'Failed to read session token usage' });
2536
- }
2537
- });
2538
-
2539
- // Serve React app for all other routes (excluding static files and API routes)
2540
- app.get('*', (req, res) => {
2541
- // Skip API routes — they should be handled by their own routers
2542
- if (req.path.startsWith('/api/') || req.path === '/mcp' || req.path === '/relay' || req.path === '/health') {
2543
- return res.status(404).json({ error: 'Not found' });
2544
- }
2545
- // Skip requests for static assets (files with extensions)
2546
- if (path.extname(req.path)) {
2547
- return res.status(404).send('Not found');
2548
- }
2549
-
2550
- // If a JWT token is in the query param and no session cookie exists,
2551
- // set the cookie now so the client-side AuthContext can authenticate on subsequent API calls.
2552
- if (req.query?.token && !req.cookies?.session) {
2553
- try {
2554
- const decoded = jwt.verify(req.query.token, JWT_SECRET);
2555
- if (decoded?.userId) {
2556
- const isSecure = process.env.NODE_ENV === 'production' || !!process.env.RAILWAY_ENVIRONMENT;
2557
- res.cookie('session', req.query.token, {
2558
- httpOnly: true,
2559
- secure: isSecure,
2560
- sameSite: isSecure ? 'none' : 'strict',
2561
- maxAge: 30 * 24 * 60 * 60 * 1000,
2562
- path: '/',
2563
- });
2564
- }
2565
- } catch (e) {
2566
- // Invalid token — just serve the page without setting cookie
2567
- }
2568
- }
2569
-
2570
- // Only serve index.html for HTML routes, not for static assets
2571
- // Static assets should already be handled by express.static middleware above
2572
- const indexPath = path.join(__dirname, '../client/dist/index.html');
2573
-
2574
- // Check if dist/index.html exists (production build available)
2575
- if (fs.existsSync(indexPath)) {
2576
- // Set no-cache headers for HTML to prevent service worker issues
2577
- res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
2578
- res.setHeader('Pragma', 'no-cache');
2579
- res.setHeader('Expires', '0');
2580
- res.sendFile(indexPath);
2581
- } else {
2582
- // In development, redirect to Vite dev server only if dist doesn't exist
2583
- res.redirect(`http://localhost:${process.env.VITE_PORT || 5173}`);
2584
- }
2585
- });
2586
-
2587
- // Helper function to convert permissions to rwx format
2588
- function permToRwx(perm) {
2589
- const r = perm & 4 ? 'r' : '-';
2590
- const w = perm & 2 ? 'w' : '-';
2591
- const x = perm & 1 ? 'x' : '-';
2592
- return r + w + x;
2593
- }
2594
-
2595
- async function getFileTree(dirPath, maxDepth = 3, currentDepth = 0, showHidden = true) {
2596
- // Using fsPromises from import
2597
- const items = [];
2598
-
2599
- try {
2600
- const entries = await fsPromises.readdir(dirPath, { withFileTypes: true });
2601
-
2602
- for (const entry of entries) {
2603
- // Debug: log all entries including hidden files
2604
-
2605
-
2606
- // Skip heavy build directories and VCS directories
2607
- if (entry.name === 'node_modules' ||
2608
- entry.name === 'dist' ||
2609
- entry.name === 'build' ||
2610
- entry.name === '.git' ||
2611
- entry.name === '.svn' ||
2612
- entry.name === '.hg') continue;
2613
-
2614
- const itemPath = path.join(dirPath, entry.name);
2615
- const item = {
2616
- name: entry.name,
2617
- path: itemPath,
2618
- type: entry.isDirectory() ? 'directory' : 'file'
2619
- };
2620
-
2621
- // Get file stats for additional metadata
2622
- try {
2623
- const stats = await fsPromises.stat(itemPath);
2624
- item.size = stats.size;
2625
- item.modified = stats.mtime.toISOString();
2626
-
2627
- // Convert permissions to rwx format
2628
- const mode = stats.mode;
2629
- const ownerPerm = (mode >> 6) & 7;
2630
- const groupPerm = (mode >> 3) & 7;
2631
- const otherPerm = mode & 7;
2632
- item.permissions = ((mode >> 6) & 7).toString() + ((mode >> 3) & 7).toString() + (mode & 7).toString();
2633
- item.permissionsRwx = permToRwx(ownerPerm) + permToRwx(groupPerm) + permToRwx(otherPerm);
2634
- } catch (statError) {
2635
- // If stat fails, provide default values
2636
- item.size = 0;
2637
- item.modified = null;
2638
- item.permissions = '000';
2639
- item.permissionsRwx = '---------';
2640
- }
2641
-
2642
- if (entry.isDirectory() && currentDepth < maxDepth) {
2643
- // Recursively get subdirectories but limit depth
2644
- try {
2645
- // Check if we can access the directory before trying to read it
2646
- await fsPromises.access(item.path, fs.constants.R_OK);
2647
- item.children = await getFileTree(item.path, maxDepth, currentDepth + 1, showHidden);
2648
- } catch (e) {
2649
- // Silently skip directories we can't access (permission denied, etc.)
2650
- item.children = [];
2651
- }
2652
- }
2653
-
2654
- items.push(item);
2655
- }
2656
- } catch (error) {
2657
- // Only log non-permission errors to avoid spam
2658
- if (error.code !== 'EACCES' && error.code !== 'EPERM') {
2659
- // directory read error handled silently
2660
- }
2661
- }
2662
-
2663
- return items.sort((a, b) => {
2664
- if (a.type !== b.type) {
2665
- return a.type === 'directory' ? -1 : 1;
2666
- }
2667
- return a.name.localeCompare(b.name);
2668
- });
2669
- }
2670
-
2671
- const PORT = process.env.PORT || 3001;
2672
-
2673
- // Initialize database and start server
2674
- async function startServer() {
2675
- try {
2676
- // Initialize authentication database
2677
- await initializeDatabase();
2678
-
2679
- // In local mode, ensure a default user exists (no signup needed)
2680
- if (IS_LOCAL) {
2681
- const hasUsers = await userDb.hasUsers();
2682
- if (!hasUsers) {
2683
- const localUsername = os.userInfo().username || 'local';
2684
- const dummyHash = crypto.randomBytes(32).toString('hex');
2685
- await userDb.createUser(localUsername, dummyHash);
2686
- console.log(`${c.ok('[LOCAL]')} Created local user: ${c.bright(localUsername)}`);
2687
- }
2688
- console.log(`${c.info('[MODE]')} Running in ${c.bright('LOCAL')} mode (no login required)`);
2689
- }
2690
-
2691
- // Check if running in production mode (dist folder exists OR NODE_ENV/RAILWAY set)
2692
- const distIndexPath = path.join(__dirname, '../client/dist/index.html');
2693
- const isProduction = fs.existsSync(distIndexPath) || process.env.NODE_ENV === 'production' || !!process.env.RAILWAY_ENVIRONMENT;
2694
-
2695
- // Log Claude implementation mode
2696
- console.log(`${c.info('[INFO]')} Using Claude Agents SDK for Claude integration`);
2697
- console.log(`${c.info('[INFO]')} Running in ${c.bright(isProduction ? 'PRODUCTION' : 'DEVELOPMENT')} mode`);
2698
-
2699
- if (!isProduction) {
2700
- console.log(`${c.warn('[WARN]')} Note: Requests will be proxied to Vite dev server at ${c.dim('http://localhost:' + (process.env.VITE_PORT || 5173))}`);
2701
- }
2702
-
2703
- server.listen(PORT, '0.0.0.0', async () => {
2704
- const appInstallPath = path.join(__dirname, '..');
2705
-
2706
- console.log('');
2707
- console.log(c.dim('═'.repeat(63)));
2708
- console.log(` ${c.bright('Upfyn-Code Server - Ready')}`);
2709
- console.log(c.dim('═'.repeat(63)));
2710
- console.log('');
2711
- console.log(`${c.info('[INFO]')} Server URL: ${c.bright('http://0.0.0.0:' + PORT)}`);
2712
- console.log(`${c.info('[INFO]')} MCP Server: ${c.bright('http://0.0.0.0:' + PORT + '/mcp')}`);
2713
- console.log(`${c.info('[INFO]')} Installed at: ${c.dim(appInstallPath)}`);
2714
- console.log(`${c.tip('[TIP]')} Run "uc status" for full configuration details`);
2715
-
2716
- // Start workflow cron scheduler
2717
- initScheduler().catch(err => console.warn('[Scheduler]', err.message));
2718
- console.log('');
2719
-
2720
- // Start watching the projects folder for changes (skip on Vercel)
2721
- if (!process.env.VERCEL) {
2722
- await setupProjectsWatcher();
2723
- }
2724
- });
2725
- } catch (error) {
2726
- console.error('[ERROR] Failed to start server:', error);
2727
- process.exit(1);
2728
- }
2729
- }
2730
-
2731
- // Only start server when not running on Vercel (Vercel uses the exported app)
2732
- if (!process.env.VERCEL) {
2733
- startServer();
2734
- }
2735
-
2736
- // Export for Vercel serverless and testing
2737
- export default app;
2738
- export { app, server, relayConnections, sendRelayCommand };