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