upfynai-code 2.2.0 → 2.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (151) hide show
  1. package/client/dist/assets/AppContent-DTZ2FbvM.js +513 -0
  2. package/client/dist/assets/CanvasPanel-DlTW6Jh6.js +6 -0
  3. package/client/dist/assets/LoginModal-CWoFm0au.js +19 -0
  4. package/client/dist/assets/MarkdownPreview-CYdvwJaV.js +1 -0
  5. package/client/dist/assets/{Onboarding-Coxo6mFA.js → Onboarding-CtIoXiTp.js} +1 -1
  6. package/client/dist/assets/{SetupForm-BzYOsbji.js → SetupForm-B4p8im5O.js} +1 -1
  7. package/client/dist/assets/{ar-SA-G6X2FPQ2-Bmw2-hDt.js → ar-SA-G6X2FPQ2-2gfmdvHk.js} +1 -1
  8. package/client/dist/assets/{arc-BMqY7_Ci.js → arc-DCZSHhoJ.js} +1 -1
  9. package/client/dist/assets/{az-AZ-76LH7QW2-Dh1le_qs.js → az-AZ-76LH7QW2-CDdeucRZ.js} +1 -1
  10. package/client/dist/assets/{bg-BG-XCXSNQG7-Cbav8Z9z.js → bg-BG-XCXSNQG7-D6__XtOK.js} +1 -1
  11. package/client/dist/assets/{blockDiagram-38ab4fdb-ChHJxsXw.js → blockDiagram-38ab4fdb-Cfbaeyp6.js} +3 -3
  12. package/client/dist/assets/{bn-BD-2XOGV67Q-DCNjOaWz.js → bn-BD-2XOGV67Q-DHNJw3OG.js} +1 -1
  13. package/client/dist/assets/{c4Diagram-3d4e48cf-b8Xue4Z6.js → c4Diagram-3d4e48cf-BBCnjOTy.js} +1 -1
  14. package/client/dist/assets/{ca-ES-6MX7JW3Y-Dl_vM7NS.js → ca-ES-6MX7JW3Y-r5g4o3zQ.js} +1 -1
  15. package/client/dist/assets/channel-O3ovC0x9.js +1 -0
  16. package/client/dist/assets/{classDiagram-70f12bd4-BheP7Ggo.js → classDiagram-70f12bd4-D0lhAcxU.js} +1 -1
  17. package/client/dist/assets/classDiagram-v2-f2320105-BuwUsF3F.js +2 -0
  18. package/client/dist/assets/clone-BG9u7vLi.js +1 -0
  19. package/client/dist/assets/{createText-2e5e7dd3-_n4jI_fO.js → createText-2e5e7dd3-B8jCDmF_.js} +1 -1
  20. package/client/dist/assets/{cs-CZ-2BRQDIVT-ftsKDdz4.js → cs-CZ-2BRQDIVT-p08jRLRC.js} +1 -1
  21. package/client/dist/assets/{da-DK-5WZEPLOC-DAjdwGRO.js → da-DK-5WZEPLOC-CnhOImFf.js} +1 -1
  22. package/client/dist/assets/{de-DE-XR44H4JA-BJXczHGT.js → de-DE-XR44H4JA-BunSXZ-Y.js} +1 -1
  23. package/client/dist/assets/{edges-e0da2a9e-CfPZr4YM.js → edges-e0da2a9e-CGBBhG8k.js} +2 -2
  24. package/client/dist/assets/{el-GR-BZB4AONW-DW2p_uy7.js → el-GR-BZB4AONW-D4wv1oIz.js} +1 -1
  25. package/client/dist/assets/{erDiagram-9861fffd-CF33V-Of.js → erDiagram-9861fffd-CYaF3q1I.js} +1 -1
  26. package/client/dist/assets/{es-ES-U4NZUMDT-DLOIGnrl.js → es-ES-U4NZUMDT-CGeTKXgd.js} +1 -1
  27. package/client/dist/assets/{eu-ES-A7QVB2H4-LJXbf89m.js → eu-ES-A7QVB2H4-Cayx1TxR.js} +1 -1
  28. package/client/dist/assets/{fa-IR-HGAKTJCU-Dvx65fgW.js → fa-IR-HGAKTJCU-CmUg8pmw.js} +1 -1
  29. package/client/dist/assets/{fi-FI-Z5N7JZ37-EoL65BQh.js → fi-FI-Z5N7JZ37-xvHcPhsU.js} +1 -1
  30. package/client/dist/assets/{flowDb-956e92f1-HgoXVy2H.js → flowDb-956e92f1-C-_LFz70.js} +3 -3
  31. package/client/dist/assets/flowDiagram-66a62f08-C1sHdSjn.js +4 -0
  32. package/client/dist/assets/flowDiagram-v2-96b9c2cf-Cd0Iascd.js +1 -0
  33. package/client/dist/assets/{flowchart-elk-definition-4a651766-DJbI2dpv.js → flowchart-elk-definition-4a651766-CNGfpudb.js} +7 -7
  34. package/client/dist/assets/{fr-FR-RHASNOE6-DNk_jdDs.js → fr-FR-RHASNOE6-DBoHEcNj.js} +1 -1
  35. package/client/dist/assets/{ganttDiagram-c361ad54-2XX670FU.js → ganttDiagram-c361ad54-B8HJQqjt.js} +1 -1
  36. package/client/dist/assets/{gitGraphDiagram-72cf32ee-CcUfruAo.js → gitGraphDiagram-72cf32ee-DojCDvlS.js} +1 -1
  37. package/client/dist/assets/{gl-ES-HMX3MZ6V-dxzFjZlG.js → gl-ES-HMX3MZ6V-p6hrn2cN.js} +1 -1
  38. package/client/dist/assets/{graph-BSbiMSBC.js → graph-DXM7lcy1.js} +1 -1
  39. package/client/dist/assets/{he-IL-6SHJWFNN-Cogsfdt1.js → he-IL-6SHJWFNN-y2jEX6-0.js} +1 -1
  40. package/client/dist/assets/{hi-IN-IWLTKZ5I-L6wbgi4F.js → hi-IN-IWLTKZ5I-99pNfyWr.js} +1 -1
  41. package/client/dist/assets/{hu-HU-A5ZG7DT2-DSA6ZDsH.js → hu-HU-A5ZG7DT2-hygceGMS.js} +1 -1
  42. package/client/dist/assets/{id-ID-SAP4L64H-BK_vGGS6.js → id-ID-SAP4L64H-CyIqi1hv.js} +1 -1
  43. package/client/dist/assets/{image-blob-reduce.esm-BLtmMM_J.js → image-blob-reduce.esm-D6s-rqMO.js} +6 -1
  44. package/client/dist/assets/{index-3862675e-Bv32HUgT.js → index-3862675e-4idOQN2N.js} +1 -1
  45. package/client/dist/assets/{index-BPwf8Fw3.js → index-BGmwbRlb.js} +6 -6
  46. package/client/dist/assets/index-BHZfFT_V.js +97 -0
  47. package/client/dist/assets/{infoDiagram-f8f76790-w4mR4pxn.js → infoDiagram-f8f76790-CFLrHqtc.js} +1 -1
  48. package/client/dist/assets/{it-IT-JPQ66NNP-BLdHYMhn.js → it-IT-JPQ66NNP-DzVvVdQI.js} +1 -1
  49. package/client/dist/assets/{ja-JP-DBVTYXUO-B_vmexl_.js → ja-JP-DBVTYXUO-BI4fPexV.js} +1 -1
  50. package/client/dist/assets/{journeyDiagram-49397b02-D9nmO17e.js → journeyDiagram-49397b02-C3CFDo8z.js} +1 -1
  51. package/client/dist/assets/{kaa-6HZHGXH3-5s-3jl6F.js → kaa-6HZHGXH3-fwOleoQB.js} +1 -1
  52. package/client/dist/assets/{kab-KAB-ZGHBKWFO-2QaVDuSf.js → kab-KAB-ZGHBKWFO-DBI_ri48.js} +1 -1
  53. package/client/dist/assets/{kk-KZ-P5N5QNE5-CTC52Vbi.js → kk-KZ-P5N5QNE5-zpl7uvyF.js} +1 -1
  54. package/client/dist/assets/{km-KH-HSX4SM5Z-DxawH8UZ.js → km-KH-HSX4SM5Z-DOMFSres.js} +1 -1
  55. package/client/dist/assets/{ko-KR-MTYHY66A-CmosEM8_.js → ko-KR-MTYHY66A-tb08hXzd.js} +1 -1
  56. package/client/dist/assets/{ku-TR-6OUDTVRD-DbiLen4y.js → ku-TR-6OUDTVRD-DlIQCCY4.js} +1 -1
  57. package/client/dist/assets/{layout-jmt3H9tA.js → layout-B_11mCXA.js} +1 -1
  58. package/client/dist/assets/{line-JTlRayUJ.js → line-B-qmK_vI.js} +1 -1
  59. package/client/dist/assets/{linear-DJeB5p7x.js → linear-Ph6uuYcX.js} +1 -1
  60. package/client/dist/assets/{lt-LT-XHIRWOB4-CH15wrjA.js → lt-LT-XHIRWOB4--qWy24_Z.js} +1 -1
  61. package/client/dist/assets/{lv-LV-5QDEKY6T-dhgfPuCQ.js → lv-LV-5QDEKY6T-Bnd_1GDb.js} +1 -1
  62. package/client/dist/assets/mindmap-definition-fc14e90a-Do79tIc0.js +425 -0
  63. package/client/dist/assets/{mr-IN-CRQNXWMA-3Gi6iq7A.js → mr-IN-CRQNXWMA-BsV6HaD9.js} +1 -1
  64. package/client/dist/assets/{my-MM-5M5IBNSE-CpH4rdJj.js → my-MM-5M5IBNSE-kZQURVIi.js} +1 -1
  65. package/client/dist/assets/{nb-NO-T6EIAALU-Du6iiGql.js → nb-NO-T6EIAALU-Cvf9FdSF.js} +1 -1
  66. package/client/dist/assets/{nl-NL-IS3SIHDZ-BGvsd1MT.js → nl-NL-IS3SIHDZ-DA1yqpXw.js} +1 -1
  67. package/client/dist/assets/{nn-NO-6E72VCQL-B-odvJZW.js → nn-NO-6E72VCQL-89lm3vku.js} +1 -1
  68. package/client/dist/assets/{oc-FR-POXYY2M6-COC8xNjo.js → oc-FR-POXYY2M6-BsrjTJQh.js} +1 -1
  69. package/client/dist/assets/{pa-IN-N4M65BXN-CE21PUQH.js → pa-IN-N4M65BXN-CczefYaj.js} +1 -1
  70. package/client/dist/assets/pdf-CE_K4jFx.js +12 -0
  71. package/client/dist/assets/percentages-BXMCSKIN-Be6p9phi.js +207 -0
  72. package/client/dist/assets/pica-CQIY57Tf.js +7 -0
  73. package/client/dist/assets/{pieDiagram-8a3498a8-Cvfh7Qr5.js → pieDiagram-8a3498a8-CfblQHdm.js} +2 -2
  74. package/client/dist/assets/{pl-PL-T2D74RX3-D4xFVSoT.js → pl-PL-T2D74RX3-DdhH-zcK.js} +1 -1
  75. package/client/dist/assets/{pt-BR-5N22H2LF-CCq257gA.js → pt-BR-5N22H2LF-gpwlheL6.js} +1 -1
  76. package/client/dist/assets/{pt-PT-UZXXM6DQ-1l8gt5vA.js → pt-PT-UZXXM6DQ-Cs87vICi.js} +1 -1
  77. package/client/dist/assets/{quadrantDiagram-120e2f19-BA0js1aD.js → quadrantDiagram-120e2f19-CRMSamSP.js} +1 -1
  78. package/client/dist/assets/{requirementDiagram-deff3bca-B0QNFfIn.js → requirementDiagram-deff3bca-D3LBN016.js} +1 -1
  79. package/client/dist/assets/{ro-RO-JPDTUUEW-yosBW01E.js → ro-RO-JPDTUUEW-CWTSJ1Dt.js} +1 -1
  80. package/client/dist/assets/roundRect-0PYZxl1G.js +1 -0
  81. package/client/dist/assets/{ru-RU-B4JR7IUQ-8LkEJUix.js → ru-RU-B4JR7IUQ-Bq7aN2ep.js} +1 -1
  82. package/client/dist/assets/{sankeyDiagram-04a897e0-D4T9eCXn.js → sankeyDiagram-04a897e0-CsFqOQZN.js} +3 -3
  83. package/client/dist/assets/{sequenceDiagram-704730f1-CfBUTCrO.js → sequenceDiagram-704730f1-BRYXVDGX.js} +1 -1
  84. package/client/dist/assets/{si-LK-N5RQ5JYF-D8rjbqtd.js → si-LK-N5RQ5JYF-BBjcNYQh.js} +1 -1
  85. package/client/dist/assets/{sk-SK-C5VTKIMK-Bg14sAzN.js → sk-SK-C5VTKIMK-ByjKQzUb.js} +1 -1
  86. package/client/dist/assets/{sl-SI-NN7IZMDC-CMTib6Zs.js → sl-SI-NN7IZMDC-B8WCyMBU.js} +1 -1
  87. package/client/dist/assets/{stateDiagram-587899a1-BGgvmVSZ.js → stateDiagram-587899a1-BHoy9LtD.js} +1 -1
  88. package/client/dist/assets/{stateDiagram-v2-d93cdb3a-Qn3DpYuO.js → stateDiagram-v2-d93cdb3a-BvMUA6bS.js} +1 -1
  89. package/client/dist/assets/{styles-6aaf32cf-IdVZLPrD.js → styles-6aaf32cf-Dr-lfIOW.js} +2 -2
  90. package/client/dist/assets/{styles-9a916d00-BAC3L45X.js → styles-9a916d00-DS4wRpL7.js} +1 -1
  91. package/client/dist/assets/{styles-c10674c1-COhXxX8c.js → styles-c10674c1-nKRF6NrH.js} +1 -1
  92. package/client/dist/assets/{subset-shared.chunk-BWHnFai4.js → subset-shared.chunk-KT79s7KG.js} +64 -2
  93. package/client/dist/assets/subset-worker.chunk-BMx1eyv3.js +1 -0
  94. package/client/dist/assets/{sv-SE-XGPEYMSR-C1425rOF.js → sv-SE-XGPEYMSR-BiIPUVbv.js} +1 -1
  95. package/client/dist/assets/{svgDrawCommon-08f97a94-Cfk-fgnN.js → svgDrawCommon-08f97a94-C3uP9PYr.js} +1 -1
  96. package/client/dist/assets/{ta-IN-2NMHFXQM-BHHo1zpF.js → ta-IN-2NMHFXQM-Cidadso2.js} +1 -1
  97. package/client/dist/assets/{th-TH-HPSO5L25-CZVzm_WT.js → th-TH-HPSO5L25-CFNnJwSv.js} +1 -1
  98. package/client/dist/assets/{timeline-definition-85554ec2-VAvuJith.js → timeline-definition-85554ec2-BSsLsIgF.js} +1 -1
  99. package/client/dist/assets/{tr-TR-DEFEU3FU-DE1lclCq.js → tr-TR-DEFEU3FU-DaFcI-KL.js} +1 -1
  100. package/client/dist/assets/{uk-UA-QMV73CPH-D4lJZ85O.js → uk-UA-QMV73CPH-DkBW36St.js} +1 -1
  101. package/client/dist/assets/vendor-codemirror-langs-BH1ZcKHY.js +20 -0
  102. package/client/dist/assets/vendor-codemirror-rix45NST.js +16 -0
  103. package/client/dist/assets/vendor-i18n-DCFGyhQR.js +1 -0
  104. package/client/dist/assets/vendor-icons-Dh9m_Ydt.js +596 -0
  105. package/client/dist/assets/{vendor-markdown-CIVH08vJ.js → vendor-markdown-BXEi_H3G.js} +3 -3
  106. package/client/dist/assets/vendor-react-9mUTKBHH.js +67 -0
  107. package/client/dist/assets/{vendor-syntax-Djb62v3a.js → vendor-syntax-DnmwQQJF.js} +14 -7
  108. package/client/dist/assets/vendor-xterm-CZq1hqo1.js +66 -0
  109. package/client/dist/assets/vendor-xterm-qxJ8_QYu.css +32 -0
  110. package/client/dist/assets/{vi-VN-M7AON7JQ-Dgc_SShk.js → vi-VN-M7AON7JQ-KrtfxOzl.js} +1 -1
  111. package/client/dist/assets/{xychartDiagram-e933f94c-BeyVBJhb.js → xychartDiagram-e933f94c-CgNgZ4pp.js} +1 -1
  112. package/client/dist/assets/{zh-CN-LNUGB5OW-MH4Yh8in.js → zh-CN-LNUGB5OW-BQu12RoD.js} +1 -1
  113. package/client/dist/assets/{zh-HK-E62DVLB3-D4XHehjx.js → zh-HK-E62DVLB3-zx9CvERq.js} +1 -1
  114. package/client/dist/assets/{zh-TW-RAJ6MFWO--efj3evj.js → zh-TW-RAJ6MFWO-ffJWgVxn.js} +1 -1
  115. package/client/dist/index.html +6 -6
  116. package/commands/upfynai-connect.md +31 -18
  117. package/commands/upfynai.md +45 -26
  118. package/package.json +1 -1
  119. package/server/cli-ui.js +785 -0
  120. package/server/cli.js +235 -161
  121. package/server/index.js +159 -107
  122. package/server/middleware/auth.js +9 -5
  123. package/server/openrouter.js +137 -0
  124. package/server/relay-client.js +158 -47
  125. package/server/routes/agent.js +54 -19
  126. package/server/routes/auth.js +59 -22
  127. package/server/routes/settings.js +91 -0
  128. package/shared/modelConstants.js +29 -0
  129. package/client/dist/assets/AppContent-CTSHQdyq.js +0 -513
  130. package/client/dist/assets/CanvasPanel-Cig0Mo9s.js +0 -6
  131. package/client/dist/assets/LoginModal-silya-zP.js +0 -11
  132. package/client/dist/assets/MarkdownPreview-B3c7OEj6.js +0 -1
  133. package/client/dist/assets/channel-CSnvHe_M.js +0 -1
  134. package/client/dist/assets/classDiagram-v2-f2320105-xtym7GEZ.js +0 -2
  135. package/client/dist/assets/clone-B75abXxS.js +0 -1
  136. package/client/dist/assets/flowDiagram-66a62f08-tffoET0H.js +0 -4
  137. package/client/dist/assets/flowDiagram-v2-96b9c2cf-Byc3JCHh.js +0 -1
  138. package/client/dist/assets/index-D1urGMYu.js +0 -95
  139. package/client/dist/assets/mindmap-definition-fc14e90a-BOOrexmz.js +0 -415
  140. package/client/dist/assets/pdf-TYrZqVzP.js +0 -12
  141. package/client/dist/assets/percentages-BXMCSKIN-C9GT0OD3.js +0 -199
  142. package/client/dist/assets/pica-VkdyTzi8.js +0 -2
  143. package/client/dist/assets/roundRect-mAH3dD0p.js +0 -1
  144. package/client/dist/assets/subset-worker.chunk-C8QUSruZ.js +0 -1
  145. package/client/dist/assets/vendor-codemirror-BARtJV1V.js +0 -16
  146. package/client/dist/assets/vendor-codemirror-langs-52_y1wip.js +0 -20
  147. package/client/dist/assets/vendor-i18n-ByAl-gdx.js +0 -1
  148. package/client/dist/assets/vendor-icons-D33IkSIf.js +0 -1
  149. package/client/dist/assets/vendor-react-CHoMc7ka.js +0 -8
  150. package/client/dist/assets/vendor-xterm-DBb3RXlu.js +0 -66
  151. package/client/dist/assets/vendor-xterm-DrlLKa8f.css +0 -1
@@ -17,19 +17,27 @@ import path from 'path';
17
17
  import { spawn } from 'child_process';
18
18
  import { promises as fsPromises } from 'fs';
19
19
  import crypto from 'crypto';
20
+ import {
21
+ c,
22
+ showConnectStartup,
23
+ showConnectionBanner,
24
+ logRelayEvent,
25
+ createSpinner,
26
+ } from './cli-ui.js';
27
+
28
+ // Load package.json for version
29
+ import { fileURLToPath } from 'url';
30
+ const __filename_rc = fileURLToPath(import.meta.url);
31
+ const __dirname_rc = path.dirname(__filename_rc);
32
+ let VERSION = '0.0.0';
33
+ try {
34
+ const pkg = JSON.parse(fs.readFileSync(path.join(__dirname_rc, '../package.json'), 'utf8'));
35
+ VERSION = pkg.version;
36
+ } catch { /* ignore */ }
20
37
 
21
38
  const CONFIG_DIR = path.join(os.homedir(), '.upfynai');
22
39
  const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json');
23
40
 
24
- // ANSI colors
25
- const c = {
26
- green: (t) => `\x1b[32m${t}\x1b[0m`,
27
- red: (t) => `\x1b[31m${t}\x1b[0m`,
28
- cyan: (t) => `\x1b[36m${t}\x1b[0m`,
29
- dim: (t) => `\x1b[2m${t}\x1b[0m`,
30
- bold: (t) => `\x1b[1m${t}\x1b[0m`,
31
- };
32
-
33
41
  function loadConfig() {
34
42
  try {
35
43
  if (fs.existsSync(CONFIG_FILE)) {
@@ -90,11 +98,9 @@ async function handleRelayCommand(data, ws) {
90
98
  try {
91
99
  switch (action) {
92
100
  case 'claude-query': {
93
- // Run Claude via local Agent SDK / CLI
94
101
  const { command, options } = data;
95
- console.log(c.cyan(`[RELAY] Claude query: ${command?.slice(0, 80)}...`));
102
+ logRelayEvent('>', `Claude query: ${command?.slice(0, 60)}...`, 'cyan');
96
103
 
97
- // Use claude CLI for the query
98
104
  const args = ['--print'];
99
105
  if (options?.projectPath) args.push('--cwd', options.projectPath);
100
106
  if (options?.sessionId) args.push('--continue', options.sessionId);
@@ -133,7 +139,11 @@ async function handleRelayCommand(data, ws) {
133
139
 
134
140
  case 'shell-command': {
135
141
  const { command: cmd, cwd } = data;
136
- console.log(c.dim(`[RELAY] Shell: ${cmd?.slice(0, 60)}`));
142
+ // Block dangerous shell patterns
143
+ if (!cmd || typeof cmd !== 'string') throw new Error('Invalid command');
144
+ const dangerous = ['rm -rf /', 'mkfs', 'dd if=', ':(){', 'fork bomb', '> /dev/sd'];
145
+ if (dangerous.some(d => cmd.includes(d))) throw new Error('Command blocked for safety');
146
+ logRelayEvent('$', `Shell: ${cmd?.slice(0, 50)}`, 'dim');
137
147
  const result = await execCommand(cmd, [], { cwd: cwd || os.homedir(), timeout: 60000 });
138
148
  ws.send(JSON.stringify({ type: 'relay-response', requestId, data: { stdout: result } }));
139
149
  break;
@@ -141,14 +151,26 @@ async function handleRelayCommand(data, ws) {
141
151
 
142
152
  case 'file-read': {
143
153
  const { filePath } = data;
144
- const content = await fsPromises.readFile(filePath, 'utf8');
154
+ if (!filePath || typeof filePath !== 'string') throw new Error('Invalid file path');
155
+ // Block reading sensitive system files
156
+ const normalizedPath = path.resolve(filePath);
157
+ const blocked = ['/etc/shadow', '/etc/passwd', '.ssh/id_rsa', '.env'];
158
+ if (blocked.some(b => normalizedPath.includes(b))) throw new Error('Access denied');
159
+ logRelayEvent('R', `Read: ${filePath}`, 'dim');
160
+ const content = await fsPromises.readFile(normalizedPath, 'utf8');
145
161
  ws.send(JSON.stringify({ type: 'relay-response', requestId, data: { content } }));
146
162
  break;
147
163
  }
148
164
 
149
165
  case 'file-write': {
150
166
  const { filePath: fp, content: fileContent } = data;
151
- await fsPromises.writeFile(fp, fileContent, 'utf8');
167
+ if (!fp || typeof fp !== 'string') throw new Error('Invalid file path');
168
+ // Block writing to sensitive locations
169
+ const normalizedFp = path.resolve(fp);
170
+ const blockedDirs = ['/etc/', '/usr/bin/', '/usr/sbin/', 'System32', '.ssh/'];
171
+ if (blockedDirs.some(d => normalizedFp.includes(d))) throw new Error('Access denied');
172
+ logRelayEvent('W', `Write: ${fp}`, 'dim');
173
+ await fsPromises.writeFile(normalizedFp, fileContent, 'utf8');
152
174
  ws.send(JSON.stringify({ type: 'relay-response', requestId, data: { success: true } }));
153
175
  break;
154
176
  }
@@ -162,7 +184,7 @@ async function handleRelayCommand(data, ws) {
162
184
 
163
185
  case 'git-operation': {
164
186
  const { gitCommand, cwd: gitCwd } = data;
165
- console.log(c.dim(`[RELAY] Git: ${gitCommand}`));
187
+ logRelayEvent('G', `Git: ${gitCommand}`, 'dim');
166
188
  const result = await execCommand('git', [gitCommand], { cwd: gitCwd });
167
189
  ws.send(JSON.stringify({ type: 'relay-response', requestId, data: { stdout: result } }));
168
190
  break;
@@ -192,7 +214,7 @@ async function buildFileTree(dirPath, maxDepth, currentDepth = 0) {
192
214
  try {
193
215
  const entries = await fsPromises.readdir(dirPath, { withFileTypes: true });
194
216
  const items = [];
195
- for (const entry of entries.slice(0, 100)) { // Limit to 100 entries
217
+ for (const entry of entries.slice(0, 100)) {
196
218
  if (entry.name.startsWith('.') || entry.name === 'node_modules') continue;
197
219
  const item = { name: entry.name, type: entry.isDirectory() ? 'directory' : 'file' };
198
220
  if (entry.isDirectory() && currentDepth < maxDepth - 1) {
@@ -207,7 +229,22 @@ async function buildFileTree(dirPath, maxDepth, currentDepth = 0) {
207
229
  }
208
230
 
209
231
  /**
210
- * Main connect function
232
+ * Create WebSocket connection with optional API key in handshake
233
+ */
234
+ function createRelayConnection(wsUrl, config = {}) {
235
+ const headers = {};
236
+ // Send API key in WebSocket headers if available
237
+ if (config.anthropicApiKey) {
238
+ headers['x-anthropic-api-key'] = config.anthropicApiKey;
239
+ }
240
+ headers['x-upfyn-version'] = VERSION;
241
+ headers['x-upfyn-machine'] = os.hostname();
242
+
243
+ return new WebSocket(wsUrl, { headers });
244
+ }
245
+
246
+ /**
247
+ * Main connect function (interactive — with animation and logging)
211
248
  */
212
249
  export async function connectToServer(options = {}) {
213
250
  const config = loadConfig();
@@ -215,43 +252,44 @@ export async function connectToServer(options = {}) {
215
252
  const relayKey = options.key || config.relayKey;
216
253
 
217
254
  if (!relayKey) {
218
- console.error(c.red('No relay key provided.'));
219
- console.log('Generate a relay token from Settings > Relay Tokens in the web UI.');
220
- console.log(`Then run: ${c.cyan('upfynai-code connect --key upfyn_your_token_here')}`);
255
+ console.log('');
256
+ console.log(` ${c.red('FAIL')} No relay key provided.`);
257
+ console.log('');
258
+ console.log(` ${c.gray('Get your relay token from the web UI:')}`);
259
+ console.log(` ${c.dim('1.')} Sign in at ${c.cyan('https://cli.upfyn.com')}`);
260
+ console.log(` ${c.dim('2.')} Click ${c.bright('Connect')} button`);
261
+ console.log(` ${c.dim('3.')} Copy the command and run it here`);
262
+ console.log('');
263
+ console.log(` ${c.gray('Or run:')} ${c.bright('uc connect --server <url> --key upfyn_your_token')}`);
264
+ console.log('');
221
265
  process.exit(1);
222
266
  }
223
267
 
224
268
  // Save config for future use
225
269
  saveConfig({ ...config, server: serverUrl, relayKey });
226
270
 
271
+ // Show beautiful startup with rocket animation
272
+ await showConnectStartup(
273
+ serverUrl,
274
+ os.hostname(),
275
+ os.userInfo().username,
276
+ VERSION
277
+ );
278
+
227
279
  const wsUrl = serverUrl.replace(/^http/, 'ws') + '/relay?token=' + encodeURIComponent(relayKey);
228
280
 
229
- console.log(c.bold('\n Upfyn-Code Relay Client\n'));
230
- console.log(` Server: ${c.cyan(serverUrl)}`);
231
- console.log(` Machine: ${c.dim(os.hostname())}`);
232
- console.log(` User: ${c.dim(os.userInfo().username)}\n`);
281
+ const spinner = createSpinner('Connecting to server...');
282
+ spinner.start();
233
283
 
234
284
  let reconnectAttempts = 0;
235
285
  const MAX_RECONNECT = 10;
236
286
 
237
287
  function connect() {
238
- const ws = new WebSocket(wsUrl);
288
+ const ws = createRelayConnection(wsUrl, config);
239
289
 
240
290
  ws.on('open', () => {
241
291
  reconnectAttempts = 0;
242
- console.log(c.green(' Connected! Your local machine is now bridged to the server.'));
243
- console.log(c.dim(' Press Ctrl+C to disconnect.\n'));
244
-
245
- // Send heartbeat every 30 seconds
246
- const heartbeat = setInterval(() => {
247
- if (ws.readyState === 1) {
248
- ws.send(JSON.stringify({ type: 'ping' }));
249
- }
250
- }, 30000);
251
-
252
- ws.on('close', () => {
253
- clearInterval(heartbeat);
254
- });
292
+ // Don't stop spinner yet wait for relay-connected message
255
293
  });
256
294
 
257
295
  ws.on('message', (rawMessage) => {
@@ -259,7 +297,12 @@ export async function connectToServer(options = {}) {
259
297
  const data = JSON.parse(rawMessage);
260
298
 
261
299
  if (data.type === 'relay-connected') {
262
- console.log(c.green(` ${data.message}`));
300
+ spinner.stop('Connected!');
301
+ // Extract username from message if possible
302
+ const nameMatch = data.message?.match(/Connected as (.+?)\./);
303
+ const username = nameMatch ? nameMatch[1] : 'Unknown';
304
+ showConnectionBanner(username, serverUrl);
305
+ logRelayEvent('*', 'Relay active -- waiting for commands...', 'green');
263
306
  return;
264
307
  }
265
308
 
@@ -271,44 +314,112 @@ export async function connectToServer(options = {}) {
271
314
  if (data.type === 'pong') return;
272
315
 
273
316
  if (data.type === 'error') {
274
- console.error(c.red(` Server error: ${data.error}`));
317
+ spinner.fail(`Server error: ${data.error}`);
275
318
  return;
276
319
  }
277
320
  } catch (e) {
278
- console.error(c.red(` Error: ${e.message}`));
321
+ logRelayEvent('!', `Parse error: ${e.message}`, 'red');
279
322
  }
280
323
  });
281
324
 
282
325
  ws.on('close', (code) => {
283
326
  if (code === 1000) {
284
- console.log(c.dim(' Disconnected.'));
327
+ logRelayEvent('-', 'Disconnected gracefully.', 'dim');
285
328
  process.exit(0);
286
329
  }
287
330
 
288
331
  reconnectAttempts++;
289
332
  if (reconnectAttempts <= MAX_RECONNECT) {
290
333
  const delay = Math.min(1000 * Math.pow(2, reconnectAttempts), 30000);
291
- console.log(c.dim(` Connection lost. Reconnecting in ${delay / 1000}s... (${reconnectAttempts}/${MAX_RECONNECT})`));
334
+ logRelayEvent('~', `Connection lost. Reconnecting in ${delay / 1000}s... (${reconnectAttempts}/${MAX_RECONNECT})`, 'yellow');
292
335
  setTimeout(connect, delay);
293
336
  } else {
294
- console.error(c.red(' Max reconnection attempts reached. Exiting.'));
337
+ logRelayEvent('X', 'Max reconnection attempts reached. Exiting.', 'red');
295
338
  process.exit(1);
296
339
  }
297
340
  });
298
341
 
299
342
  ws.on('error', (err) => {
300
343
  if (err.code === 'ECONNREFUSED') {
301
- console.error(c.red(` Cannot reach ${serverUrl}. Is the server running?`));
344
+ spinner.fail(`Cannot reach ${serverUrl}. Is the server running?`);
302
345
  }
303
346
  // close handler will trigger reconnect
304
347
  });
348
+
349
+ // Heartbeat every 30 seconds
350
+ const heartbeat = setInterval(() => {
351
+ if (ws.readyState === 1) {
352
+ ws.send(JSON.stringify({ type: 'ping' }));
353
+ } else {
354
+ clearInterval(heartbeat);
355
+ }
356
+ }, 30000);
305
357
  }
306
358
 
307
359
  connect();
308
360
 
309
361
  // Graceful shutdown
310
362
  process.on('SIGINT', () => {
311
- console.log(c.dim('\n Disconnecting...'));
363
+ console.log('');
364
+ logRelayEvent('-', 'Disconnecting...', 'dim');
312
365
  process.exit(0);
313
366
  });
314
367
  }
368
+
369
+ /**
370
+ * Background connect function (silent — used when uc launches Claude Code)
371
+ * Runs relay in the background without animation or user-facing output.
372
+ */
373
+ export function connectToServerBackground(options = {}) {
374
+ const config = loadConfig();
375
+ const serverUrl = options.server || config.server;
376
+ const relayKey = options.key || config.relayKey;
377
+
378
+ if (!serverUrl || !relayKey) return;
379
+
380
+ const wsUrl = serverUrl.replace(/^http/, 'ws') + '/relay?token=' + encodeURIComponent(relayKey);
381
+
382
+ let reconnectAttempts = 0;
383
+ const MAX_RECONNECT = 5;
384
+
385
+ function connect() {
386
+ const ws = createRelayConnection(wsUrl, config);
387
+
388
+ ws.on('message', (rawMessage) => {
389
+ try {
390
+ const data = JSON.parse(rawMessage);
391
+ if (data.type === 'relay-command') {
392
+ handleRelayCommand(data, ws);
393
+ }
394
+ } catch { /* ignore */ }
395
+ });
396
+
397
+ ws.on('open', () => {
398
+ reconnectAttempts = 0;
399
+ });
400
+
401
+ ws.on('close', (code) => {
402
+ if (code === 1000) return;
403
+ reconnectAttempts++;
404
+ if (reconnectAttempts <= MAX_RECONNECT) {
405
+ const delay = Math.min(1000 * Math.pow(2, reconnectAttempts), 30000);
406
+ setTimeout(connect, delay);
407
+ }
408
+ });
409
+
410
+ ws.on('error', () => {
411
+ // silent — close handler will reconnect
412
+ });
413
+
414
+ // Heartbeat
415
+ const heartbeat = setInterval(() => {
416
+ if (ws.readyState === 1) {
417
+ ws.send(JSON.stringify({ type: 'ping' }));
418
+ } else {
419
+ clearInterval(heartbeat);
420
+ }
421
+ }, 30000);
422
+ }
423
+
424
+ connect();
425
+ }
@@ -4,15 +4,34 @@ import path from 'path';
4
4
  import os from 'os';
5
5
  import { promises as fs } from 'fs';
6
6
  import crypto from 'crypto';
7
- import { userDb, apiKeysDb, githubTokensDb } from '../database/db.js';
7
+ import { userDb, apiKeysDb, githubTokensDb, credentialsDb } from '../database/db.js';
8
8
  import { addProjectManually } from '../projects.js';
9
9
  import { queryClaudeSDK } from '../claude-sdk.js';
10
10
  import { spawnCursor } from '../cursor-cli.js';
11
11
  import { queryCodex } from '../openai-codex.js';
12
12
  import { Octokit } from '@octokit/rest';
13
13
  import { CLAUDE_MODELS, CURSOR_MODELS, CODEX_MODELS } from '../../shared/modelConstants.js';
14
+ import { queryOpenRouter, OPENROUTER_MODELS } from '../openrouter.js';
14
15
  import { IS_PLATFORM } from '../constants/config.js';
15
16
 
17
+ // BYOK helper: get user's stored API key for a provider
18
+ async function getUserProviderKey(userId, providerType) {
19
+ if (!userId) return null;
20
+ try {
21
+ const creds = await credentialsDb.getCredentials(userId, providerType);
22
+ const active = creds.find(c => c.is_active);
23
+ return active?.credential_value || null;
24
+ } catch { return null; }
25
+ }
26
+
27
+ async function withUserApiKey(envKey, userKey, fn) {
28
+ if (!userKey) return fn();
29
+ const prev = process.env[envKey];
30
+ process.env[envKey] = userKey;
31
+ try { return await fn(); }
32
+ finally { if (prev !== undefined) process.env[envKey] = prev; else delete process.env[envKey]; }
33
+ }
34
+
16
35
  const router = express.Router();
17
36
 
18
37
  /**
@@ -939,17 +958,22 @@ router.post('/', validateExternalApiKey, async (req, res) => {
939
958
  });
940
959
  }
941
960
 
942
- // Start the appropriate session
961
+ // Start the appropriate session (with BYOK key injection where applicable)
962
+ const userId = req.user?.id;
963
+
943
964
  if (provider === 'claude') {
944
965
  console.log('🤖 Starting Claude SDK session');
945
-
946
- await queryClaudeSDK(message.trim(), {
947
- projectPath: finalProjectPath,
948
- cwd: finalProjectPath,
949
- sessionId: null, // New session
950
- model: model,
951
- permissionMode: 'bypassPermissions' // Bypass all permissions for API calls
952
- }, writer);
966
+ const userKey = await getUserProviderKey(userId, 'anthropic_key');
967
+
968
+ await withUserApiKey('ANTHROPIC_API_KEY', userKey, () =>
969
+ queryClaudeSDK(message.trim(), {
970
+ projectPath: finalProjectPath,
971
+ cwd: finalProjectPath,
972
+ sessionId: null,
973
+ model: model,
974
+ permissionMode: 'bypassPermissions'
975
+ }, writer)
976
+ );
953
977
 
954
978
  } else if (provider === 'cursor') {
955
979
  console.log('🖱️ Starting Cursor CLI session');
@@ -957,19 +981,30 @@ router.post('/', validateExternalApiKey, async (req, res) => {
957
981
  await spawnCursor(message.trim(), {
958
982
  projectPath: finalProjectPath,
959
983
  cwd: finalProjectPath,
960
- sessionId: null, // New session
984
+ sessionId: null,
961
985
  model: model || undefined,
962
- skipPermissions: true // Bypass permissions for Cursor
986
+ skipPermissions: true
963
987
  }, writer);
964
988
  } else if (provider === 'codex') {
965
989
  console.log('🤖 Starting Codex SDK session');
966
-
967
- await queryCodex(message.trim(), {
968
- projectPath: finalProjectPath,
969
- cwd: finalProjectPath,
970
- sessionId: null,
971
- model: model || CODEX_MODELS.DEFAULT,
972
- permissionMode: 'bypassPermissions'
990
+ const userKey = await getUserProviderKey(userId, 'openai_key');
991
+
992
+ await withUserApiKey('OPENAI_API_KEY', userKey, () =>
993
+ queryCodex(message.trim(), {
994
+ projectPath: finalProjectPath,
995
+ cwd: finalProjectPath,
996
+ sessionId: null,
997
+ model: model || CODEX_MODELS.DEFAULT,
998
+ permissionMode: 'bypassPermissions'
999
+ }, writer)
1000
+ );
1001
+ } else if (provider === 'openrouter') {
1002
+ console.log('🌐 Starting OpenRouter session');
1003
+ const userKey = await getUserProviderKey(userId, 'openrouter_key');
1004
+
1005
+ await queryOpenRouter(message.trim(), {
1006
+ model: model || OPENROUTER_MODELS.DEFAULT,
1007
+ apiKey: userKey,
973
1008
  }, writer);
974
1009
  }
975
1010
 
@@ -1,6 +1,6 @@
1
1
  import express from 'express';
2
2
  import bcrypt from 'bcryptjs';
3
- import { userDb, subscriptionDb, relayTokensDb, apiKeysDb } from '../database/db.js';
3
+ import { db, userDb, subscriptionDb, relayTokensDb, apiKeysDb } from '../database/db.js';
4
4
  import { generateToken, authenticateToken, setSessionCookie, clearSessionCookie } from '../middleware/auth.js';
5
5
 
6
6
  const router = express.Router();
@@ -19,8 +19,12 @@ router.get('/status', async (req, res) => {
19
19
  }
20
20
  });
21
21
 
22
- // User registration — allows multiple users
23
- router.post('/register', async (req, res) => {
22
+ // User registration — allows multiple users (rate limited)
23
+ router.post('/register', (req, res, next) => {
24
+ const rl = req.app.locals.authRateLimit;
25
+ if (rl) return rl(req, res, next);
26
+ next();
27
+ }, async (req, res) => {
24
28
  try {
25
29
  const { username, password, email, phone, firstName, lastName } = req.body;
26
30
 
@@ -28,13 +32,16 @@ router.post('/register', async (req, res) => {
28
32
  if (!password) {
29
33
  return res.status(400).json({ error: 'Password is required' });
30
34
  }
31
- if (password.length < 6) {
32
- return res.status(400).json({ error: 'Password must be at least 6 characters' });
35
+ if (password.length < 8) {
36
+ return res.status(400).json({ error: 'Password must be at least 8 characters' });
37
+ }
38
+ if (password.length > 128) {
39
+ return res.status(400).json({ error: 'Password is too long' });
33
40
  }
34
41
 
35
- // Derive display username from firstName + lastName if no username provided
36
- const fName = (firstName || '').trim();
37
- const lName = (lastName || '').trim();
42
+ // Sanitize and validate name/phone inputs
43
+ const fName = (firstName || '').trim().slice(0, 50);
44
+ const lName = (lastName || '').trim().slice(0, 50);
38
45
  const displayName = username || [fName, lName].filter(Boolean).join(' ') || 'User';
39
46
 
40
47
  if (displayName.length < 2) {
@@ -86,39 +93,69 @@ router.post('/register', async (req, res) => {
86
93
  }
87
94
  });
88
95
 
89
- // User login
90
- router.post('/login', async (req, res) => {
96
+ // User login (rate limited)
97
+ router.post('/login', (req, res, next) => {
98
+ const rl = req.app.locals.authRateLimit;
99
+ if (rl) return rl(req, res, next);
100
+ next();
101
+ }, async (req, res) => {
91
102
  try {
92
- const { username, password } = req.body;
103
+ const { username, password, firstName, lastName, phone } = req.body;
93
104
 
94
105
  if (!username || !password) {
95
- return res.status(400).json({ error: 'Username and password are required' });
106
+ return res.status(400).json({ error: 'Email and password are required' });
96
107
  }
97
108
 
98
109
  const user = await userDb.getUserByUsername(username.trim());
99
110
  if (!user) {
100
- return res.status(401).json({ error: 'Invalid username or password' });
111
+ return res.status(401).json({ error: 'Invalid email or password' });
101
112
  }
102
113
 
103
114
  const isValidPassword = await bcrypt.compare(password, user.password_hash);
104
115
  if (!isValidPassword) {
105
- return res.status(401).json({ error: 'Invalid username or password' });
116
+ return res.status(401).json({ error: 'Invalid email or password' });
117
+ }
118
+
119
+ // Update name/phone if provided and different from stored values
120
+ const fName = (firstName || '').trim().slice(0, 50);
121
+ const lName = (lastName || '').trim().slice(0, 50);
122
+ const ph = (phone || '').trim().slice(0, 20);
123
+ const updates = [];
124
+ const args = [];
125
+ if (fName && fName !== user.first_name) { updates.push('first_name = ?'); args.push(fName); }
126
+ if (lName && lName !== user.last_name) { updates.push('last_name = ?'); args.push(lName); }
127
+ if (ph && ph !== user.phone) { updates.push('phone = ?'); args.push(ph); }
128
+ if (fName && lName && `${fName} ${lName}` !== user.username) {
129
+ updates.push('username = ?');
130
+ args.push(`${fName} ${lName}`);
131
+ } else if (fName && !lName && fName !== user.username && !user.last_name) {
132
+ updates.push('username = ?');
133
+ args.push(fName);
134
+ }
135
+ if (updates.length > 0) {
136
+ try {
137
+ args.push(user.id);
138
+ await db.execute({ sql: `UPDATE users SET ${updates.join(', ')} WHERE id = ?`, args });
139
+ } catch { /* non-critical profile update */ }
106
140
  }
107
141
 
142
+ // Re-fetch user to get updated fields
143
+ const updatedUser = updates.length > 0 ? (await userDb.getUserById(user.id)) || user : user;
144
+
108
145
  // Generate token + set cookie
109
- const token = generateToken(user);
146
+ const token = generateToken(updatedUser);
110
147
  setSessionCookie(res, token);
111
- await userDb.updateLastLogin(user.id);
148
+ await userDb.updateLastLogin(updatedUser.id);
112
149
 
113
150
  // Backfill relay token + API key if missing (for users created before auto-provisioning)
114
151
  try {
115
- const existingTokens = await relayTokensDb.getTokens(user.id);
152
+ const existingTokens = await relayTokensDb.getTokens(updatedUser.id);
116
153
  if (existingTokens.length === 0) {
117
- await relayTokensDb.createToken(user.id, 'default');
154
+ await relayTokensDb.createToken(updatedUser.id, 'default');
118
155
  }
119
- const existingKeys = await apiKeysDb.getApiKeys(user.id);
156
+ const existingKeys = await apiKeysDb.getApiKeys(updatedUser.id);
120
157
  if (existingKeys.length === 0) {
121
- await apiKeysDb.createApiKey(user.id, 'default');
158
+ await apiKeysDb.createApiKey(updatedUser.id, 'default');
122
159
  }
123
160
  } catch { /* non-critical backfill */ }
124
161
 
@@ -126,7 +163,7 @@ router.post('/login', async (req, res) => {
126
163
  let subscription = null;
127
164
  try {
128
165
  await subscriptionDb.expireOverdue();
129
- const sub = await subscriptionDb.getActiveSub(user.id);
166
+ const sub = await subscriptionDb.getActiveSub(updatedUser.id);
130
167
  if (sub) {
131
168
  subscription = { id: sub.id, planId: sub.plan_id, status: sub.status, startsAt: sub.starts_at, expiresAt: sub.expires_at };
132
169
  }
@@ -134,7 +171,7 @@ router.post('/login', async (req, res) => {
134
171
 
135
172
  res.json({
136
173
  success: true,
137
- user: { id: user.user_code || `upc-${String(user.id).padStart(3, '0')}`, username: user.username, first_name: user.first_name, last_name: user.last_name, email: user.email, phone: user.phone, access_override: user.access_override || null, subscription },
174
+ user: { id: updatedUser.user_code || `upc-${String(updatedUser.id).padStart(3, '0')}`, username: updatedUser.username, first_name: updatedUser.first_name, last_name: updatedUser.last_name, email: updatedUser.email, phone: updatedUser.phone, access_override: updatedUser.access_override || null, subscription },
138
175
  token // backward compat
139
176
  });
140
177
 
@@ -175,4 +175,95 @@ router.patch('/credentials/:credentialId/toggle', async (req, res) => {
175
175
  }
176
176
  });
177
177
 
178
+ // ===============================
179
+ // AI Provider Keys (BYOK)
180
+ // ===============================
181
+
182
+ const AI_PROVIDER_TYPES = [
183
+ 'anthropic_key',
184
+ 'openai_key',
185
+ 'openrouter_key',
186
+ 'google_key',
187
+ ];
188
+
189
+ // Get all AI provider keys for the authenticated user (masked values)
190
+ router.get('/ai-providers', async (req, res) => {
191
+ try {
192
+ const allCreds = [];
193
+ for (const type of AI_PROVIDER_TYPES) {
194
+ const creds = await credentialsDb.getCredentials(req.user.id, type);
195
+ allCreds.push(...creds.map(c => ({
196
+ id: c.id,
197
+ credential_name: c.credential_name,
198
+ credential_type: c.credential_type,
199
+ description: c.description,
200
+ is_active: c.is_active,
201
+ created_at: c.created_at,
202
+ // Mask the key — show first 8 and last 4 chars
203
+ masked_value: c.credential_value
204
+ ? c.credential_value.slice(0, 8) + '...' + c.credential_value.slice(-4)
205
+ : '***',
206
+ })));
207
+ }
208
+ res.json({ providers: allCreds });
209
+ } catch (error) {
210
+ res.status(500).json({ error: 'Failed to fetch AI provider keys' });
211
+ }
212
+ });
213
+
214
+ // Save an AI provider key
215
+ router.post('/ai-providers', async (req, res) => {
216
+ try {
217
+ const { providerType, apiKey, name } = req.body;
218
+
219
+ if (!providerType || !AI_PROVIDER_TYPES.includes(providerType)) {
220
+ return res.status(400).json({ error: `Invalid provider type. Supported: ${AI_PROVIDER_TYPES.join(', ')}` });
221
+ }
222
+ if (!apiKey || !apiKey.trim()) {
223
+ return res.status(400).json({ error: 'API key is required' });
224
+ }
225
+ if (apiKey.trim().length < 10 || apiKey.trim().length > 256) {
226
+ return res.status(400).json({ error: 'Invalid API key length' });
227
+ }
228
+
229
+ const label = providerType.replace('_key', '').replace('_', ' ');
230
+ const credName = name?.trim() || `${label} API key`;
231
+
232
+ // Deactivate existing keys of same type (user should only have one active per provider)
233
+ const existing = await credentialsDb.getCredentials(req.user.id, providerType);
234
+ for (const cred of existing) {
235
+ if (cred.is_active) {
236
+ await credentialsDb.toggleCredential(req.user.id, cred.id, false);
237
+ }
238
+ }
239
+
240
+ const result = await credentialsDb.createCredential(
241
+ req.user.id,
242
+ credName,
243
+ providerType,
244
+ apiKey.trim(),
245
+ `User-provided ${label} API key`
246
+ );
247
+
248
+ res.json({ success: true, credential: { id: result.id, credential_type: providerType, credential_name: credName } });
249
+ } catch (error) {
250
+ res.status(500).json({ error: 'Failed to save AI provider key' });
251
+ }
252
+ });
253
+
254
+ // Delete an AI provider key
255
+ router.delete('/ai-providers/:credentialId', async (req, res) => {
256
+ try {
257
+ const { credentialId } = req.params;
258
+ const success = await credentialsDb.deleteCredential(req.user.id, parseInt(credentialId));
259
+ if (success) {
260
+ res.json({ success: true });
261
+ } else {
262
+ res.status(404).json({ error: 'Provider key not found' });
263
+ }
264
+ } catch (error) {
265
+ res.status(500).json({ error: 'Failed to delete provider key' });
266
+ }
267
+ });
268
+
178
269
  export default router;