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