@pixelbyte-software/pixcode 1.51.2 → 1.51.4

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 (331) hide show
  1. package/CODE_OF_CONDUCT.md +41 -41
  2. package/CONTRIBUTING.md +155 -155
  3. package/LICENSE +718 -718
  4. package/README.de.md +169 -169
  5. package/README.ja.md +167 -167
  6. package/README.ko.md +167 -167
  7. package/README.md +419 -419
  8. package/README.ru.md +169 -169
  9. package/README.tr.md +298 -298
  10. package/README.zh-CN.md +167 -167
  11. package/SECURITY.md +46 -46
  12. package/dist/api-automation.html +110 -110
  13. package/dist/api-docs.html +548 -548
  14. package/dist/assets/index-B9N-gfOQ.css +32 -0
  15. package/dist/assets/{index-EN9ngyxf.js → index-HfGHXhD6.js} +175 -175
  16. package/dist/clear-cache.html +85 -85
  17. package/dist/convert-icons.md +52 -52
  18. package/dist/docs.html +308 -308
  19. package/dist/favicon.svg +8 -8
  20. package/dist/features.html +133 -133
  21. package/dist/generate-icons.js +48 -48
  22. package/dist/humans.txt +15 -15
  23. package/dist/icons/codex-white.svg +3 -3
  24. package/dist/icons/codex.svg +3 -3
  25. package/dist/icons/cursor-white.svg +11 -11
  26. package/dist/icons/icon-128x128.svg +9 -9
  27. package/dist/icons/icon-144x144.svg +9 -9
  28. package/dist/icons/icon-152x152.svg +9 -9
  29. package/dist/icons/icon-192x192.svg +9 -9
  30. package/dist/icons/icon-384x384.svg +9 -9
  31. package/dist/icons/icon-512x512.svg +9 -9
  32. package/dist/icons/icon-72x72.svg +9 -9
  33. package/dist/icons/icon-96x96.svg +9 -9
  34. package/dist/icons/icon-template.svg +9 -9
  35. package/dist/icons/qwen-logo.svg +14 -14
  36. package/dist/index.html +59 -59
  37. package/dist/landing.html +268 -268
  38. package/dist/llms-full.txt +119 -119
  39. package/dist/llms.txt +53 -53
  40. package/dist/logo.svg +12 -12
  41. package/dist/manifest.json +60 -60
  42. package/dist/openapi.yaml +1696 -1696
  43. package/dist/orchestration.html +125 -125
  44. package/dist/robots.txt +4 -4
  45. package/dist/site.css +692 -692
  46. package/dist/sitemap.xml +51 -51
  47. package/dist/sw.js +132 -132
  48. package/dist-server/server/cli.js +96 -96
  49. package/dist-server/server/daemon/manager.js +33 -33
  50. package/dist-server/server/daemon-manager.js +64 -64
  51. package/dist-server/server/database/db.js +14 -2
  52. package/dist-server/server/database/db.js.map +1 -1
  53. package/dist-server/server/index.js +191 -31
  54. package/dist-server/server/index.js.map +1 -1
  55. package/dist-server/server/middleware/auth.js +16 -5
  56. package/dist-server/server/middleware/auth.js.map +1 -1
  57. package/dist-server/server/modules/orchestration/a2a/adapters/json-event.adapter.js +84 -0
  58. package/dist-server/server/modules/orchestration/a2a/adapters/json-event.adapter.js.map +1 -0
  59. package/dist-server/server/modules/orchestration/a2a/adapters/json-event.adapter.test.js +43 -0
  60. package/dist-server/server/modules/orchestration/a2a/adapters/json-event.adapter.test.js.map +1 -0
  61. package/dist-server/server/modules/orchestration/hermes/hermes.routes.js +55 -1
  62. package/dist-server/server/modules/orchestration/hermes/hermes.routes.js.map +1 -1
  63. package/dist-server/server/modules/orchestration/index.js +1 -0
  64. package/dist-server/server/modules/orchestration/index.js.map +1 -1
  65. package/dist-server/server/routes/auth.js +12 -5
  66. package/dist-server/server/routes/auth.js.map +1 -1
  67. package/dist-server/server/routes/commands.js +25 -25
  68. package/dist-server/server/routes/git.js +29 -17
  69. package/dist-server/server/routes/git.js.map +1 -1
  70. package/dist-server/server/routes/live-view.js +46 -46
  71. package/dist-server/server/routes/platformization.js +7 -6
  72. package/dist-server/server/routes/platformization.js.map +1 -1
  73. package/dist-server/server/services/hermes-gateway.js +310 -0
  74. package/dist-server/server/services/hermes-gateway.js.map +1 -1
  75. package/dist-server/server/services/platformization.js +58 -2
  76. package/dist-server/server/services/platformization.js.map +1 -1
  77. package/dist-server/server/services/public-api-manifest.js +59 -51
  78. package/dist-server/server/services/public-api-manifest.js.map +1 -1
  79. package/package.json +222 -222
  80. package/scripts/fix-node-pty.js +67 -67
  81. package/scripts/github/create-v1.38-issues.mjs +351 -351
  82. package/scripts/github/create-vscode-workbench-issues.mjs +121 -121
  83. package/scripts/hermes/configure-pixcode-mcp.mjs +165 -163
  84. package/scripts/hermes/pixcode-mcp-server.mjs +1009 -958
  85. package/scripts/smoke/changes-panel-layout.mjs +48 -48
  86. package/scripts/smoke/chat-composer-fixed-layout.mjs +55 -55
  87. package/scripts/smoke/chat-message-timeline-order.mjs +41 -41
  88. package/scripts/smoke/chat-realtime-hydration.mjs +44 -44
  89. package/scripts/smoke/chat-session-provider-pools.mjs +35 -35
  90. package/scripts/smoke/chat-session-state.mjs +19 -19
  91. package/scripts/smoke/code-editor-theme.mjs +55 -55
  92. package/scripts/smoke/code-editor-vscode-engine.mjs +91 -91
  93. package/scripts/smoke/command-center-agent-writes.mjs +79 -79
  94. package/scripts/smoke/command-center-non-git.mjs +46 -46
  95. package/scripts/smoke/context-packet.mjs +43 -43
  96. package/scripts/smoke/control-room-ux-redesign.mjs +91 -91
  97. package/scripts/smoke/daemon-entrypoint.mjs +20 -20
  98. package/scripts/smoke/default-landing-routing.mjs +33 -33
  99. package/scripts/smoke/desktop-native-notifications.mjs +30 -30
  100. package/scripts/smoke/desktop-tray-icon.mjs +33 -33
  101. package/scripts/smoke/discord-release-workflow.mjs +24 -24
  102. package/scripts/smoke/git-install-update.mjs +255 -255
  103. package/scripts/smoke/handoff-artifact-protocol.mjs +50 -50
  104. package/scripts/smoke/hermes-api-install.mjs +56 -56
  105. package/scripts/smoke/hermes-gateway-persistence.mjs +104 -104
  106. package/scripts/smoke/hermes-mcp-pixcode-roundtrip.mjs +426 -367
  107. package/scripts/smoke/hermes-rest-chat-api.mjs +162 -162
  108. package/scripts/smoke/hermes-rest-chat-live.mjs +45 -45
  109. package/scripts/smoke/hermes-rest-codex-launch.mjs +209 -209
  110. package/scripts/smoke/hermes-rest-gateway.mjs +79 -70
  111. package/scripts/smoke/hermes-rest-live.mjs +42 -42
  112. package/scripts/smoke/hermes-roundtrip.mjs +167 -167
  113. package/scripts/smoke/hermes-settings-commands.mjs +349 -346
  114. package/scripts/smoke/hermes-smoke-launcher-guard.mjs +34 -34
  115. package/scripts/smoke/live-view-diagnostics.mjs +53 -53
  116. package/scripts/smoke/live-view-environment.mjs +92 -92
  117. package/scripts/smoke/live-view-integration.mjs +450 -450
  118. package/scripts/smoke/mac-desktop-runtime.mjs +37 -37
  119. package/scripts/smoke/mobile-tunnel-guidance.mjs +29 -29
  120. package/scripts/smoke/model-registry.mjs +36 -36
  121. package/scripts/smoke/multi-project-ui.mjs +45 -45
  122. package/scripts/smoke/multi-worker-slots.mjs +42 -42
  123. package/scripts/smoke/notification-center.mjs +87 -87
  124. package/scripts/smoke/notification-inapp-preference.mjs +23 -23
  125. package/scripts/smoke/notification-taxonomy.mjs +58 -58
  126. package/scripts/smoke/orchestration-api.mjs +172 -172
  127. package/scripts/smoke/orchestration-execution-dashboard.mjs +33 -33
  128. package/scripts/smoke/orchestration-live-run.mjs +176 -176
  129. package/scripts/smoke/orchestration-mobile-scroll.mjs +29 -29
  130. package/scripts/smoke/orchestration-model-sync.mjs +30 -30
  131. package/scripts/smoke/orchestration-permission-fallback.mjs +34 -34
  132. package/scripts/smoke/orchestration-runtime-guards.mjs +48 -48
  133. package/scripts/smoke/orchestration-user-facing-output.mjs +25 -25
  134. package/scripts/smoke/permission-policy.mjs +50 -50
  135. package/scripts/smoke/pixcode-workbench-1-48.mjs +167 -167
  136. package/scripts/smoke/provider-models-opencode-live.mjs +66 -66
  137. package/scripts/smoke/provider-rest-api.mjs +124 -124
  138. package/scripts/smoke/provider-selection-status.mjs +52 -52
  139. package/scripts/smoke/run-state-refresh.mjs +52 -52
  140. package/scripts/smoke/runtime-manager.mjs +99 -99
  141. package/scripts/smoke/shell-manual-disconnect.mjs +30 -30
  142. package/scripts/smoke/side-panel-editor-layout.mjs +34 -34
  143. package/scripts/smoke/static-root-routing.mjs +21 -21
  144. package/scripts/smoke/strict-handoff-compact.mjs +60 -60
  145. package/scripts/smoke/taskmaster-config.mjs +24 -24
  146. package/scripts/smoke/taskmaster-execution-telegram.mjs +3 -3
  147. package/scripts/smoke/taskmaster-onboarding.mjs +3 -3
  148. package/scripts/smoke/taskmaster-run-graph.mjs +3 -3
  149. package/scripts/smoke/telegram-control.mjs +242 -242
  150. package/scripts/smoke/tunnel-persistence.mjs +56 -56
  151. package/scripts/smoke/update-issue-progress.mjs +69 -69
  152. package/scripts/smoke/update-ux.mjs +55 -55
  153. package/scripts/smoke/v138-completion.mjs +132 -132
  154. package/scripts/smoke/v138-desktop-release-hardening.mjs +69 -69
  155. package/scripts/smoke/v138-diagnostics.mjs +63 -63
  156. package/scripts/smoke/v138-issue-planner.mjs +33 -33
  157. package/scripts/smoke/v143-remote-control.mjs +76 -76
  158. package/scripts/smoke/v144-production-loop.mjs +47 -47
  159. package/scripts/smoke/v145-platformization.mjs +46 -46
  160. package/scripts/smoke/v146-control-room-ui.mjs +150 -150
  161. package/scripts/smoke/version-modal-autoshow.mjs +29 -29
  162. package/scripts/smoke/vscode-workbench-layout.mjs +63 -63
  163. package/scripts/smoke/vscode-workbench-polish.mjs +461 -436
  164. package/scripts/smoke/workflow-fallback-replay.mjs +56 -56
  165. package/scripts/smoke/workflow-templates.mjs +43 -43
  166. package/scripts/smoke/workflow-trace-timeline.mjs +46 -46
  167. package/scripts/update-git-install.mjs +293 -293
  168. package/server/claude-sdk.js +920 -920
  169. package/server/cli.js +1039 -1039
  170. package/server/constants/config.js +4 -4
  171. package/server/cursor-cli.js +344 -344
  172. package/server/daemon/manager.js +563 -563
  173. package/server/daemon-manager.js +964 -964
  174. package/server/database/db.js +908 -895
  175. package/server/database/json-store.js +197 -197
  176. package/server/gemini-cli.js +550 -550
  177. package/server/gemini-response-handler.js +79 -79
  178. package/server/index.js +201 -30
  179. package/server/load-env.js +35 -35
  180. package/server/middleware/auth.js +171 -156
  181. package/server/modules/orchestration/a2a/adapter-registry.ts +108 -108
  182. package/server/modules/orchestration/a2a/adapters/abstract-a2a.adapter.ts +63 -63
  183. package/server/modules/orchestration/a2a/adapters/claude-code.adapter.ts +286 -286
  184. package/server/modules/orchestration/a2a/adapters/codex.adapter.ts +244 -244
  185. package/server/modules/orchestration/a2a/adapters/cursor.adapter.ts +249 -249
  186. package/server/modules/orchestration/a2a/adapters/gemini.adapter.ts +248 -248
  187. package/server/modules/orchestration/a2a/adapters/json-event.adapter.test.ts +60 -0
  188. package/server/modules/orchestration/a2a/adapters/json-event.adapter.ts +101 -0
  189. package/server/modules/orchestration/a2a/adapters/opencode.adapter.ts +248 -248
  190. package/server/modules/orchestration/a2a/adapters/qwen.adapter.ts +248 -248
  191. package/server/modules/orchestration/a2a/agent-card.ts +55 -55
  192. package/server/modules/orchestration/a2a/routes.ts +590 -590
  193. package/server/modules/orchestration/a2a/task-store.ts +178 -178
  194. package/server/modules/orchestration/a2a/types.ts +126 -126
  195. package/server/modules/orchestration/a2a/validator.ts +113 -113
  196. package/server/modules/orchestration/hermes/hermes.routes.ts +642 -583
  197. package/server/modules/orchestration/index.ts +101 -100
  198. package/server/modules/orchestration/preview/port-watcher.ts +112 -112
  199. package/server/modules/orchestration/preview/preview-proxy.ts +60 -60
  200. package/server/modules/orchestration/preview/types.ts +19 -19
  201. package/server/modules/orchestration/security/permission-policy.ts +401 -401
  202. package/server/modules/orchestration/tasks/orchestration-task-store.ts +41 -41
  203. package/server/modules/orchestration/tasks/orchestration-task.routes.ts +64 -64
  204. package/server/modules/orchestration/tasks/orchestration-task.service.ts +209 -209
  205. package/server/modules/orchestration/tasks/orchestration-task.types.ts +40 -40
  206. package/server/modules/orchestration/tasks/task-run-graph.ts +155 -155
  207. package/server/modules/orchestration/workflows/approval-queue.ts +106 -106
  208. package/server/modules/orchestration/workflows/built-in-workflows.ts +127 -127
  209. package/server/modules/orchestration/workflows/context-packet.ts +186 -186
  210. package/server/modules/orchestration/workflows/handoff-artifact.ts +175 -175
  211. package/server/modules/orchestration/workflows/workflow-fallback-policy.ts +161 -161
  212. package/server/modules/orchestration/workflows/workflow-replay.ts +254 -254
  213. package/server/modules/orchestration/workflows/workflow-runner.ts +2070 -2070
  214. package/server/modules/orchestration/workflows/workflow-store.ts +97 -97
  215. package/server/modules/orchestration/workflows/workflow-templates.ts +272 -272
  216. package/server/modules/orchestration/workflows/workflow-trace.ts +424 -424
  217. package/server/modules/orchestration/workflows/workflow.routes.ts +586 -586
  218. package/server/modules/orchestration/workflows/workflow.types.ts +111 -111
  219. package/server/modules/orchestration/workflows/workspace-target.ts +122 -122
  220. package/server/modules/orchestration/workspace/docker-workspace.ts +136 -136
  221. package/server/modules/orchestration/workspace/path-safety.ts +55 -55
  222. package/server/modules/orchestration/workspace/types.ts +52 -52
  223. package/server/modules/orchestration/workspace/workspace-manager.ts +102 -102
  224. package/server/modules/orchestration/workspace/worktree-workspace.ts +126 -126
  225. package/server/modules/providers/index.ts +2 -2
  226. package/server/modules/providers/list/claude/claude-auth.provider.ts +146 -146
  227. package/server/modules/providers/list/claude/claude-mcp.provider.ts +135 -135
  228. package/server/modules/providers/list/claude/claude-sessions.provider.ts +306 -306
  229. package/server/modules/providers/list/claude/claude.provider.ts +15 -15
  230. package/server/modules/providers/list/codex/codex-auth.provider.ts +117 -117
  231. package/server/modules/providers/list/codex/codex-mcp.provider.ts +135 -135
  232. package/server/modules/providers/list/codex/codex-sessions.provider.ts +319 -319
  233. package/server/modules/providers/list/codex/codex.provider.ts +15 -15
  234. package/server/modules/providers/list/cursor/cursor-auth.provider.ts +147 -147
  235. package/server/modules/providers/list/cursor/cursor-mcp.provider.ts +108 -108
  236. package/server/modules/providers/list/cursor/cursor-sessions.provider.ts +421 -421
  237. package/server/modules/providers/list/cursor/cursor.provider.ts +15 -15
  238. package/server/modules/providers/list/gemini/gemini-auth.provider.ts +173 -173
  239. package/server/modules/providers/list/gemini/gemini-mcp.provider.ts +110 -110
  240. package/server/modules/providers/list/gemini/gemini-sessions.provider.ts +227 -227
  241. package/server/modules/providers/list/gemini/gemini.provider.ts +15 -15
  242. package/server/modules/providers/list/opencode/opencode-auth.provider.ts +131 -131
  243. package/server/modules/providers/list/opencode/opencode-mcp.provider.ts +126 -126
  244. package/server/modules/providers/list/opencode/opencode-sessions.provider.ts +286 -286
  245. package/server/modules/providers/list/opencode/opencode.provider.ts +29 -29
  246. package/server/modules/providers/list/qwen/qwen-auth.provider.ts +146 -146
  247. package/server/modules/providers/list/qwen/qwen-mcp.provider.ts +114 -114
  248. package/server/modules/providers/list/qwen/qwen-sessions.provider.ts +265 -265
  249. package/server/modules/providers/list/qwen/qwen.provider.ts +21 -21
  250. package/server/modules/providers/provider.registry.ts +40 -40
  251. package/server/modules/providers/provider.routes.ts +944 -944
  252. package/server/modules/providers/services/mcp.service.ts +86 -86
  253. package/server/modules/providers/services/provider-auth.service.ts +26 -26
  254. package/server/modules/providers/services/sessions.service.ts +45 -45
  255. package/server/modules/providers/shared/base/abstract.provider.ts +20 -20
  256. package/server/modules/providers/shared/mcp/mcp.provider.ts +151 -151
  257. package/server/modules/providers/shared/provider-configs.ts +142 -142
  258. package/server/modules/providers/tests/mcp.test.ts +293 -293
  259. package/server/openai-codex.js +462 -462
  260. package/server/opencode-cli.js +491 -491
  261. package/server/opencode-response-handler.js +111 -111
  262. package/server/projects.js +3008 -3008
  263. package/server/qwen-code-cli.js +410 -410
  264. package/server/qwen-response-handler.js +73 -73
  265. package/server/routes/agent.js +1435 -1435
  266. package/server/routes/auth.js +154 -146
  267. package/server/routes/codex.js +20 -20
  268. package/server/routes/commands.js +570 -570
  269. package/server/routes/cursor.js +61 -61
  270. package/server/routes/diagnostics.js +41 -41
  271. package/server/routes/gemini.js +25 -25
  272. package/server/routes/git.js +1650 -1635
  273. package/server/routes/live-view.js +411 -411
  274. package/server/routes/mcp-utils.js +13 -13
  275. package/server/routes/messages.js +62 -62
  276. package/server/routes/network.js +125 -125
  277. package/server/routes/platformization.js +198 -197
  278. package/server/routes/plugins.js +320 -320
  279. package/server/routes/production-agent-loop.js +90 -90
  280. package/server/routes/projects.js +917 -917
  281. package/server/routes/public-api.js +34 -34
  282. package/server/routes/qwen.js +27 -27
  283. package/server/routes/remote.js +55 -55
  284. package/server/routes/settings.js +321 -321
  285. package/server/routes/telegram.js +140 -140
  286. package/server/routes/user.js +125 -125
  287. package/server/routes/webhooks.js +63 -63
  288. package/server/services/control-room.js +102 -102
  289. package/server/services/diagnostics.js +165 -165
  290. package/server/services/external-access.js +375 -375
  291. package/server/services/hermes-gateway.js +1562 -1247
  292. package/server/services/hermes-install-jobs.js +729 -729
  293. package/server/services/install-jobs.js +715 -715
  294. package/server/services/live-view.js +956 -956
  295. package/server/services/managed-runtimes.js +493 -493
  296. package/server/services/model-registry.js +144 -144
  297. package/server/services/notification-orchestrator.js +365 -365
  298. package/server/services/notification-taxonomy.js +204 -204
  299. package/server/services/platformization.js +844 -779
  300. package/server/services/production-agent-loop.js +248 -248
  301. package/server/services/provider-cli-versions.js +149 -149
  302. package/server/services/provider-credentials.js +189 -189
  303. package/server/services/provider-models.js +396 -396
  304. package/server/services/public-api-manifest.js +190 -182
  305. package/server/services/remote-connection.js +127 -127
  306. package/server/services/runtime-manager.js +323 -323
  307. package/server/services/startup-update.js +234 -234
  308. package/server/services/telegram/bot.js +331 -331
  309. package/server/services/telegram/control-center.js +979 -979
  310. package/server/services/telegram/telegram-http-client.js +151 -151
  311. package/server/services/telegram/translations.js +340 -340
  312. package/server/services/vapid-keys.js +36 -36
  313. package/server/services/webhooks.js +216 -216
  314. package/server/sessionManager.js +225 -225
  315. package/server/shared/interfaces.ts +54 -54
  316. package/server/shared/types.ts +172 -172
  317. package/server/shared/utils.ts +193 -193
  318. package/server/tsconfig.json +36 -36
  319. package/server/utils/colors.js +21 -21
  320. package/server/utils/commandParser.js +305 -305
  321. package/server/utils/frontmatter.js +18 -18
  322. package/server/utils/gitConfig.js +34 -34
  323. package/server/utils/plugin-loader.js +457 -457
  324. package/server/utils/plugin-process-manager.js +185 -185
  325. package/server/utils/port-access.js +209 -209
  326. package/server/utils/runtime-paths.js +37 -37
  327. package/server/utils/url-detection.js +71 -71
  328. package/server/vite-daemon.js +79 -79
  329. package/shared/modelConstants.js +161 -161
  330. package/shared/networkHosts.js +22 -22
  331. package/dist/assets/index-DMz0zv6T.css +0 -32
@@ -1,944 +1,944 @@
1
- /* eslint-disable import-x/order */
2
- import express, { type Request, type Response } from 'express';
3
-
4
- import { providerAuthService } from '@/modules/providers/services/provider-auth.service.js';
5
- import { providerMcpService } from '@/modules/providers/services/mcp.service.js';
6
-
7
- // @ts-ignore — plain-JS service, typed via inference
8
- import {
9
- applyProviderCredentialsToEnv,
10
- listProviderCredentialSummaries,
11
- setProviderCredentials,
12
- PROVIDER_ENV_VARS,
13
- } from '@/services/provider-credentials.js';
14
-
15
- // @ts-ignore — plain-JS service
16
- import {
17
- clearProviderModelRegistryCache,
18
- getProviderModelRegistryEntry,
19
- } from '@/services/model-registry.js';
20
- // @ts-ignore — plain-JS service
21
- import { getProviderCliVersionStatus } from '@/services/provider-cli-versions.js';
22
-
23
- // @ts-ignore — plain-JS service
24
- import {
25
- createInstallJob,
26
- getInstallJob,
27
- cancelInstallJob,
28
- snapshotDonePayload,
29
- } from '@/services/install-jobs.js';
30
-
31
-
32
- // @ts-ignore — plain-JS shared module
33
- import {
34
- MAX_CONFIG_FILE_SIZE_BYTES,
35
- PROVIDER_CONFIG_FILES,
36
- type ProviderConfigFile,
37
- } from '@/modules/providers/shared/provider-configs.js';
38
- import { AppError, asyncHandler, createApiSuccessResponse } from '@/shared/utils.js';
39
- import type { LLMProvider, McpScope, McpTransport, UpsertProviderMcpServerInput } from '@/shared/types.js';
40
-
41
- import fs from 'node:fs/promises';
42
- import http from 'node:http';
43
- import os from 'node:os';
44
- import path from 'node:path';
45
-
46
-
47
- /**
48
- * npm-global install command per provider. Used by POST
49
- * /api/providers/:p/install to run the install directly from Pixcode so
50
- * users don't have to drop into a shell just to get a CLI on the host.
51
- * Cursor uses its own install script, not npm.
52
- */
53
- /**
54
- * npm package name per provider. The in-app installer drops these into
55
- * ~/.pixcode/cli-bin/ as LOCAL deps (no -g, no sudo). A sibling string
56
- * for display ("npm install -g …") is surfaced in the UI so users who
57
- * prefer to install manually still see a recognizable command.
58
- */
59
- const PROVIDER_INSTALL_PACKAGES: Record<LLMProvider, string | null> = {
60
- claude: '@anthropic-ai/claude-code',
61
- codex: '@openai/codex',
62
- gemini: '@google/gemini-cli',
63
- qwen: '@qwen-code/qwen-code',
64
- opencode: 'opencode-ai',
65
- // Cursor ships via a bash script hosted at cursor.com; safer to ask
66
- // users to run it themselves than to pipe-to-bash from our server.
67
- cursor: null,
68
- };
69
-
70
- const PROVIDER_INSTALL_COMMANDS: Record<LLMProvider, string | null> = {
71
- claude: 'npm install -g @anthropic-ai/claude-code',
72
- codex: 'npm install -g @openai/codex',
73
- gemini: 'npm install -g @google/gemini-cli',
74
- qwen: 'npm install -g @qwen-code/qwen-code',
75
- opencode: 'npm install -g opencode-ai',
76
- cursor: null,
77
- };
78
-
79
- /**
80
- * Per-provider manual install hints, surfaced when `/install` is called
81
- * for a provider Pixcode can't sandbox-install (anything not on npm).
82
- * Each entry includes platform-specific commands so the UI can show the
83
- * right one for the user's host. Cursor is the only provider in this
84
- * bucket today — it ships via curl|bash on POSIX and a downloadable
85
- * installer on Windows. We deliberately don't pipe-to-bash from Pixcode,
86
- * so the user runs it themselves.
87
- */
88
- const PROVIDER_MANUAL_INSTALL: Partial<Record<LLMProvider, {
89
- docsUrl: string;
90
- steps: { platform: 'macos' | 'linux' | 'windows'; command: string }[];
91
- note: string;
92
- }>> = {
93
- cursor: {
94
- docsUrl: 'https://docs.cursor.com/en/cli/installation',
95
- steps: [
96
- { platform: 'macos', command: 'curl https://cursor.com/install -fsS | bash' },
97
- { platform: 'linux', command: 'curl https://cursor.com/install -fsS | bash' },
98
- { platform: 'windows', command: 'iwr https://cursor.com/install.ps1 -useb | iex' },
99
- ],
100
- note: 'Cursor ships outside npm — run the command for your platform in a separate terminal, then click "Refresh" on this page once the binary is on PATH.',
101
- },
102
- };
103
-
104
- const router = express.Router();
105
-
106
- const readPathParam = (value: unknown, name: string): string => {
107
- if (typeof value === 'string') {
108
- return value;
109
- }
110
-
111
- if (Array.isArray(value) && typeof value[0] === 'string') {
112
- return value[0];
113
- }
114
-
115
- throw new AppError(`${name} path parameter is invalid.`, {
116
- code: 'INVALID_PATH_PARAMETER',
117
- statusCode: 400,
118
- });
119
- };
120
-
121
- const normalizeProviderParam = (value: unknown): string =>
122
- readPathParam(value, 'provider').trim().toLowerCase();
123
-
124
- const readOptionalQueryString = (value: unknown): string | undefined => {
125
- if (typeof value !== 'string') {
126
- return undefined;
127
- }
128
-
129
- const normalized = value.trim();
130
- return normalized.length > 0 ? normalized : undefined;
131
- };
132
-
133
- const parseMcpScope = (value: unknown): McpScope | undefined => {
134
- if (value === undefined) {
135
- return undefined;
136
- }
137
-
138
- const normalized = readOptionalQueryString(value);
139
- if (!normalized) {
140
- return undefined;
141
- }
142
-
143
- if (normalized === 'user' || normalized === 'local' || normalized === 'project') {
144
- return normalized;
145
- }
146
-
147
- throw new AppError(`Unsupported MCP scope "${normalized}".`, {
148
- code: 'INVALID_MCP_SCOPE',
149
- statusCode: 400,
150
- });
151
- };
152
-
153
- const parseMcpTransport = (value: unknown): McpTransport => {
154
- const normalized = readOptionalQueryString(value);
155
- if (!normalized) {
156
- throw new AppError('transport is required.', {
157
- code: 'MCP_TRANSPORT_REQUIRED',
158
- statusCode: 400,
159
- });
160
- }
161
-
162
- if (normalized === 'stdio' || normalized === 'http' || normalized === 'sse') {
163
- return normalized;
164
- }
165
-
166
- throw new AppError(`Unsupported MCP transport "${normalized}".`, {
167
- code: 'INVALID_MCP_TRANSPORT',
168
- statusCode: 400,
169
- });
170
- };
171
-
172
- const parseMcpUpsertPayload = (payload: unknown): UpsertProviderMcpServerInput => {
173
- if (!payload || typeof payload !== 'object') {
174
- throw new AppError('Request body must be an object.', {
175
- code: 'INVALID_REQUEST_BODY',
176
- statusCode: 400,
177
- });
178
- }
179
-
180
- const body = payload as Record<string, unknown>;
181
- const name = readOptionalQueryString(body.name);
182
- if (!name) {
183
- throw new AppError('name is required.', {
184
- code: 'MCP_NAME_REQUIRED',
185
- statusCode: 400,
186
- });
187
- }
188
-
189
- const transport = parseMcpTransport(body.transport);
190
- const scope = parseMcpScope(body.scope);
191
- const workspacePath = readOptionalQueryString(body.workspacePath);
192
-
193
- return {
194
- name,
195
- transport,
196
- scope,
197
- workspacePath,
198
- command: readOptionalQueryString(body.command),
199
- args: Array.isArray(body.args) ? body.args.filter((entry): entry is string => typeof entry === 'string') : undefined,
200
- env: typeof body.env === 'object' && body.env !== null
201
- ? Object.fromEntries(
202
- Object.entries(body.env as Record<string, unknown>).filter(
203
- (entry): entry is [string, string] => typeof entry[1] === 'string',
204
- ),
205
- )
206
- : undefined,
207
- cwd: readOptionalQueryString(body.cwd),
208
- url: readOptionalQueryString(body.url),
209
- headers: typeof body.headers === 'object' && body.headers !== null
210
- ? Object.fromEntries(
211
- Object.entries(body.headers as Record<string, unknown>).filter(
212
- (entry): entry is [string, string] => typeof entry[1] === 'string',
213
- ),
214
- )
215
- : undefined,
216
- envVars: Array.isArray(body.envVars)
217
- ? body.envVars.filter((entry): entry is string => typeof entry === 'string')
218
- : undefined,
219
- bearerTokenEnvVar: readOptionalQueryString(body.bearerTokenEnvVar),
220
- envHttpHeaders: typeof body.envHttpHeaders === 'object' && body.envHttpHeaders !== null
221
- ? Object.fromEntries(
222
- Object.entries(body.envHttpHeaders as Record<string, unknown>).filter(
223
- (entry): entry is [string, string] => typeof entry[1] === 'string',
224
- ),
225
- )
226
- : undefined,
227
- };
228
- };
229
-
230
- const parseProvider = (value: unknown): LLMProvider => {
231
- const normalized = normalizeProviderParam(value);
232
- if (
233
- normalized === 'claude' ||
234
- normalized === 'codex' ||
235
- normalized === 'cursor' ||
236
- normalized === 'gemini' ||
237
- normalized === 'qwen' ||
238
- normalized === 'opencode'
239
- ) {
240
- return normalized;
241
- }
242
-
243
- throw new AppError(`Unsupported provider "${normalized}".`, {
244
- code: 'UNSUPPORTED_PROVIDER',
245
- statusCode: 400,
246
- });
247
- };
248
-
249
- router.get(
250
- '/:provider/auth/status',
251
- asyncHandler(async (req: Request, res: Response) => {
252
- const provider = parseProvider(req.params.provider);
253
- const forceRefresh = String(req.query.refresh || '').toLowerCase() === '1';
254
- const status = await providerAuthService.getProviderAuthStatus(provider);
255
- const cliVersion = await getProviderCliVersionStatus(provider, {
256
- installed: status.installed,
257
- forceRefresh,
258
- });
259
- res.json(createApiSuccessResponse({ ...status, ...cliVersion }));
260
- }),
261
- );
262
-
263
- router.get(
264
- '/:provider/mcp/servers',
265
- asyncHandler(async (req: Request, res: Response) => {
266
- const provider = parseProvider(req.params.provider);
267
- const workspacePath = readOptionalQueryString(req.query.workspacePath);
268
- const scope = parseMcpScope(req.query.scope);
269
-
270
- if (scope) {
271
- const servers = await providerMcpService.listProviderMcpServersForScope(provider, scope, { workspacePath });
272
- res.json(createApiSuccessResponse({ provider, scope, servers }));
273
- return;
274
- }
275
-
276
- const groupedServers = await providerMcpService.listProviderMcpServers(provider, { workspacePath });
277
- res.json(createApiSuccessResponse({ provider, scopes: groupedServers }));
278
- }),
279
- );
280
-
281
- router.post(
282
- '/:provider/mcp/servers',
283
- asyncHandler(async (req: Request, res: Response) => {
284
- const provider = parseProvider(req.params.provider);
285
- const payload = parseMcpUpsertPayload(req.body);
286
- const server = await providerMcpService.upsertProviderMcpServer(provider, payload);
287
- res.status(201).json(createApiSuccessResponse({ server }));
288
- }),
289
- );
290
-
291
- router.delete(
292
- '/:provider/mcp/servers/:name',
293
- asyncHandler(async (req: Request, res: Response) => {
294
- const provider = parseProvider(req.params.provider);
295
- const scope = parseMcpScope(req.query.scope);
296
- const workspacePath = readOptionalQueryString(req.query.workspacePath);
297
- const result = await providerMcpService.removeProviderMcpServer(provider, {
298
- name: readPathParam(req.params.name, 'name'),
299
- scope,
300
- workspacePath,
301
- });
302
- res.json(createApiSuccessResponse(result));
303
- }),
304
- );
305
-
306
- /**
307
- * GET /api/providers/credentials
308
- * Summary for every provider (hasKey + baseUrl + updatedAt). Used by the
309
- * Settings UI to pre-fill the "API Key" tab.
310
- */
311
- router.get(
312
- '/credentials',
313
- asyncHandler(async (_req: Request, res: Response) => {
314
- const summaries = await listProviderCredentialSummaries();
315
- res.json(createApiSuccessResponse(summaries));
316
- }),
317
- );
318
-
319
- /**
320
- * POST /api/providers/:provider/auth/api-key
321
- * Body: { apiKey: string, baseUrl?: string }. Stores the credentials in
322
- * ~/.pixcode/provider-credentials.json and applies them to process.env
323
- * so the next CLI spawn/SDK call picks them up. Empty apiKey clears.
324
- */
325
- router.post(
326
- '/:provider/auth/api-key',
327
- asyncHandler(async (req: Request, res: Response) => {
328
- const provider = parseProvider(req.params.provider);
329
- if (!(provider in PROVIDER_ENV_VARS)) {
330
- throw new AppError(`Provider "${provider}" does not accept API-key auth.`, {
331
- code: 'PROVIDER_NO_API_KEY',
332
- statusCode: 400,
333
- });
334
- }
335
- const body = (req.body ?? {}) as Record<string, unknown>;
336
- const apiKey = typeof body.apiKey === 'string' ? body.apiKey : '';
337
- const baseUrl = typeof body.baseUrl === 'string' ? body.baseUrl : '';
338
-
339
- await setProviderCredentials(provider, { apiKey, baseUrl });
340
- await applyProviderCredentialsToEnv(provider);
341
-
342
- res.json(createApiSuccessResponse({ provider, stored: Boolean(apiKey.trim()) }));
343
- }),
344
- );
345
-
346
- /**
347
- * POST /api/providers/:provider/oauth-paste
348
- * Body: { callbackUrl: string }.
349
- *
350
- * When the CLI starts an OAuth flow it spins up a local HTTP server on
351
- * 127.0.0.1:<PORT> and expects the OAuth provider to redirect the user's
352
- * browser to `http://127.0.0.1:<PORT>/callback?code=...`. On remote VPS
353
- * setups that redirect hits the user's laptop localhost (which has nothing
354
- * listening), not the server running the CLI. This endpoint is the escape
355
- * hatch: the user copies the dead callback URL from their browser and
356
- * posts it here; we parse out the port + code and forward the original
357
- * GET to the VPS-side 127.0.0.1:PORT so the CLI's local handler completes
358
- * the token exchange.
359
- */
360
- router.post(
361
- '/:provider/oauth-paste',
362
- asyncHandler(async (req: Request, res: Response) => {
363
- parseProvider(req.params.provider); // validate id but we don't use it further
364
- const body = (req.body ?? {}) as Record<string, unknown>;
365
- const raw = typeof body.callbackUrl === 'string' ? body.callbackUrl.trim() : '';
366
- if (!raw) {
367
- throw new AppError('callbackUrl is required.', {
368
- code: 'OAUTH_PASTE_URL_REQUIRED',
369
- statusCode: 400,
370
- });
371
- }
372
-
373
- let parsed: URL;
374
- try {
375
- parsed = new URL(raw);
376
- } catch {
377
- throw new AppError('callbackUrl must be a valid URL.', {
378
- code: 'OAUTH_PASTE_URL_INVALID',
379
- statusCode: 400,
380
- });
381
- }
382
-
383
- // Accept localhost / 127.0.0.1 callbacks — reject anything else so we
384
- // never proxy arbitrary outbound requests on behalf of a user.
385
- const host = parsed.hostname;
386
- if (host !== '127.0.0.1' && host !== 'localhost' && host !== '::1') {
387
- throw new AppError('Only local CLI callback URLs are accepted.', {
388
- code: 'OAUTH_PASTE_URL_NOT_LOCAL',
389
- statusCode: 400,
390
- });
391
- }
392
-
393
- const port = Number(parsed.port);
394
- if (!port || port < 1 || port > 65535) {
395
- throw new AppError('Callback URL must include the CLI callback port.', {
396
- code: 'OAUTH_PASTE_PORT_INVALID',
397
- statusCode: 400,
398
- });
399
- }
400
-
401
- const pathAndQuery = parsed.pathname + parsed.search;
402
- await new Promise<void>((resolve, reject) => {
403
- const forwardReq = http.request(
404
- {
405
- host: '127.0.0.1',
406
- port,
407
- method: 'GET',
408
- path: pathAndQuery,
409
- timeout: 10000,
410
- },
411
- (forwardRes) => {
412
- forwardRes.resume(); // drain
413
- forwardRes.on('end', () => resolve());
414
- },
415
- );
416
- forwardReq.on('timeout', () => {
417
- forwardReq.destroy(new Error('CLI callback server did not respond within 10s'));
418
- });
419
- forwardReq.on('error', (err) => reject(err));
420
- forwardReq.end();
421
- });
422
-
423
- res.json(createApiSuccessResponse({ forwarded: true, port }));
424
- }),
425
- );
426
-
427
- /**
428
- * GET /api/providers/:provider/models?refresh=1
429
- * Provider model registry entry: central static fallback + live API discovery
430
- * with freshness/degraded metadata. 6-hour cache; pass `refresh=1` to force an
431
- * upstream hit.
432
- */
433
- router.get(
434
- '/:provider/models',
435
- asyncHandler(async (req: Request, res: Response) => {
436
- const provider = parseProvider(req.params.provider);
437
- const forceRefresh = String(req.query.refresh || '').toLowerCase() === '1';
438
- const result = await getProviderModelRegistryEntry(provider, { forceRefresh });
439
- res.json(createApiSuccessResponse(result));
440
- }),
441
- );
442
-
443
- router.delete(
444
- '/:provider/models/cache',
445
- asyncHandler(async (req: Request, res: Response) => {
446
- const provider = parseProvider(req.params.provider);
447
- await clearProviderModelRegistryCache(provider);
448
- res.json(createApiSuccessResponse({ cleared: true, provider }));
449
- }),
450
- );
451
-
452
- /**
453
- * POST /api/providers/:provider/install
454
- * Kicks off the install in the background and immediately returns
455
- * `{ jobId }`. The actual log stream is fetched separately via
456
- * GET /install/:jobId/stream (EventSource). This split solves the
457
- * "Client disconnected before install finished" class of errors,
458
- * where a single long-lived POST SSE would get torn down by dev
459
- * proxies, service-worker reloads, or Vite HMR and short-circuit
460
- * an in-flight install. The child now outlives the request.
461
- */
462
- router.post(
463
- '/:provider/install',
464
- asyncHandler(async (req: Request, res: Response) => {
465
- const parsed = parseProvider(req.params.provider);
466
- const packageName = PROVIDER_INSTALL_PACKAGES[parsed];
467
- const installCmd = PROVIDER_INSTALL_COMMANDS[parsed];
468
- if (!packageName || !installCmd) {
469
- const manual = PROVIDER_MANUAL_INSTALL[parsed];
470
- // Don't 4xx on this — the call ISN'T malformed, the provider is
471
- // simply not npm-installable. Return 200 with a `manual` payload
472
- // the UI can present as instructions instead of "install failed".
473
- res.json(createApiSuccessResponse({
474
- provider: parsed,
475
- manual: manual || null,
476
- message: manual
477
- ? `${parsed} ships outside npm. Run the platform-specific command, then refresh.`
478
- : `${parsed} cannot be installed automatically — see the provider's documentation.`,
479
- }));
480
- return;
481
- }
482
-
483
- const job = createInstallJob({ provider: parsed, installCmd, packageName });
484
- res.json(createApiSuccessResponse({
485
- jobId: job.id,
486
- provider: parsed,
487
- installCmd,
488
- startedAt: job.startedAt,
489
- }));
490
- }),
491
- );
492
-
493
- /**
494
- * GET /api/providers/:provider/install/:jobId/stream
495
- * SSE endpoint (EventSource-friendly). Replays every buffered log line
496
- * to the new subscriber, then forwards live stdout/stderr until the
497
- * child exits. Clients can reconnect freely — reconnects replay from
498
- * the start, so you never miss output, even if the browser dropped
499
- * the previous connection while npm was mid-download.
500
- *
501
- * EventSource can't set custom headers, so this endpoint also accepts
502
- * ?token=... as a fallback auth channel (same pattern the search
503
- * endpoint uses).
504
- */
505
- router.get(
506
- '/:provider/install/:jobId/stream',
507
- asyncHandler(async (req: Request, res: Response) => {
508
- const parsed = parseProvider(req.params.provider);
509
- const jobId = readPathParam(req.params.jobId, 'jobId');
510
- const job = getInstallJob(jobId);
511
- if (!job || job.provider !== parsed) {
512
- throw new AppError('Install job not found or already expired.', {
513
- code: 'INSTALL_JOB_NOT_FOUND',
514
- statusCode: 404,
515
- });
516
- }
517
-
518
- res.setHeader('Content-Type', 'text/event-stream');
519
- res.setHeader('Cache-Control', 'no-cache, no-transform');
520
- res.setHeader('Connection', 'keep-alive');
521
- res.setHeader('X-Accel-Buffering', 'no');
522
- if (typeof res.flushHeaders === 'function') res.flushHeaders();
523
- try {
524
- (res.socket as NodeJS.Socket & { setNoDelay?: (on: boolean) => void })?.setNoDelay?.(true);
525
- } catch { /* noop */ }
526
-
527
- let closed = false;
528
- const write = (event: string, payload: unknown) => {
529
- if (closed) return;
530
- try {
531
- res.write(`event: ${event}\n`);
532
- res.write(`data: ${JSON.stringify(payload)}\n\n`);
533
- } catch { /* socket gone */ }
534
- };
535
-
536
- // Immediate primer + heartbeat, same as before — keeps intermediary
537
- // proxies from treating the connection as idle.
538
- try { res.write(': start\n\n'); } catch { /* noop */ }
539
- const heartbeat = setInterval(() => {
540
- if (closed) return;
541
- try { res.write(': ping\n\n'); } catch { /* noop */ }
542
- }, 5000);
543
-
544
- // Replay the buffered transcript first so late subscribers see
545
- // every line npm has already produced.
546
- for (const entry of job.logs) {
547
- write('log', { stream: entry.stream, chunk: entry.chunk });
548
- }
549
-
550
- const onLog = (entry: { stream: string; chunk: string }) => {
551
- write('log', { stream: entry.stream, chunk: entry.chunk });
552
- };
553
- const onDone = (payload: Record<string, unknown>) => {
554
- write('done', payload);
555
- cleanup();
556
- try { res.end(); } catch { /* noop */ }
557
- };
558
-
559
- const cleanup = () => {
560
- if (closed) return;
561
- closed = true;
562
- clearInterval(heartbeat);
563
- job.emitter.off('log', onLog);
564
- job.emitter.off('done', onDone);
565
- };
566
-
567
- if (job.status !== 'running') {
568
- // Job already finished — replay the terminal done frame and exit.
569
- write('done', snapshotDonePayload(job));
570
- cleanup();
571
- try { res.end(); } catch { /* noop */ }
572
- return;
573
- }
574
-
575
- job.emitter.on('log', onLog);
576
- job.emitter.once('done', onDone);
577
-
578
- req.on('close', () => {
579
- // Client walked away. DO NOT cancel the install — detaching is fine.
580
- cleanup();
581
- });
582
- }),
583
- );
584
-
585
- router.delete(
586
- '/:provider/install/:jobId',
587
- asyncHandler(async (req: Request, res: Response) => {
588
- const parsed = parseProvider(req.params.provider);
589
- const jobId = readPathParam(req.params.jobId, 'jobId');
590
- const job = getInstallJob(jobId);
591
- if (!job || job.provider !== parsed) {
592
- throw new AppError('Install job not found.', {
593
- code: 'INSTALL_JOB_NOT_FOUND',
594
- statusCode: 404,
595
- });
596
- }
597
- const cancelled = cancelInstallJob(jobId);
598
- res.json(createApiSuccessResponse({ cancelled }));
599
- }),
600
- );
601
-
602
- router.post(
603
- '/mcp/servers/global',
604
- asyncHandler(async (req: Request, res: Response) => {
605
- const payload = parseMcpUpsertPayload(req.body);
606
- if (payload.scope === 'local') {
607
- throw new AppError('Global MCP add supports only "user" or "project" scopes.', {
608
- code: 'INVALID_GLOBAL_MCP_SCOPE',
609
- statusCode: 400,
610
- });
611
- }
612
-
613
- const results = await providerMcpService.addMcpServerToAllProviders({
614
- ...payload,
615
- scope: payload.scope === 'user' ? 'user' : 'project',
616
- });
617
- res.status(201).json(createApiSuccessResponse({ results }));
618
- }),
619
- );
620
-
621
- // ============================================================================
622
- // Provider config files — read / edit the per-CLI settings/env files from
623
- // inside Pixcode rather than making the user open a text editor themselves.
624
- // The registry at server/modules/providers/shared/provider-configs.ts is the
625
- // single source of truth for which files exist; the client pulls this list
626
- // via GET /config-files and then reads/writes individual files by id.
627
- // ============================================================================
628
-
629
- // Resolve a config descriptor from (provider, fileId). Throws a 404
630
- // AppError if either isn't registered so the client sees a clear failure
631
- // instead of a generic 500.
632
- const resolveConfigFile = (provider: string, fileId: string): { descriptor: ProviderConfigFile; absolutePath: string } => {
633
- const list = PROVIDER_CONFIG_FILES[provider];
634
- if (!list) {
635
- throw new AppError(`No config files registered for provider "${provider}"`, {
636
- code: 'PROVIDER_CONFIG_UNKNOWN_PROVIDER',
637
- statusCode: 404,
638
- });
639
- }
640
- const descriptor = list.find((entry) => entry.id === fileId);
641
- if (!descriptor) {
642
- throw new AppError(`Unknown config file "${fileId}" for provider "${provider}"`, {
643
- code: 'PROVIDER_CONFIG_UNKNOWN_FILE',
644
- statusCode: 404,
645
- });
646
- }
647
- // Always resolve relative to the server's os.homedir() — we never trust
648
- // the client for any part of the path. `path.resolve` then normalises
649
- // out any `..` segments the registry might accidentally contain.
650
- const absolutePath = path.resolve(os.homedir(), descriptor.relativePath);
651
- return { descriptor, absolutePath };
652
- };
653
-
654
- const SENSITIVE_CONFIG_PATTERN = /(api[_-]?key|token|secret|password|authorization|bearer)\s*[:=]\s*["']?([^"'\n\r]+)/ig;
655
-
656
- function redactProviderConfigPreview(contents: string): string {
657
- return contents.replace(SENSITIVE_CONFIG_PATTERN, (_match, key) => `${key}: [redacted]`);
658
- }
659
-
660
- async function validateProviderConfigContents(descriptor: ProviderConfigFile, contents: string) {
661
- if (Buffer.byteLength(contents, 'utf8') > MAX_CONFIG_FILE_SIZE_BYTES) {
662
- throw new AppError(
663
- `Config contents exceed ${MAX_CONFIG_FILE_SIZE_BYTES} bytes`,
664
- { code: 'PROVIDER_CONFIG_TOO_LARGE', statusCode: 413 },
665
- );
666
- }
667
-
668
- if (descriptor.format === 'json') {
669
- try {
670
- JSON.parse(contents || '{}');
671
- } catch (err) {
672
- throw new AppError(`Invalid JSON: ${(err as Error).message}`, {
673
- code: 'PROVIDER_CONFIG_INVALID_JSON',
674
- statusCode: 400,
675
- });
676
- }
677
- }
678
-
679
- return {
680
- valid: true,
681
- format: descriptor.format,
682
- readonly: Boolean(descriptor.readonly),
683
- preview: redactProviderConfigPreview(contents).slice(0, 4000),
684
- };
685
- }
686
-
687
- async function buildProviderPluginState(provider: string) {
688
- const files = PROVIDER_CONFIG_FILES[provider] || [];
689
- const configs = await Promise.all(files.map(async (entry) => {
690
- const absolutePath = path.resolve(os.homedir(), entry.relativePath);
691
- let exists = false;
692
- let size: number | null = null;
693
- let updatedAt: string | null = null;
694
- let preview = '';
695
- try {
696
- const stat = await fs.stat(absolutePath);
697
- exists = stat.isFile();
698
- size = stat.size;
699
- updatedAt = stat.mtime.toISOString();
700
- if (exists && stat.size <= MAX_CONFIG_FILE_SIZE_BYTES) {
701
- preview = redactProviderConfigPreview(await fs.readFile(absolutePath, 'utf8')).slice(0, 1200);
702
- }
703
- } catch {
704
- // Missing config files are normal for CLIs that have not been used yet.
705
- }
706
-
707
- return {
708
- id: entry.id,
709
- label: entry.label,
710
- format: entry.format,
711
- readonly: Boolean(entry.readonly),
712
- relativePath: entry.relativePath,
713
- absolutePath,
714
- exists,
715
- size,
716
- updatedAt,
717
- preview,
718
- canBackup: exists,
719
- canValidate: entry.format === 'json' || entry.format === 'env' || entry.format === 'toml' || entry.format === 'text',
720
- };
721
- }));
722
-
723
- return {
724
- provider,
725
- supported: files.length > 0,
726
- configCount: files.length,
727
- installedCount: configs.filter((config) => config.exists).length,
728
- configs,
729
- };
730
- }
731
-
732
- router.get(
733
- '/plugin-state',
734
- asyncHandler(async (_req: Request, res: Response) => {
735
- const providers = await Promise.all(
736
- Object.keys(PROVIDER_CONFIG_FILES).map((provider) => buildProviderPluginState(provider)),
737
- );
738
- res.json(createApiSuccessResponse({ providers }));
739
- }),
740
- );
741
-
742
- router.get(
743
- '/plugin-state/:provider',
744
- asyncHandler(async (req: Request, res: Response) => {
745
- const provider = parseProvider(req.params.provider);
746
- res.json(createApiSuccessResponse(await buildProviderPluginState(provider)));
747
- }),
748
- );
749
-
750
- router.get(
751
- '/:provider/config-files',
752
- asyncHandler(async (req: Request, res: Response) => {
753
- const provider = String(req.params.provider);
754
- const list = PROVIDER_CONFIG_FILES[provider];
755
- if (!list) {
756
- throw new AppError(`No config files registered for provider "${provider}"`, {
757
- code: 'PROVIDER_CONFIG_UNKNOWN_PROVIDER',
758
- statusCode: 404,
759
- });
760
- }
761
- const files = await Promise.all(
762
- list.map(async (entry: ProviderConfigFile) => {
763
- const absolutePath = path.resolve(os.homedir(), entry.relativePath);
764
- let exists = false;
765
- let size: number | null = null;
766
- let updatedAt: string | null = null;
767
- try {
768
- const stat = await fs.stat(absolutePath);
769
- exists = stat.isFile();
770
- size = stat.size;
771
- updatedAt = stat.mtime.toISOString();
772
- } catch (err) {
773
- // ENOENT is the expected path for "user hasn't created this yet".
774
- // Anything else (EACCES, EISDIR, …) we surface as a hint rather
775
- // than blow up the whole list response.
776
- if ((err as NodeJS.ErrnoException).code !== 'ENOENT') {
777
- console.warn(`[provider-configs] stat ${absolutePath}:`, (err as Error).message);
778
- }
779
- }
780
- return {
781
- id: entry.id,
782
- label: entry.label,
783
- format: entry.format,
784
- readonly: Boolean(entry.readonly),
785
- description: entry.description ?? null,
786
- relativePath: entry.relativePath,
787
- absolutePath,
788
- exists,
789
- size,
790
- updatedAt,
791
- };
792
- }),
793
- );
794
- res.json(createApiSuccessResponse({ provider, files }));
795
- }),
796
- );
797
-
798
- router.get(
799
- '/:provider/config-files/:fileId',
800
- asyncHandler(async (req: Request, res: Response) => {
801
- const provider = String(req.params.provider);
802
- const fileId = String(req.params.fileId);
803
- const { descriptor, absolutePath } = resolveConfigFile(provider, fileId);
804
-
805
- try {
806
- const stat = await fs.stat(absolutePath);
807
- if (!stat.isFile()) {
808
- throw new AppError(`${absolutePath} is not a regular file`, {
809
- code: 'PROVIDER_CONFIG_NOT_FILE',
810
- statusCode: 409,
811
- });
812
- }
813
- if (stat.size > MAX_CONFIG_FILE_SIZE_BYTES) {
814
- throw new AppError(
815
- `Config file is larger than ${MAX_CONFIG_FILE_SIZE_BYTES} bytes — refusing to load`,
816
- { code: 'PROVIDER_CONFIG_TOO_LARGE', statusCode: 413 },
817
- );
818
- }
819
- const contents = await fs.readFile(absolutePath, 'utf8');
820
- res.json(createApiSuccessResponse({
821
- id: descriptor.id,
822
- label: descriptor.label,
823
- format: descriptor.format,
824
- readonly: Boolean(descriptor.readonly),
825
- relativePath: descriptor.relativePath,
826
- absolutePath,
827
- exists: true,
828
- size: stat.size,
829
- updatedAt: stat.mtime.toISOString(),
830
- contents,
831
- }));
832
- } catch (err) {
833
- if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
834
- // Report "file doesn't exist yet" with empty contents so the UI can
835
- // still open an editor and let the user create it with a save.
836
- res.json(createApiSuccessResponse({
837
- id: descriptor.id,
838
- label: descriptor.label,
839
- format: descriptor.format,
840
- readonly: Boolean(descriptor.readonly),
841
- relativePath: descriptor.relativePath,
842
- absolutePath,
843
- exists: false,
844
- size: 0,
845
- updatedAt: null,
846
- contents: '',
847
- }));
848
- return;
849
- }
850
- throw err;
851
- }
852
- }),
853
- );
854
-
855
- router.put(
856
- '/:provider/config-files/:fileId',
857
- asyncHandler(async (req: Request, res: Response) => {
858
- const provider = String(req.params.provider);
859
- const fileId = String(req.params.fileId);
860
- const { descriptor, absolutePath } = resolveConfigFile(provider, fileId);
861
-
862
- if (descriptor.readonly) {
863
- throw new AppError(`${descriptor.label} is read-only`, {
864
- code: 'PROVIDER_CONFIG_READONLY',
865
- statusCode: 403,
866
- });
867
- }
868
-
869
- const contents = typeof req.body?.contents === 'string' ? req.body.contents : '';
870
- if (Buffer.byteLength(contents, 'utf8') > MAX_CONFIG_FILE_SIZE_BYTES) {
871
- throw new AppError(
872
- `Refusing to write: contents exceed ${MAX_CONFIG_FILE_SIZE_BYTES} bytes`,
873
- { code: 'PROVIDER_CONFIG_TOO_LARGE', statusCode: 413 },
874
- );
875
- }
876
-
877
- // Light format validation — catches "pasted a stray character and now
878
- // the CLI refuses to start" before we actually save the file. We don't
879
- // try to be strict about TOML / env formats because a user who's
880
- // editing these probably knows the grammar better than our regex.
881
- if (descriptor.format === 'json') {
882
- try {
883
- JSON.parse(contents || '{}');
884
- } catch (err) {
885
- throw new AppError(`Invalid JSON: ${(err as Error).message}`, {
886
- code: 'PROVIDER_CONFIG_INVALID_JSON',
887
- statusCode: 400,
888
- });
889
- }
890
- }
891
-
892
- await fs.mkdir(path.dirname(absolutePath), { recursive: true });
893
- await fs.writeFile(absolutePath, contents, 'utf8');
894
-
895
- const stat = await fs.stat(absolutePath);
896
- res.json(createApiSuccessResponse({
897
- id: descriptor.id,
898
- relativePath: descriptor.relativePath,
899
- absolutePath,
900
- size: stat.size,
901
- updatedAt: stat.mtime.toISOString(),
902
- }));
903
- }),
904
- );
905
-
906
- router.post(
907
- '/:provider/config-files/:fileId/validate',
908
- asyncHandler(async (req: Request, res: Response) => {
909
- const provider = String(req.params.provider);
910
- const fileId = String(req.params.fileId);
911
- const { descriptor, absolutePath } = resolveConfigFile(provider, fileId);
912
- const contents = typeof req.body?.contents === 'string'
913
- ? req.body.contents
914
- : await fs.readFile(absolutePath, 'utf8').catch(() => '');
915
- res.json(createApiSuccessResponse(await validateProviderConfigContents(descriptor, contents)));
916
- }),
917
- );
918
-
919
- router.post(
920
- '/:provider/config-files/:fileId/backup',
921
- asyncHandler(async (req: Request, res: Response) => {
922
- const provider = String(req.params.provider);
923
- const fileId = String(req.params.fileId);
924
- const { absolutePath } = resolveConfigFile(provider, fileId);
925
- const stat = await fs.stat(absolutePath);
926
- if (!stat.isFile()) {
927
- throw new AppError(`${absolutePath} is not a regular file`, {
928
- code: 'PROVIDER_CONFIG_NOT_FILE',
929
- statusCode: 409,
930
- });
931
- }
932
- const backupPath = `${absolutePath}.pixcode-backup-${Date.now()}`;
933
- await fs.copyFile(absolutePath, backupPath);
934
- res.json(createApiSuccessResponse({
935
- provider,
936
- fileId,
937
- absolutePath,
938
- backupPath,
939
- size: stat.size,
940
- }));
941
- }),
942
- );
943
-
944
- export default router;
1
+ /* eslint-disable import-x/order */
2
+ import express, { type Request, type Response } from 'express';
3
+
4
+ import { providerAuthService } from '@/modules/providers/services/provider-auth.service.js';
5
+ import { providerMcpService } from '@/modules/providers/services/mcp.service.js';
6
+
7
+ // @ts-ignore — plain-JS service, typed via inference
8
+ import {
9
+ applyProviderCredentialsToEnv,
10
+ listProviderCredentialSummaries,
11
+ setProviderCredentials,
12
+ PROVIDER_ENV_VARS,
13
+ } from '@/services/provider-credentials.js';
14
+
15
+ // @ts-ignore — plain-JS service
16
+ import {
17
+ clearProviderModelRegistryCache,
18
+ getProviderModelRegistryEntry,
19
+ } from '@/services/model-registry.js';
20
+ // @ts-ignore — plain-JS service
21
+ import { getProviderCliVersionStatus } from '@/services/provider-cli-versions.js';
22
+
23
+ // @ts-ignore — plain-JS service
24
+ import {
25
+ createInstallJob,
26
+ getInstallJob,
27
+ cancelInstallJob,
28
+ snapshotDonePayload,
29
+ } from '@/services/install-jobs.js';
30
+
31
+
32
+ // @ts-ignore — plain-JS shared module
33
+ import {
34
+ MAX_CONFIG_FILE_SIZE_BYTES,
35
+ PROVIDER_CONFIG_FILES,
36
+ type ProviderConfigFile,
37
+ } from '@/modules/providers/shared/provider-configs.js';
38
+ import { AppError, asyncHandler, createApiSuccessResponse } from '@/shared/utils.js';
39
+ import type { LLMProvider, McpScope, McpTransport, UpsertProviderMcpServerInput } from '@/shared/types.js';
40
+
41
+ import fs from 'node:fs/promises';
42
+ import http from 'node:http';
43
+ import os from 'node:os';
44
+ import path from 'node:path';
45
+
46
+
47
+ /**
48
+ * npm-global install command per provider. Used by POST
49
+ * /api/providers/:p/install to run the install directly from Pixcode so
50
+ * users don't have to drop into a shell just to get a CLI on the host.
51
+ * Cursor uses its own install script, not npm.
52
+ */
53
+ /**
54
+ * npm package name per provider. The in-app installer drops these into
55
+ * ~/.pixcode/cli-bin/ as LOCAL deps (no -g, no sudo). A sibling string
56
+ * for display ("npm install -g …") is surfaced in the UI so users who
57
+ * prefer to install manually still see a recognizable command.
58
+ */
59
+ const PROVIDER_INSTALL_PACKAGES: Record<LLMProvider, string | null> = {
60
+ claude: '@anthropic-ai/claude-code',
61
+ codex: '@openai/codex',
62
+ gemini: '@google/gemini-cli',
63
+ qwen: '@qwen-code/qwen-code',
64
+ opencode: 'opencode-ai',
65
+ // Cursor ships via a bash script hosted at cursor.com; safer to ask
66
+ // users to run it themselves than to pipe-to-bash from our server.
67
+ cursor: null,
68
+ };
69
+
70
+ const PROVIDER_INSTALL_COMMANDS: Record<LLMProvider, string | null> = {
71
+ claude: 'npm install -g @anthropic-ai/claude-code',
72
+ codex: 'npm install -g @openai/codex',
73
+ gemini: 'npm install -g @google/gemini-cli',
74
+ qwen: 'npm install -g @qwen-code/qwen-code',
75
+ opencode: 'npm install -g opencode-ai',
76
+ cursor: null,
77
+ };
78
+
79
+ /**
80
+ * Per-provider manual install hints, surfaced when `/install` is called
81
+ * for a provider Pixcode can't sandbox-install (anything not on npm).
82
+ * Each entry includes platform-specific commands so the UI can show the
83
+ * right one for the user's host. Cursor is the only provider in this
84
+ * bucket today — it ships via curl|bash on POSIX and a downloadable
85
+ * installer on Windows. We deliberately don't pipe-to-bash from Pixcode,
86
+ * so the user runs it themselves.
87
+ */
88
+ const PROVIDER_MANUAL_INSTALL: Partial<Record<LLMProvider, {
89
+ docsUrl: string;
90
+ steps: { platform: 'macos' | 'linux' | 'windows'; command: string }[];
91
+ note: string;
92
+ }>> = {
93
+ cursor: {
94
+ docsUrl: 'https://docs.cursor.com/en/cli/installation',
95
+ steps: [
96
+ { platform: 'macos', command: 'curl https://cursor.com/install -fsS | bash' },
97
+ { platform: 'linux', command: 'curl https://cursor.com/install -fsS | bash' },
98
+ { platform: 'windows', command: 'iwr https://cursor.com/install.ps1 -useb | iex' },
99
+ ],
100
+ note: 'Cursor ships outside npm — run the command for your platform in a separate terminal, then click "Refresh" on this page once the binary is on PATH.',
101
+ },
102
+ };
103
+
104
+ const router = express.Router();
105
+
106
+ const readPathParam = (value: unknown, name: string): string => {
107
+ if (typeof value === 'string') {
108
+ return value;
109
+ }
110
+
111
+ if (Array.isArray(value) && typeof value[0] === 'string') {
112
+ return value[0];
113
+ }
114
+
115
+ throw new AppError(`${name} path parameter is invalid.`, {
116
+ code: 'INVALID_PATH_PARAMETER',
117
+ statusCode: 400,
118
+ });
119
+ };
120
+
121
+ const normalizeProviderParam = (value: unknown): string =>
122
+ readPathParam(value, 'provider').trim().toLowerCase();
123
+
124
+ const readOptionalQueryString = (value: unknown): string | undefined => {
125
+ if (typeof value !== 'string') {
126
+ return undefined;
127
+ }
128
+
129
+ const normalized = value.trim();
130
+ return normalized.length > 0 ? normalized : undefined;
131
+ };
132
+
133
+ const parseMcpScope = (value: unknown): McpScope | undefined => {
134
+ if (value === undefined) {
135
+ return undefined;
136
+ }
137
+
138
+ const normalized = readOptionalQueryString(value);
139
+ if (!normalized) {
140
+ return undefined;
141
+ }
142
+
143
+ if (normalized === 'user' || normalized === 'local' || normalized === 'project') {
144
+ return normalized;
145
+ }
146
+
147
+ throw new AppError(`Unsupported MCP scope "${normalized}".`, {
148
+ code: 'INVALID_MCP_SCOPE',
149
+ statusCode: 400,
150
+ });
151
+ };
152
+
153
+ const parseMcpTransport = (value: unknown): McpTransport => {
154
+ const normalized = readOptionalQueryString(value);
155
+ if (!normalized) {
156
+ throw new AppError('transport is required.', {
157
+ code: 'MCP_TRANSPORT_REQUIRED',
158
+ statusCode: 400,
159
+ });
160
+ }
161
+
162
+ if (normalized === 'stdio' || normalized === 'http' || normalized === 'sse') {
163
+ return normalized;
164
+ }
165
+
166
+ throw new AppError(`Unsupported MCP transport "${normalized}".`, {
167
+ code: 'INVALID_MCP_TRANSPORT',
168
+ statusCode: 400,
169
+ });
170
+ };
171
+
172
+ const parseMcpUpsertPayload = (payload: unknown): UpsertProviderMcpServerInput => {
173
+ if (!payload || typeof payload !== 'object') {
174
+ throw new AppError('Request body must be an object.', {
175
+ code: 'INVALID_REQUEST_BODY',
176
+ statusCode: 400,
177
+ });
178
+ }
179
+
180
+ const body = payload as Record<string, unknown>;
181
+ const name = readOptionalQueryString(body.name);
182
+ if (!name) {
183
+ throw new AppError('name is required.', {
184
+ code: 'MCP_NAME_REQUIRED',
185
+ statusCode: 400,
186
+ });
187
+ }
188
+
189
+ const transport = parseMcpTransport(body.transport);
190
+ const scope = parseMcpScope(body.scope);
191
+ const workspacePath = readOptionalQueryString(body.workspacePath);
192
+
193
+ return {
194
+ name,
195
+ transport,
196
+ scope,
197
+ workspacePath,
198
+ command: readOptionalQueryString(body.command),
199
+ args: Array.isArray(body.args) ? body.args.filter((entry): entry is string => typeof entry === 'string') : undefined,
200
+ env: typeof body.env === 'object' && body.env !== null
201
+ ? Object.fromEntries(
202
+ Object.entries(body.env as Record<string, unknown>).filter(
203
+ (entry): entry is [string, string] => typeof entry[1] === 'string',
204
+ ),
205
+ )
206
+ : undefined,
207
+ cwd: readOptionalQueryString(body.cwd),
208
+ url: readOptionalQueryString(body.url),
209
+ headers: typeof body.headers === 'object' && body.headers !== null
210
+ ? Object.fromEntries(
211
+ Object.entries(body.headers as Record<string, unknown>).filter(
212
+ (entry): entry is [string, string] => typeof entry[1] === 'string',
213
+ ),
214
+ )
215
+ : undefined,
216
+ envVars: Array.isArray(body.envVars)
217
+ ? body.envVars.filter((entry): entry is string => typeof entry === 'string')
218
+ : undefined,
219
+ bearerTokenEnvVar: readOptionalQueryString(body.bearerTokenEnvVar),
220
+ envHttpHeaders: typeof body.envHttpHeaders === 'object' && body.envHttpHeaders !== null
221
+ ? Object.fromEntries(
222
+ Object.entries(body.envHttpHeaders as Record<string, unknown>).filter(
223
+ (entry): entry is [string, string] => typeof entry[1] === 'string',
224
+ ),
225
+ )
226
+ : undefined,
227
+ };
228
+ };
229
+
230
+ const parseProvider = (value: unknown): LLMProvider => {
231
+ const normalized = normalizeProviderParam(value);
232
+ if (
233
+ normalized === 'claude' ||
234
+ normalized === 'codex' ||
235
+ normalized === 'cursor' ||
236
+ normalized === 'gemini' ||
237
+ normalized === 'qwen' ||
238
+ normalized === 'opencode'
239
+ ) {
240
+ return normalized;
241
+ }
242
+
243
+ throw new AppError(`Unsupported provider "${normalized}".`, {
244
+ code: 'UNSUPPORTED_PROVIDER',
245
+ statusCode: 400,
246
+ });
247
+ };
248
+
249
+ router.get(
250
+ '/:provider/auth/status',
251
+ asyncHandler(async (req: Request, res: Response) => {
252
+ const provider = parseProvider(req.params.provider);
253
+ const forceRefresh = String(req.query.refresh || '').toLowerCase() === '1';
254
+ const status = await providerAuthService.getProviderAuthStatus(provider);
255
+ const cliVersion = await getProviderCliVersionStatus(provider, {
256
+ installed: status.installed,
257
+ forceRefresh,
258
+ });
259
+ res.json(createApiSuccessResponse({ ...status, ...cliVersion }));
260
+ }),
261
+ );
262
+
263
+ router.get(
264
+ '/:provider/mcp/servers',
265
+ asyncHandler(async (req: Request, res: Response) => {
266
+ const provider = parseProvider(req.params.provider);
267
+ const workspacePath = readOptionalQueryString(req.query.workspacePath);
268
+ const scope = parseMcpScope(req.query.scope);
269
+
270
+ if (scope) {
271
+ const servers = await providerMcpService.listProviderMcpServersForScope(provider, scope, { workspacePath });
272
+ res.json(createApiSuccessResponse({ provider, scope, servers }));
273
+ return;
274
+ }
275
+
276
+ const groupedServers = await providerMcpService.listProviderMcpServers(provider, { workspacePath });
277
+ res.json(createApiSuccessResponse({ provider, scopes: groupedServers }));
278
+ }),
279
+ );
280
+
281
+ router.post(
282
+ '/:provider/mcp/servers',
283
+ asyncHandler(async (req: Request, res: Response) => {
284
+ const provider = parseProvider(req.params.provider);
285
+ const payload = parseMcpUpsertPayload(req.body);
286
+ const server = await providerMcpService.upsertProviderMcpServer(provider, payload);
287
+ res.status(201).json(createApiSuccessResponse({ server }));
288
+ }),
289
+ );
290
+
291
+ router.delete(
292
+ '/:provider/mcp/servers/:name',
293
+ asyncHandler(async (req: Request, res: Response) => {
294
+ const provider = parseProvider(req.params.provider);
295
+ const scope = parseMcpScope(req.query.scope);
296
+ const workspacePath = readOptionalQueryString(req.query.workspacePath);
297
+ const result = await providerMcpService.removeProviderMcpServer(provider, {
298
+ name: readPathParam(req.params.name, 'name'),
299
+ scope,
300
+ workspacePath,
301
+ });
302
+ res.json(createApiSuccessResponse(result));
303
+ }),
304
+ );
305
+
306
+ /**
307
+ * GET /api/providers/credentials
308
+ * Summary for every provider (hasKey + baseUrl + updatedAt). Used by the
309
+ * Settings UI to pre-fill the "API Key" tab.
310
+ */
311
+ router.get(
312
+ '/credentials',
313
+ asyncHandler(async (_req: Request, res: Response) => {
314
+ const summaries = await listProviderCredentialSummaries();
315
+ res.json(createApiSuccessResponse(summaries));
316
+ }),
317
+ );
318
+
319
+ /**
320
+ * POST /api/providers/:provider/auth/api-key
321
+ * Body: { apiKey: string, baseUrl?: string }. Stores the credentials in
322
+ * ~/.pixcode/provider-credentials.json and applies them to process.env
323
+ * so the next CLI spawn/SDK call picks them up. Empty apiKey clears.
324
+ */
325
+ router.post(
326
+ '/:provider/auth/api-key',
327
+ asyncHandler(async (req: Request, res: Response) => {
328
+ const provider = parseProvider(req.params.provider);
329
+ if (!(provider in PROVIDER_ENV_VARS)) {
330
+ throw new AppError(`Provider "${provider}" does not accept API-key auth.`, {
331
+ code: 'PROVIDER_NO_API_KEY',
332
+ statusCode: 400,
333
+ });
334
+ }
335
+ const body = (req.body ?? {}) as Record<string, unknown>;
336
+ const apiKey = typeof body.apiKey === 'string' ? body.apiKey : '';
337
+ const baseUrl = typeof body.baseUrl === 'string' ? body.baseUrl : '';
338
+
339
+ await setProviderCredentials(provider, { apiKey, baseUrl });
340
+ await applyProviderCredentialsToEnv(provider);
341
+
342
+ res.json(createApiSuccessResponse({ provider, stored: Boolean(apiKey.trim()) }));
343
+ }),
344
+ );
345
+
346
+ /**
347
+ * POST /api/providers/:provider/oauth-paste
348
+ * Body: { callbackUrl: string }.
349
+ *
350
+ * When the CLI starts an OAuth flow it spins up a local HTTP server on
351
+ * 127.0.0.1:<PORT> and expects the OAuth provider to redirect the user's
352
+ * browser to `http://127.0.0.1:<PORT>/callback?code=...`. On remote VPS
353
+ * setups that redirect hits the user's laptop localhost (which has nothing
354
+ * listening), not the server running the CLI. This endpoint is the escape
355
+ * hatch: the user copies the dead callback URL from their browser and
356
+ * posts it here; we parse out the port + code and forward the original
357
+ * GET to the VPS-side 127.0.0.1:PORT so the CLI's local handler completes
358
+ * the token exchange.
359
+ */
360
+ router.post(
361
+ '/:provider/oauth-paste',
362
+ asyncHandler(async (req: Request, res: Response) => {
363
+ parseProvider(req.params.provider); // validate id but we don't use it further
364
+ const body = (req.body ?? {}) as Record<string, unknown>;
365
+ const raw = typeof body.callbackUrl === 'string' ? body.callbackUrl.trim() : '';
366
+ if (!raw) {
367
+ throw new AppError('callbackUrl is required.', {
368
+ code: 'OAUTH_PASTE_URL_REQUIRED',
369
+ statusCode: 400,
370
+ });
371
+ }
372
+
373
+ let parsed: URL;
374
+ try {
375
+ parsed = new URL(raw);
376
+ } catch {
377
+ throw new AppError('callbackUrl must be a valid URL.', {
378
+ code: 'OAUTH_PASTE_URL_INVALID',
379
+ statusCode: 400,
380
+ });
381
+ }
382
+
383
+ // Accept localhost / 127.0.0.1 callbacks — reject anything else so we
384
+ // never proxy arbitrary outbound requests on behalf of a user.
385
+ const host = parsed.hostname;
386
+ if (host !== '127.0.0.1' && host !== 'localhost' && host !== '::1') {
387
+ throw new AppError('Only local CLI callback URLs are accepted.', {
388
+ code: 'OAUTH_PASTE_URL_NOT_LOCAL',
389
+ statusCode: 400,
390
+ });
391
+ }
392
+
393
+ const port = Number(parsed.port);
394
+ if (!port || port < 1 || port > 65535) {
395
+ throw new AppError('Callback URL must include the CLI callback port.', {
396
+ code: 'OAUTH_PASTE_PORT_INVALID',
397
+ statusCode: 400,
398
+ });
399
+ }
400
+
401
+ const pathAndQuery = parsed.pathname + parsed.search;
402
+ await new Promise<void>((resolve, reject) => {
403
+ const forwardReq = http.request(
404
+ {
405
+ host: '127.0.0.1',
406
+ port,
407
+ method: 'GET',
408
+ path: pathAndQuery,
409
+ timeout: 10000,
410
+ },
411
+ (forwardRes) => {
412
+ forwardRes.resume(); // drain
413
+ forwardRes.on('end', () => resolve());
414
+ },
415
+ );
416
+ forwardReq.on('timeout', () => {
417
+ forwardReq.destroy(new Error('CLI callback server did not respond within 10s'));
418
+ });
419
+ forwardReq.on('error', (err) => reject(err));
420
+ forwardReq.end();
421
+ });
422
+
423
+ res.json(createApiSuccessResponse({ forwarded: true, port }));
424
+ }),
425
+ );
426
+
427
+ /**
428
+ * GET /api/providers/:provider/models?refresh=1
429
+ * Provider model registry entry: central static fallback + live API discovery
430
+ * with freshness/degraded metadata. 6-hour cache; pass `refresh=1` to force an
431
+ * upstream hit.
432
+ */
433
+ router.get(
434
+ '/:provider/models',
435
+ asyncHandler(async (req: Request, res: Response) => {
436
+ const provider = parseProvider(req.params.provider);
437
+ const forceRefresh = String(req.query.refresh || '').toLowerCase() === '1';
438
+ const result = await getProviderModelRegistryEntry(provider, { forceRefresh });
439
+ res.json(createApiSuccessResponse(result));
440
+ }),
441
+ );
442
+
443
+ router.delete(
444
+ '/:provider/models/cache',
445
+ asyncHandler(async (req: Request, res: Response) => {
446
+ const provider = parseProvider(req.params.provider);
447
+ await clearProviderModelRegistryCache(provider);
448
+ res.json(createApiSuccessResponse({ cleared: true, provider }));
449
+ }),
450
+ );
451
+
452
+ /**
453
+ * POST /api/providers/:provider/install
454
+ * Kicks off the install in the background and immediately returns
455
+ * `{ jobId }`. The actual log stream is fetched separately via
456
+ * GET /install/:jobId/stream (EventSource). This split solves the
457
+ * "Client disconnected before install finished" class of errors,
458
+ * where a single long-lived POST SSE would get torn down by dev
459
+ * proxies, service-worker reloads, or Vite HMR and short-circuit
460
+ * an in-flight install. The child now outlives the request.
461
+ */
462
+ router.post(
463
+ '/:provider/install',
464
+ asyncHandler(async (req: Request, res: Response) => {
465
+ const parsed = parseProvider(req.params.provider);
466
+ const packageName = PROVIDER_INSTALL_PACKAGES[parsed];
467
+ const installCmd = PROVIDER_INSTALL_COMMANDS[parsed];
468
+ if (!packageName || !installCmd) {
469
+ const manual = PROVIDER_MANUAL_INSTALL[parsed];
470
+ // Don't 4xx on this — the call ISN'T malformed, the provider is
471
+ // simply not npm-installable. Return 200 with a `manual` payload
472
+ // the UI can present as instructions instead of "install failed".
473
+ res.json(createApiSuccessResponse({
474
+ provider: parsed,
475
+ manual: manual || null,
476
+ message: manual
477
+ ? `${parsed} ships outside npm. Run the platform-specific command, then refresh.`
478
+ : `${parsed} cannot be installed automatically — see the provider's documentation.`,
479
+ }));
480
+ return;
481
+ }
482
+
483
+ const job = createInstallJob({ provider: parsed, installCmd, packageName });
484
+ res.json(createApiSuccessResponse({
485
+ jobId: job.id,
486
+ provider: parsed,
487
+ installCmd,
488
+ startedAt: job.startedAt,
489
+ }));
490
+ }),
491
+ );
492
+
493
+ /**
494
+ * GET /api/providers/:provider/install/:jobId/stream
495
+ * SSE endpoint (EventSource-friendly). Replays every buffered log line
496
+ * to the new subscriber, then forwards live stdout/stderr until the
497
+ * child exits. Clients can reconnect freely — reconnects replay from
498
+ * the start, so you never miss output, even if the browser dropped
499
+ * the previous connection while npm was mid-download.
500
+ *
501
+ * EventSource can't set custom headers, so this endpoint also accepts
502
+ * ?token=... as a fallback auth channel (same pattern the search
503
+ * endpoint uses).
504
+ */
505
+ router.get(
506
+ '/:provider/install/:jobId/stream',
507
+ asyncHandler(async (req: Request, res: Response) => {
508
+ const parsed = parseProvider(req.params.provider);
509
+ const jobId = readPathParam(req.params.jobId, 'jobId');
510
+ const job = getInstallJob(jobId);
511
+ if (!job || job.provider !== parsed) {
512
+ throw new AppError('Install job not found or already expired.', {
513
+ code: 'INSTALL_JOB_NOT_FOUND',
514
+ statusCode: 404,
515
+ });
516
+ }
517
+
518
+ res.setHeader('Content-Type', 'text/event-stream');
519
+ res.setHeader('Cache-Control', 'no-cache, no-transform');
520
+ res.setHeader('Connection', 'keep-alive');
521
+ res.setHeader('X-Accel-Buffering', 'no');
522
+ if (typeof res.flushHeaders === 'function') res.flushHeaders();
523
+ try {
524
+ (res.socket as NodeJS.Socket & { setNoDelay?: (on: boolean) => void })?.setNoDelay?.(true);
525
+ } catch { /* noop */ }
526
+
527
+ let closed = false;
528
+ const write = (event: string, payload: unknown) => {
529
+ if (closed) return;
530
+ try {
531
+ res.write(`event: ${event}\n`);
532
+ res.write(`data: ${JSON.stringify(payload)}\n\n`);
533
+ } catch { /* socket gone */ }
534
+ };
535
+
536
+ // Immediate primer + heartbeat, same as before — keeps intermediary
537
+ // proxies from treating the connection as idle.
538
+ try { res.write(': start\n\n'); } catch { /* noop */ }
539
+ const heartbeat = setInterval(() => {
540
+ if (closed) return;
541
+ try { res.write(': ping\n\n'); } catch { /* noop */ }
542
+ }, 5000);
543
+
544
+ // Replay the buffered transcript first so late subscribers see
545
+ // every line npm has already produced.
546
+ for (const entry of job.logs) {
547
+ write('log', { stream: entry.stream, chunk: entry.chunk });
548
+ }
549
+
550
+ const onLog = (entry: { stream: string; chunk: string }) => {
551
+ write('log', { stream: entry.stream, chunk: entry.chunk });
552
+ };
553
+ const onDone = (payload: Record<string, unknown>) => {
554
+ write('done', payload);
555
+ cleanup();
556
+ try { res.end(); } catch { /* noop */ }
557
+ };
558
+
559
+ const cleanup = () => {
560
+ if (closed) return;
561
+ closed = true;
562
+ clearInterval(heartbeat);
563
+ job.emitter.off('log', onLog);
564
+ job.emitter.off('done', onDone);
565
+ };
566
+
567
+ if (job.status !== 'running') {
568
+ // Job already finished — replay the terminal done frame and exit.
569
+ write('done', snapshotDonePayload(job));
570
+ cleanup();
571
+ try { res.end(); } catch { /* noop */ }
572
+ return;
573
+ }
574
+
575
+ job.emitter.on('log', onLog);
576
+ job.emitter.once('done', onDone);
577
+
578
+ req.on('close', () => {
579
+ // Client walked away. DO NOT cancel the install — detaching is fine.
580
+ cleanup();
581
+ });
582
+ }),
583
+ );
584
+
585
+ router.delete(
586
+ '/:provider/install/:jobId',
587
+ asyncHandler(async (req: Request, res: Response) => {
588
+ const parsed = parseProvider(req.params.provider);
589
+ const jobId = readPathParam(req.params.jobId, 'jobId');
590
+ const job = getInstallJob(jobId);
591
+ if (!job || job.provider !== parsed) {
592
+ throw new AppError('Install job not found.', {
593
+ code: 'INSTALL_JOB_NOT_FOUND',
594
+ statusCode: 404,
595
+ });
596
+ }
597
+ const cancelled = cancelInstallJob(jobId);
598
+ res.json(createApiSuccessResponse({ cancelled }));
599
+ }),
600
+ );
601
+
602
+ router.post(
603
+ '/mcp/servers/global',
604
+ asyncHandler(async (req: Request, res: Response) => {
605
+ const payload = parseMcpUpsertPayload(req.body);
606
+ if (payload.scope === 'local') {
607
+ throw new AppError('Global MCP add supports only "user" or "project" scopes.', {
608
+ code: 'INVALID_GLOBAL_MCP_SCOPE',
609
+ statusCode: 400,
610
+ });
611
+ }
612
+
613
+ const results = await providerMcpService.addMcpServerToAllProviders({
614
+ ...payload,
615
+ scope: payload.scope === 'user' ? 'user' : 'project',
616
+ });
617
+ res.status(201).json(createApiSuccessResponse({ results }));
618
+ }),
619
+ );
620
+
621
+ // ============================================================================
622
+ // Provider config files — read / edit the per-CLI settings/env files from
623
+ // inside Pixcode rather than making the user open a text editor themselves.
624
+ // The registry at server/modules/providers/shared/provider-configs.ts is the
625
+ // single source of truth for which files exist; the client pulls this list
626
+ // via GET /config-files and then reads/writes individual files by id.
627
+ // ============================================================================
628
+
629
+ // Resolve a config descriptor from (provider, fileId). Throws a 404
630
+ // AppError if either isn't registered so the client sees a clear failure
631
+ // instead of a generic 500.
632
+ const resolveConfigFile = (provider: string, fileId: string): { descriptor: ProviderConfigFile; absolutePath: string } => {
633
+ const list = PROVIDER_CONFIG_FILES[provider];
634
+ if (!list) {
635
+ throw new AppError(`No config files registered for provider "${provider}"`, {
636
+ code: 'PROVIDER_CONFIG_UNKNOWN_PROVIDER',
637
+ statusCode: 404,
638
+ });
639
+ }
640
+ const descriptor = list.find((entry) => entry.id === fileId);
641
+ if (!descriptor) {
642
+ throw new AppError(`Unknown config file "${fileId}" for provider "${provider}"`, {
643
+ code: 'PROVIDER_CONFIG_UNKNOWN_FILE',
644
+ statusCode: 404,
645
+ });
646
+ }
647
+ // Always resolve relative to the server's os.homedir() — we never trust
648
+ // the client for any part of the path. `path.resolve` then normalises
649
+ // out any `..` segments the registry might accidentally contain.
650
+ const absolutePath = path.resolve(os.homedir(), descriptor.relativePath);
651
+ return { descriptor, absolutePath };
652
+ };
653
+
654
+ const SENSITIVE_CONFIG_PATTERN = /(api[_-]?key|token|secret|password|authorization|bearer)\s*[:=]\s*["']?([^"'\n\r]+)/ig;
655
+
656
+ function redactProviderConfigPreview(contents: string): string {
657
+ return contents.replace(SENSITIVE_CONFIG_PATTERN, (_match, key) => `${key}: [redacted]`);
658
+ }
659
+
660
+ async function validateProviderConfigContents(descriptor: ProviderConfigFile, contents: string) {
661
+ if (Buffer.byteLength(contents, 'utf8') > MAX_CONFIG_FILE_SIZE_BYTES) {
662
+ throw new AppError(
663
+ `Config contents exceed ${MAX_CONFIG_FILE_SIZE_BYTES} bytes`,
664
+ { code: 'PROVIDER_CONFIG_TOO_LARGE', statusCode: 413 },
665
+ );
666
+ }
667
+
668
+ if (descriptor.format === 'json') {
669
+ try {
670
+ JSON.parse(contents || '{}');
671
+ } catch (err) {
672
+ throw new AppError(`Invalid JSON: ${(err as Error).message}`, {
673
+ code: 'PROVIDER_CONFIG_INVALID_JSON',
674
+ statusCode: 400,
675
+ });
676
+ }
677
+ }
678
+
679
+ return {
680
+ valid: true,
681
+ format: descriptor.format,
682
+ readonly: Boolean(descriptor.readonly),
683
+ preview: redactProviderConfigPreview(contents).slice(0, 4000),
684
+ };
685
+ }
686
+
687
+ async function buildProviderPluginState(provider: string) {
688
+ const files = PROVIDER_CONFIG_FILES[provider] || [];
689
+ const configs = await Promise.all(files.map(async (entry) => {
690
+ const absolutePath = path.resolve(os.homedir(), entry.relativePath);
691
+ let exists = false;
692
+ let size: number | null = null;
693
+ let updatedAt: string | null = null;
694
+ let preview = '';
695
+ try {
696
+ const stat = await fs.stat(absolutePath);
697
+ exists = stat.isFile();
698
+ size = stat.size;
699
+ updatedAt = stat.mtime.toISOString();
700
+ if (exists && stat.size <= MAX_CONFIG_FILE_SIZE_BYTES) {
701
+ preview = redactProviderConfigPreview(await fs.readFile(absolutePath, 'utf8')).slice(0, 1200);
702
+ }
703
+ } catch {
704
+ // Missing config files are normal for CLIs that have not been used yet.
705
+ }
706
+
707
+ return {
708
+ id: entry.id,
709
+ label: entry.label,
710
+ format: entry.format,
711
+ readonly: Boolean(entry.readonly),
712
+ relativePath: entry.relativePath,
713
+ absolutePath,
714
+ exists,
715
+ size,
716
+ updatedAt,
717
+ preview,
718
+ canBackup: exists,
719
+ canValidate: entry.format === 'json' || entry.format === 'env' || entry.format === 'toml' || entry.format === 'text',
720
+ };
721
+ }));
722
+
723
+ return {
724
+ provider,
725
+ supported: files.length > 0,
726
+ configCount: files.length,
727
+ installedCount: configs.filter((config) => config.exists).length,
728
+ configs,
729
+ };
730
+ }
731
+
732
+ router.get(
733
+ '/plugin-state',
734
+ asyncHandler(async (_req: Request, res: Response) => {
735
+ const providers = await Promise.all(
736
+ Object.keys(PROVIDER_CONFIG_FILES).map((provider) => buildProviderPluginState(provider)),
737
+ );
738
+ res.json(createApiSuccessResponse({ providers }));
739
+ }),
740
+ );
741
+
742
+ router.get(
743
+ '/plugin-state/:provider',
744
+ asyncHandler(async (req: Request, res: Response) => {
745
+ const provider = parseProvider(req.params.provider);
746
+ res.json(createApiSuccessResponse(await buildProviderPluginState(provider)));
747
+ }),
748
+ );
749
+
750
+ router.get(
751
+ '/:provider/config-files',
752
+ asyncHandler(async (req: Request, res: Response) => {
753
+ const provider = String(req.params.provider);
754
+ const list = PROVIDER_CONFIG_FILES[provider];
755
+ if (!list) {
756
+ throw new AppError(`No config files registered for provider "${provider}"`, {
757
+ code: 'PROVIDER_CONFIG_UNKNOWN_PROVIDER',
758
+ statusCode: 404,
759
+ });
760
+ }
761
+ const files = await Promise.all(
762
+ list.map(async (entry: ProviderConfigFile) => {
763
+ const absolutePath = path.resolve(os.homedir(), entry.relativePath);
764
+ let exists = false;
765
+ let size: number | null = null;
766
+ let updatedAt: string | null = null;
767
+ try {
768
+ const stat = await fs.stat(absolutePath);
769
+ exists = stat.isFile();
770
+ size = stat.size;
771
+ updatedAt = stat.mtime.toISOString();
772
+ } catch (err) {
773
+ // ENOENT is the expected path for "user hasn't created this yet".
774
+ // Anything else (EACCES, EISDIR, …) we surface as a hint rather
775
+ // than blow up the whole list response.
776
+ if ((err as NodeJS.ErrnoException).code !== 'ENOENT') {
777
+ console.warn(`[provider-configs] stat ${absolutePath}:`, (err as Error).message);
778
+ }
779
+ }
780
+ return {
781
+ id: entry.id,
782
+ label: entry.label,
783
+ format: entry.format,
784
+ readonly: Boolean(entry.readonly),
785
+ description: entry.description ?? null,
786
+ relativePath: entry.relativePath,
787
+ absolutePath,
788
+ exists,
789
+ size,
790
+ updatedAt,
791
+ };
792
+ }),
793
+ );
794
+ res.json(createApiSuccessResponse({ provider, files }));
795
+ }),
796
+ );
797
+
798
+ router.get(
799
+ '/:provider/config-files/:fileId',
800
+ asyncHandler(async (req: Request, res: Response) => {
801
+ const provider = String(req.params.provider);
802
+ const fileId = String(req.params.fileId);
803
+ const { descriptor, absolutePath } = resolveConfigFile(provider, fileId);
804
+
805
+ try {
806
+ const stat = await fs.stat(absolutePath);
807
+ if (!stat.isFile()) {
808
+ throw new AppError(`${absolutePath} is not a regular file`, {
809
+ code: 'PROVIDER_CONFIG_NOT_FILE',
810
+ statusCode: 409,
811
+ });
812
+ }
813
+ if (stat.size > MAX_CONFIG_FILE_SIZE_BYTES) {
814
+ throw new AppError(
815
+ `Config file is larger than ${MAX_CONFIG_FILE_SIZE_BYTES} bytes — refusing to load`,
816
+ { code: 'PROVIDER_CONFIG_TOO_LARGE', statusCode: 413 },
817
+ );
818
+ }
819
+ const contents = await fs.readFile(absolutePath, 'utf8');
820
+ res.json(createApiSuccessResponse({
821
+ id: descriptor.id,
822
+ label: descriptor.label,
823
+ format: descriptor.format,
824
+ readonly: Boolean(descriptor.readonly),
825
+ relativePath: descriptor.relativePath,
826
+ absolutePath,
827
+ exists: true,
828
+ size: stat.size,
829
+ updatedAt: stat.mtime.toISOString(),
830
+ contents,
831
+ }));
832
+ } catch (err) {
833
+ if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
834
+ // Report "file doesn't exist yet" with empty contents so the UI can
835
+ // still open an editor and let the user create it with a save.
836
+ res.json(createApiSuccessResponse({
837
+ id: descriptor.id,
838
+ label: descriptor.label,
839
+ format: descriptor.format,
840
+ readonly: Boolean(descriptor.readonly),
841
+ relativePath: descriptor.relativePath,
842
+ absolutePath,
843
+ exists: false,
844
+ size: 0,
845
+ updatedAt: null,
846
+ contents: '',
847
+ }));
848
+ return;
849
+ }
850
+ throw err;
851
+ }
852
+ }),
853
+ );
854
+
855
+ router.put(
856
+ '/:provider/config-files/:fileId',
857
+ asyncHandler(async (req: Request, res: Response) => {
858
+ const provider = String(req.params.provider);
859
+ const fileId = String(req.params.fileId);
860
+ const { descriptor, absolutePath } = resolveConfigFile(provider, fileId);
861
+
862
+ if (descriptor.readonly) {
863
+ throw new AppError(`${descriptor.label} is read-only`, {
864
+ code: 'PROVIDER_CONFIG_READONLY',
865
+ statusCode: 403,
866
+ });
867
+ }
868
+
869
+ const contents = typeof req.body?.contents === 'string' ? req.body.contents : '';
870
+ if (Buffer.byteLength(contents, 'utf8') > MAX_CONFIG_FILE_SIZE_BYTES) {
871
+ throw new AppError(
872
+ `Refusing to write: contents exceed ${MAX_CONFIG_FILE_SIZE_BYTES} bytes`,
873
+ { code: 'PROVIDER_CONFIG_TOO_LARGE', statusCode: 413 },
874
+ );
875
+ }
876
+
877
+ // Light format validation — catches "pasted a stray character and now
878
+ // the CLI refuses to start" before we actually save the file. We don't
879
+ // try to be strict about TOML / env formats because a user who's
880
+ // editing these probably knows the grammar better than our regex.
881
+ if (descriptor.format === 'json') {
882
+ try {
883
+ JSON.parse(contents || '{}');
884
+ } catch (err) {
885
+ throw new AppError(`Invalid JSON: ${(err as Error).message}`, {
886
+ code: 'PROVIDER_CONFIG_INVALID_JSON',
887
+ statusCode: 400,
888
+ });
889
+ }
890
+ }
891
+
892
+ await fs.mkdir(path.dirname(absolutePath), { recursive: true });
893
+ await fs.writeFile(absolutePath, contents, 'utf8');
894
+
895
+ const stat = await fs.stat(absolutePath);
896
+ res.json(createApiSuccessResponse({
897
+ id: descriptor.id,
898
+ relativePath: descriptor.relativePath,
899
+ absolutePath,
900
+ size: stat.size,
901
+ updatedAt: stat.mtime.toISOString(),
902
+ }));
903
+ }),
904
+ );
905
+
906
+ router.post(
907
+ '/:provider/config-files/:fileId/validate',
908
+ asyncHandler(async (req: Request, res: Response) => {
909
+ const provider = String(req.params.provider);
910
+ const fileId = String(req.params.fileId);
911
+ const { descriptor, absolutePath } = resolveConfigFile(provider, fileId);
912
+ const contents = typeof req.body?.contents === 'string'
913
+ ? req.body.contents
914
+ : await fs.readFile(absolutePath, 'utf8').catch(() => '');
915
+ res.json(createApiSuccessResponse(await validateProviderConfigContents(descriptor, contents)));
916
+ }),
917
+ );
918
+
919
+ router.post(
920
+ '/:provider/config-files/:fileId/backup',
921
+ asyncHandler(async (req: Request, res: Response) => {
922
+ const provider = String(req.params.provider);
923
+ const fileId = String(req.params.fileId);
924
+ const { absolutePath } = resolveConfigFile(provider, fileId);
925
+ const stat = await fs.stat(absolutePath);
926
+ if (!stat.isFile()) {
927
+ throw new AppError(`${absolutePath} is not a regular file`, {
928
+ code: 'PROVIDER_CONFIG_NOT_FILE',
929
+ statusCode: 409,
930
+ });
931
+ }
932
+ const backupPath = `${absolutePath}.pixcode-backup-${Date.now()}`;
933
+ await fs.copyFile(absolutePath, backupPath);
934
+ res.json(createApiSuccessResponse({
935
+ provider,
936
+ fileId,
937
+ absolutePath,
938
+ backupPath,
939
+ size: stat.size,
940
+ }));
941
+ }),
942
+ );
943
+
944
+ export default router;