@pixelbyte-software/pixcode 1.51.8 → 1.51.9
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.
- package/CODE_OF_CONDUCT.md +41 -41
- package/CONTRIBUTING.md +155 -155
- package/LICENSE +718 -718
- package/README.de.md +169 -169
- package/README.ja.md +167 -167
- package/README.ko.md +167 -167
- package/README.md +419 -419
- package/README.ru.md +169 -169
- package/README.tr.md +298 -298
- package/README.zh-CN.md +167 -167
- package/SECURITY.md +46 -46
- package/dist/api-automation.html +110 -110
- package/dist/api-docs.html +548 -548
- package/dist/assets/{index--kNEhygZ.js → index-Gs7C4hZT.js} +69 -69
- package/dist/clear-cache.html +85 -85
- package/dist/convert-icons.md +52 -52
- package/dist/docs.html +308 -308
- package/dist/favicon.svg +8 -8
- package/dist/features.html +133 -133
- package/dist/generate-icons.js +48 -48
- package/dist/humans.txt +15 -15
- package/dist/icons/codex-white.svg +3 -3
- package/dist/icons/codex.svg +3 -3
- package/dist/icons/cursor-white.svg +11 -11
- package/dist/icons/icon-128x128.svg +9 -9
- package/dist/icons/icon-144x144.svg +9 -9
- package/dist/icons/icon-152x152.svg +9 -9
- package/dist/icons/icon-192x192.svg +9 -9
- package/dist/icons/icon-384x384.svg +9 -9
- package/dist/icons/icon-512x512.svg +9 -9
- package/dist/icons/icon-72x72.svg +9 -9
- package/dist/icons/icon-96x96.svg +9 -9
- package/dist/icons/icon-template.svg +9 -9
- package/dist/icons/qwen-logo.svg +14 -14
- package/dist/index.html +58 -58
- package/dist/landing.html +268 -268
- package/dist/llms-full.txt +119 -119
- package/dist/llms.txt +53 -53
- package/dist/logo.svg +12 -12
- package/dist/manifest.json +60 -60
- package/dist/openapi.yaml +1696 -1696
- package/dist/orchestration.html +125 -125
- package/dist/robots.txt +4 -4
- package/dist/site.css +692 -692
- package/dist/sitemap.xml +51 -51
- package/dist/sw.js +132 -132
- package/dist-server/server/cli.js +96 -96
- package/dist-server/server/daemon/manager.js +33 -33
- package/dist-server/server/daemon-manager.js +64 -64
- package/dist-server/server/index.js +91 -65
- package/dist-server/server/index.js.map +1 -1
- package/dist-server/server/routes/commands.js +25 -25
- package/dist-server/server/routes/git.js +17 -17
- package/dist-server/server/routes/live-view.js +46 -46
- package/dist-server/server/services/hermes-gateway.js +5 -5
- package/dist-server/server/services/public-api-manifest.js +51 -51
- package/package.json +222 -222
- package/scripts/fix-node-pty.js +67 -67
- package/scripts/github/create-v1.38-issues.mjs +351 -351
- package/scripts/github/create-vscode-workbench-issues.mjs +121 -121
- package/scripts/hermes/configure-pixcode-mcp.mjs +165 -165
- package/scripts/hermes/pixcode-mcp-server.mjs +1009 -1009
- package/scripts/smoke/changes-panel-layout.mjs +48 -48
- package/scripts/smoke/chat-composer-fixed-layout.mjs +55 -55
- package/scripts/smoke/chat-message-timeline-order.mjs +41 -41
- package/scripts/smoke/chat-realtime-hydration.mjs +44 -44
- package/scripts/smoke/chat-session-provider-pools.mjs +35 -35
- package/scripts/smoke/chat-session-state.mjs +19 -19
- package/scripts/smoke/code-editor-theme.mjs +55 -55
- package/scripts/smoke/code-editor-vscode-engine.mjs +91 -91
- package/scripts/smoke/command-center-agent-writes.mjs +79 -79
- package/scripts/smoke/command-center-non-git.mjs +46 -46
- package/scripts/smoke/context-packet.mjs +43 -43
- package/scripts/smoke/control-room-ux-redesign.mjs +91 -91
- package/scripts/smoke/daemon-entrypoint.mjs +20 -20
- package/scripts/smoke/default-landing-routing.mjs +33 -33
- package/scripts/smoke/desktop-native-notifications.mjs +30 -30
- package/scripts/smoke/desktop-tray-icon.mjs +33 -33
- package/scripts/smoke/discord-release-workflow.mjs +24 -24
- package/scripts/smoke/git-install-update.mjs +255 -255
- package/scripts/smoke/handoff-artifact-protocol.mjs +50 -50
- package/scripts/smoke/hermes-api-install.mjs +56 -56
- package/scripts/smoke/hermes-gateway-persistence.mjs +104 -104
- package/scripts/smoke/hermes-mcp-pixcode-roundtrip.mjs +426 -426
- package/scripts/smoke/hermes-rest-chat-api.mjs +162 -162
- package/scripts/smoke/hermes-rest-chat-live.mjs +45 -45
- package/scripts/smoke/hermes-rest-codex-launch.mjs +209 -209
- package/scripts/smoke/hermes-rest-gateway.mjs +79 -79
- package/scripts/smoke/hermes-rest-live.mjs +42 -42
- package/scripts/smoke/hermes-roundtrip.mjs +167 -167
- package/scripts/smoke/hermes-settings-commands.mjs +349 -349
- package/scripts/smoke/hermes-smoke-launcher-guard.mjs +34 -34
- package/scripts/smoke/live-view-diagnostics.mjs +53 -53
- package/scripts/smoke/live-view-environment.mjs +92 -92
- package/scripts/smoke/live-view-integration.mjs +450 -450
- package/scripts/smoke/mac-desktop-runtime.mjs +37 -37
- package/scripts/smoke/mobile-tunnel-guidance.mjs +29 -29
- package/scripts/smoke/model-registry.mjs +36 -36
- package/scripts/smoke/multi-project-ui.mjs +45 -45
- package/scripts/smoke/multi-worker-slots.mjs +42 -42
- package/scripts/smoke/notification-center.mjs +87 -87
- package/scripts/smoke/notification-inapp-preference.mjs +23 -23
- package/scripts/smoke/notification-taxonomy.mjs +58 -58
- package/scripts/smoke/orchestration-api.mjs +172 -172
- package/scripts/smoke/orchestration-execution-dashboard.mjs +33 -33
- package/scripts/smoke/orchestration-live-run.mjs +176 -176
- package/scripts/smoke/orchestration-mobile-scroll.mjs +29 -29
- package/scripts/smoke/orchestration-model-sync.mjs +30 -30
- package/scripts/smoke/orchestration-permission-fallback.mjs +34 -34
- package/scripts/smoke/orchestration-runtime-guards.mjs +48 -48
- package/scripts/smoke/orchestration-user-facing-output.mjs +25 -25
- package/scripts/smoke/permission-policy.mjs +50 -50
- package/scripts/smoke/pixcode-workbench-1-48.mjs +167 -167
- package/scripts/smoke/provider-models-opencode-live.mjs +66 -66
- package/scripts/smoke/provider-rest-api.mjs +124 -124
- package/scripts/smoke/provider-selection-status.mjs +52 -52
- package/scripts/smoke/run-state-refresh.mjs +52 -52
- package/scripts/smoke/runtime-manager.mjs +99 -99
- package/scripts/smoke/shell-manual-disconnect.mjs +30 -30
- package/scripts/smoke/side-panel-editor-layout.mjs +34 -34
- package/scripts/smoke/static-root-routing.mjs +21 -21
- package/scripts/smoke/strict-handoff-compact.mjs +60 -60
- package/scripts/smoke/taskmaster-config.mjs +24 -24
- package/scripts/smoke/taskmaster-execution-telegram.mjs +3 -3
- package/scripts/smoke/taskmaster-onboarding.mjs +3 -3
- package/scripts/smoke/taskmaster-run-graph.mjs +3 -3
- package/scripts/smoke/telegram-control.mjs +242 -242
- package/scripts/smoke/tunnel-persistence.mjs +56 -56
- package/scripts/smoke/update-issue-progress.mjs +69 -69
- package/scripts/smoke/update-ux.mjs +55 -55
- package/scripts/smoke/v138-completion.mjs +132 -132
- package/scripts/smoke/v138-desktop-release-hardening.mjs +69 -69
- package/scripts/smoke/v138-diagnostics.mjs +63 -63
- package/scripts/smoke/v138-issue-planner.mjs +33 -33
- package/scripts/smoke/v143-remote-control.mjs +76 -76
- package/scripts/smoke/v144-production-loop.mjs +47 -47
- package/scripts/smoke/v145-platformization.mjs +46 -46
- package/scripts/smoke/v146-control-room-ui.mjs +150 -150
- package/scripts/smoke/version-modal-autoshow.mjs +29 -29
- package/scripts/smoke/vscode-workbench-layout.mjs +63 -63
- package/scripts/smoke/vscode-workbench-polish.mjs +461 -461
- package/scripts/smoke/workflow-fallback-replay.mjs +56 -56
- package/scripts/smoke/workflow-templates.mjs +43 -43
- package/scripts/smoke/workflow-trace-timeline.mjs +46 -46
- package/scripts/update-git-install.mjs +283 -283
- package/server/claude-sdk.js +920 -920
- package/server/cli.js +1035 -1035
- package/server/constants/config.js +4 -4
- package/server/cursor-cli.js +344 -344
- package/server/daemon/manager.js +563 -563
- package/server/daemon-manager.js +964 -964
- package/server/database/db.js +895 -895
- package/server/database/json-store.js +197 -197
- package/server/gemini-cli.js +550 -550
- package/server/gemini-response-handler.js +79 -79
- package/server/index.js +111 -81
- package/server/load-env.js +35 -35
- package/server/middleware/auth.js +155 -155
- package/server/modules/orchestration/a2a/adapter-registry.ts +108 -108
- package/server/modules/orchestration/a2a/adapters/abstract-a2a.adapter.ts +63 -63
- package/server/modules/orchestration/a2a/adapters/claude-code.adapter.ts +286 -286
- package/server/modules/orchestration/a2a/adapters/codex.adapter.ts +244 -244
- package/server/modules/orchestration/a2a/adapters/cursor.adapter.ts +249 -249
- package/server/modules/orchestration/a2a/adapters/gemini.adapter.ts +248 -248
- package/server/modules/orchestration/a2a/adapters/json-event.adapter.ts +100 -100
- package/server/modules/orchestration/a2a/adapters/opencode.adapter.ts +248 -248
- package/server/modules/orchestration/a2a/adapters/qwen.adapter.ts +248 -248
- package/server/modules/orchestration/a2a/agent-card.ts +55 -55
- package/server/modules/orchestration/a2a/routes.ts +590 -590
- package/server/modules/orchestration/a2a/task-store.ts +178 -178
- package/server/modules/orchestration/a2a/types.ts +126 -126
- package/server/modules/orchestration/a2a/validator.ts +113 -113
- package/server/modules/orchestration/hermes/hermes.routes.ts +642 -642
- package/server/modules/orchestration/index.ts +101 -101
- package/server/modules/orchestration/preview/port-watcher.ts +112 -112
- package/server/modules/orchestration/preview/preview-proxy.ts +60 -60
- package/server/modules/orchestration/preview/types.ts +19 -19
- package/server/modules/orchestration/security/permission-policy.ts +401 -401
- package/server/modules/orchestration/tasks/orchestration-task-store.ts +41 -41
- package/server/modules/orchestration/tasks/orchestration-task.routes.ts +64 -64
- package/server/modules/orchestration/tasks/orchestration-task.service.ts +209 -209
- package/server/modules/orchestration/tasks/orchestration-task.types.ts +40 -40
- package/server/modules/orchestration/tasks/task-run-graph.ts +155 -155
- package/server/modules/orchestration/workflows/approval-queue.ts +106 -106
- package/server/modules/orchestration/workflows/built-in-workflows.ts +127 -127
- package/server/modules/orchestration/workflows/context-packet.ts +186 -186
- package/server/modules/orchestration/workflows/handoff-artifact.ts +175 -175
- package/server/modules/orchestration/workflows/workflow-fallback-policy.ts +161 -161
- package/server/modules/orchestration/workflows/workflow-replay.ts +254 -254
- package/server/modules/orchestration/workflows/workflow-runner.ts +2070 -2070
- package/server/modules/orchestration/workflows/workflow-store.ts +97 -97
- package/server/modules/orchestration/workflows/workflow-templates.ts +272 -272
- package/server/modules/orchestration/workflows/workflow-trace.ts +424 -424
- package/server/modules/orchestration/workflows/workflow.routes.ts +586 -586
- package/server/modules/orchestration/workflows/workflow.types.ts +111 -111
- package/server/modules/orchestration/workflows/workspace-target.ts +122 -122
- package/server/modules/orchestration/workspace/docker-workspace.ts +136 -136
- package/server/modules/orchestration/workspace/path-safety.ts +55 -55
- package/server/modules/orchestration/workspace/types.ts +52 -52
- package/server/modules/orchestration/workspace/workspace-manager.ts +102 -102
- package/server/modules/orchestration/workspace/worktree-workspace.ts +126 -126
- package/server/modules/providers/index.ts +2 -2
- package/server/modules/providers/list/claude/claude-auth.provider.ts +146 -146
- package/server/modules/providers/list/claude/claude-mcp.provider.ts +135 -135
- package/server/modules/providers/list/claude/claude-sessions.provider.ts +306 -306
- package/server/modules/providers/list/claude/claude.provider.ts +15 -15
- package/server/modules/providers/list/codex/codex-auth.provider.ts +117 -117
- package/server/modules/providers/list/codex/codex-mcp.provider.ts +135 -135
- package/server/modules/providers/list/codex/codex-sessions.provider.ts +319 -319
- package/server/modules/providers/list/codex/codex.provider.ts +15 -15
- package/server/modules/providers/list/cursor/cursor-auth.provider.ts +147 -147
- package/server/modules/providers/list/cursor/cursor-mcp.provider.ts +108 -108
- package/server/modules/providers/list/cursor/cursor-sessions.provider.ts +421 -421
- package/server/modules/providers/list/cursor/cursor.provider.ts +15 -15
- package/server/modules/providers/list/gemini/gemini-auth.provider.ts +173 -173
- package/server/modules/providers/list/gemini/gemini-mcp.provider.ts +110 -110
- package/server/modules/providers/list/gemini/gemini-sessions.provider.ts +227 -227
- package/server/modules/providers/list/gemini/gemini.provider.ts +15 -15
- package/server/modules/providers/list/opencode/opencode-auth.provider.ts +131 -131
- package/server/modules/providers/list/opencode/opencode-mcp.provider.ts +126 -126
- package/server/modules/providers/list/opencode/opencode-sessions.provider.ts +286 -286
- package/server/modules/providers/list/opencode/opencode.provider.ts +29 -29
- package/server/modules/providers/list/qwen/qwen-auth.provider.ts +146 -146
- package/server/modules/providers/list/qwen/qwen-mcp.provider.ts +114 -114
- package/server/modules/providers/list/qwen/qwen-sessions.provider.ts +265 -265
- package/server/modules/providers/list/qwen/qwen.provider.ts +21 -21
- package/server/modules/providers/provider.registry.ts +40 -40
- package/server/modules/providers/provider.routes.ts +891 -891
- package/server/modules/providers/services/mcp.service.ts +86 -86
- package/server/modules/providers/services/provider-auth.service.ts +26 -26
- package/server/modules/providers/services/sessions.service.ts +45 -45
- package/server/modules/providers/shared/base/abstract.provider.ts +20 -20
- package/server/modules/providers/shared/mcp/mcp.provider.ts +151 -151
- package/server/modules/providers/shared/provider-configs.ts +142 -142
- package/server/modules/providers/tests/mcp.test.ts +293 -293
- package/server/openai-codex.js +462 -462
- package/server/opencode-cli.js +491 -491
- package/server/opencode-response-handler.js +111 -111
- package/server/projects.js +3008 -3008
- package/server/qwen-code-cli.js +410 -410
- package/server/qwen-response-handler.js +73 -73
- package/server/routes/agent.js +1435 -1435
- package/server/routes/auth.js +142 -142
- package/server/routes/codex.js +20 -20
- package/server/routes/commands.js +538 -538
- package/server/routes/cursor.js +61 -61
- package/server/routes/diagnostics.js +41 -41
- package/server/routes/gemini.js +25 -25
- package/server/routes/git.js +1641 -1641
- package/server/routes/live-view.js +387 -387
- package/server/routes/mcp-utils.js +13 -13
- package/server/routes/messages.js +50 -50
- package/server/routes/network.js +117 -117
- package/server/routes/platformization.js +182 -182
- package/server/routes/plugins.js +320 -320
- package/server/routes/production-agent-loop.js +90 -90
- package/server/routes/projects.js +911 -911
- package/server/routes/public-api.js +34 -34
- package/server/routes/qwen.js +27 -27
- package/server/routes/remote.js +50 -50
- package/server/routes/settings.js +321 -321
- package/server/routes/telegram.js +140 -140
- package/server/routes/user.js +125 -125
- package/server/routes/webhooks.js +63 -63
- package/server/services/control-room.js +102 -102
- package/server/services/diagnostics.js +165 -165
- package/server/services/external-access.js +375 -375
- package/server/services/hermes-gateway.js +1562 -1562
- package/server/services/hermes-install-jobs.js +729 -729
- package/server/services/install-jobs.js +715 -715
- package/server/services/live-view.js +956 -956
- package/server/services/managed-runtimes.js +493 -493
- package/server/services/model-registry.js +144 -144
- package/server/services/notification-orchestrator.js +365 -365
- package/server/services/notification-taxonomy.js +204 -204
- package/server/services/platformization.js +773 -773
- package/server/services/production-agent-loop.js +248 -248
- package/server/services/provider-cli-versions.js +149 -149
- package/server/services/provider-credentials.js +189 -189
- package/server/services/provider-models.js +396 -396
- package/server/services/public-api-manifest.js +190 -190
- package/server/services/remote-connection.js +127 -127
- package/server/services/runtime-manager.js +323 -323
- package/server/services/startup-update.js +234 -234
- package/server/services/telegram/bot.js +331 -331
- package/server/services/telegram/control-center.js +979 -979
- package/server/services/telegram/telegram-http-client.js +151 -151
- package/server/services/telegram/translations.js +340 -340
- package/server/services/vapid-keys.js +36 -36
- package/server/services/webhooks.js +216 -216
- package/server/sessionManager.js +225 -225
- package/server/shared/interfaces.ts +54 -54
- package/server/shared/types.ts +172 -172
- package/server/shared/utils.ts +193 -193
- package/server/tsconfig.json +36 -36
- package/server/utils/colors.js +21 -21
- package/server/utils/commandParser.js +305 -305
- package/server/utils/frontmatter.js +18 -18
- package/server/utils/gitConfig.js +34 -34
- package/server/utils/plugin-loader.js +457 -457
- package/server/utils/plugin-process-manager.js +185 -185
- package/server/utils/port-access.js +209 -209
- package/server/utils/runtime-paths.js +37 -37
- package/server/utils/url-detection.js +71 -71
- package/server/vite-daemon.js +79 -79
- package/shared/modelConstants.js +161 -161
- 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;
|