@pixelbyte-software/pixcode 1.35.2 → 1.35.3

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