@pixelbyte-software/pixcode 1.54.9 → 1.54.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (288) hide show
  1. package/CODE_OF_CONDUCT.md +41 -41
  2. package/CONTRIBUTING.md +156 -156
  3. package/LICENSE +21 -718
  4. package/README.de.md +169 -169
  5. package/README.ja.md +167 -167
  6. package/README.ko.md +167 -167
  7. package/README.md +435 -418
  8. package/README.ru.md +169 -169
  9. package/README.tr.md +298 -298
  10. package/README.zh-CN.md +167 -167
  11. package/SECURITY.md +46 -46
  12. package/dist/api-automation.html +110 -110
  13. package/dist/api-docs.html +548 -548
  14. package/dist/assets/{index-Bm6uAqKU.js → index-Bcqs_1vc.js} +114 -114
  15. package/dist/clear-cache.html +85 -85
  16. package/dist/convert-icons.md +52 -52
  17. package/dist/docs.html +308 -308
  18. package/dist/features.html +133 -133
  19. package/dist/generate-icons.js +48 -48
  20. package/dist/humans.txt +15 -15
  21. package/dist/icons/codex-white.svg +3 -3
  22. package/dist/icons/codex.svg +3 -3
  23. package/dist/icons/cursor-white.svg +11 -11
  24. package/dist/icons/qwen-logo.svg +14 -14
  25. package/dist/index.html +58 -58
  26. package/dist/landing.html +268 -268
  27. package/dist/llms-full.txt +119 -119
  28. package/dist/llms.txt +53 -53
  29. package/dist/manifest.json +60 -60
  30. package/dist/openapi.yaml +1696 -1696
  31. package/dist/orchestration.html +125 -125
  32. package/dist/robots.txt +4 -4
  33. package/dist/site.css +692 -692
  34. package/dist/sitemap.xml +51 -51
  35. package/dist/sw.js +132 -132
  36. package/dist-server/server/cli.js +100 -100
  37. package/dist-server/server/daemon/manager.js +33 -33
  38. package/dist-server/server/daemon-manager.js +64 -64
  39. package/dist-server/server/index.js +13 -0
  40. package/dist-server/server/index.js.map +1 -1
  41. package/dist-server/server/routes/agent.js +10 -1
  42. package/dist-server/server/routes/agent.js.map +1 -1
  43. package/dist-server/server/routes/commands.js +25 -25
  44. package/dist-server/server/routes/git.js +17 -17
  45. package/dist-server/server/routes/live-view.js +46 -46
  46. package/dist-server/server/services/public-api-manifest.js +51 -51
  47. package/dist-server/server/sessionManager.js +5 -5
  48. package/dist-server/server/sessionManager.js.map +1 -1
  49. package/package.json +224 -224
  50. package/scripts/fix-node-pty.js +67 -67
  51. package/scripts/github/create-v1.38-issues.mjs +351 -351
  52. package/scripts/github/create-vscode-workbench-issues.mjs +121 -121
  53. package/scripts/smoke/backend-resource-bounds.mjs +152 -152
  54. package/scripts/smoke/changes-panel-layout.mjs +48 -48
  55. package/scripts/smoke/chat-composer-fixed-layout.mjs +55 -55
  56. package/scripts/smoke/chat-message-timeline-order.mjs +41 -41
  57. package/scripts/smoke/chat-realtime-hydration.mjs +44 -44
  58. package/scripts/smoke/chat-session-provider-pools.mjs +35 -35
  59. package/scripts/smoke/chat-session-state.mjs +19 -19
  60. package/scripts/smoke/code-editor-theme.mjs +55 -55
  61. package/scripts/smoke/code-editor-vscode-engine.mjs +91 -91
  62. package/scripts/smoke/command-center-agent-writes.mjs +79 -79
  63. package/scripts/smoke/command-center-non-git.mjs +46 -46
  64. package/scripts/smoke/context-packet.mjs +43 -43
  65. package/scripts/smoke/control-room-ux-redesign.mjs +91 -91
  66. package/scripts/smoke/daemon-entrypoint.mjs +20 -20
  67. package/scripts/smoke/default-landing-routing.mjs +33 -33
  68. package/scripts/smoke/desktop-native-notifications.mjs +30 -30
  69. package/scripts/smoke/desktop-tray-icon.mjs +33 -33
  70. package/scripts/smoke/discord-release-workflow.mjs +24 -24
  71. package/scripts/smoke/git-install-update.mjs +255 -255
  72. package/scripts/smoke/handoff-artifact-protocol.mjs +50 -50
  73. package/scripts/smoke/live-view-diagnostics.mjs +53 -53
  74. package/scripts/smoke/live-view-environment.mjs +92 -92
  75. package/scripts/smoke/live-view-integration.mjs +450 -450
  76. package/scripts/smoke/mac-desktop-runtime.mjs +37 -37
  77. package/scripts/smoke/mobile-tunnel-guidance.mjs +29 -29
  78. package/scripts/smoke/mobile-ux.mjs +76 -76
  79. package/scripts/smoke/model-registry.mjs +36 -36
  80. package/scripts/smoke/multi-project-ui.mjs +45 -45
  81. package/scripts/smoke/multi-worker-slots.mjs +42 -42
  82. package/scripts/smoke/notification-center.mjs +87 -87
  83. package/scripts/smoke/notification-inapp-preference.mjs +23 -23
  84. package/scripts/smoke/notification-taxonomy.mjs +58 -58
  85. package/scripts/smoke/orchestration-api.mjs +172 -172
  86. package/scripts/smoke/orchestration-execution-dashboard.mjs +33 -33
  87. package/scripts/smoke/orchestration-live-run.mjs +176 -176
  88. package/scripts/smoke/orchestration-mobile-scroll.mjs +29 -29
  89. package/scripts/smoke/orchestration-model-sync.mjs +30 -30
  90. package/scripts/smoke/orchestration-permission-fallback.mjs +34 -34
  91. package/scripts/smoke/orchestration-runtime-guards.mjs +48 -48
  92. package/scripts/smoke/orchestration-user-facing-output.mjs +25 -25
  93. package/scripts/smoke/permission-policy.mjs +50 -50
  94. package/scripts/smoke/pixcode-workbench-1-48.mjs +167 -167
  95. package/scripts/smoke/provider-models-opencode-live.mjs +66 -66
  96. package/scripts/smoke/provider-rest-api.mjs +124 -124
  97. package/scripts/smoke/provider-selection-status.mjs +52 -52
  98. package/scripts/smoke/run-state-refresh.mjs +52 -52
  99. package/scripts/smoke/runtime-manager.mjs +99 -99
  100. package/scripts/smoke/shell-manual-disconnect.mjs +30 -30
  101. package/scripts/smoke/side-panel-editor-layout.mjs +34 -34
  102. package/scripts/smoke/static-root-routing.mjs +21 -21
  103. package/scripts/smoke/strict-handoff-compact.mjs +60 -60
  104. package/scripts/smoke/taskmaster-config.mjs +24 -24
  105. package/scripts/smoke/taskmaster-execution-telegram.mjs +3 -3
  106. package/scripts/smoke/taskmaster-onboarding.mjs +3 -3
  107. package/scripts/smoke/taskmaster-run-graph.mjs +3 -3
  108. package/scripts/smoke/telegram-control.mjs +331 -331
  109. package/scripts/smoke/tunnel-persistence.mjs +56 -56
  110. package/scripts/smoke/update-issue-progress.mjs +69 -69
  111. package/scripts/smoke/update-ux.mjs +55 -55
  112. package/scripts/smoke/v138-completion.mjs +132 -132
  113. package/scripts/smoke/v138-desktop-release-hardening.mjs +69 -69
  114. package/scripts/smoke/v138-diagnostics.mjs +63 -63
  115. package/scripts/smoke/v138-issue-planner.mjs +33 -33
  116. package/scripts/smoke/v143-remote-control.mjs +76 -76
  117. package/scripts/smoke/v144-production-loop.mjs +47 -47
  118. package/scripts/smoke/v145-platformization.mjs +46 -46
  119. package/scripts/smoke/v146-control-room-ui.mjs +150 -150
  120. package/scripts/smoke/version-modal-autoshow.mjs +29 -29
  121. package/scripts/smoke/vscode-workbench-layout.mjs +63 -63
  122. package/scripts/smoke/vscode-workbench-polish.mjs +461 -461
  123. package/scripts/smoke/workflow-fallback-replay.mjs +56 -56
  124. package/scripts/smoke/workflow-templates.mjs +43 -43
  125. package/scripts/smoke/workflow-trace-timeline.mjs +46 -46
  126. package/scripts/update-git-install.mjs +298 -298
  127. package/server/claude-sdk.js +927 -927
  128. package/server/cli.js +1106 -1106
  129. package/server/constants/config.js +4 -4
  130. package/server/cursor-cli.js +344 -344
  131. package/server/daemon/manager.js +563 -563
  132. package/server/daemon-manager.js +964 -964
  133. package/server/database/db.js +988 -988
  134. package/server/database/json-store.js +197 -197
  135. package/server/gemini-cli.js +550 -550
  136. package/server/gemini-response-handler.js +79 -79
  137. package/server/index.js +5671 -5659
  138. package/server/load-env.js +35 -35
  139. package/server/middleware/account-lockout.js +112 -112
  140. package/server/middleware/auth.js +277 -277
  141. package/server/middleware/rate-limiter.js +87 -87
  142. package/server/modules/orchestration/a2a/adapter-registry.ts +108 -108
  143. package/server/modules/orchestration/a2a/adapters/abstract-a2a.adapter.ts +63 -63
  144. package/server/modules/orchestration/a2a/adapters/claude-code.adapter.ts +286 -286
  145. package/server/modules/orchestration/a2a/adapters/codex.adapter.ts +244 -244
  146. package/server/modules/orchestration/a2a/adapters/cursor.adapter.ts +249 -249
  147. package/server/modules/orchestration/a2a/adapters/gemini.adapter.ts +248 -248
  148. package/server/modules/orchestration/a2a/adapters/json-event.adapter.test.ts +60 -60
  149. package/server/modules/orchestration/a2a/adapters/json-event.adapter.ts +101 -101
  150. package/server/modules/orchestration/a2a/adapters/opencode.adapter.ts +248 -248
  151. package/server/modules/orchestration/a2a/adapters/qwen.adapter.ts +248 -248
  152. package/server/modules/orchestration/a2a/agent-card.ts +55 -55
  153. package/server/modules/orchestration/a2a/auth.middleware.ts +29 -29
  154. package/server/modules/orchestration/a2a/bus.ts +46 -46
  155. package/server/modules/orchestration/a2a/routes.ts +586 -586
  156. package/server/modules/orchestration/a2a/task-dispatcher.ts +345 -345
  157. package/server/modules/orchestration/a2a/task-store.ts +178 -178
  158. package/server/modules/orchestration/a2a/types.ts +126 -126
  159. package/server/modules/orchestration/a2a/validator.ts +113 -113
  160. package/server/modules/orchestration/index.ts +99 -99
  161. package/server/modules/orchestration/preview/port-watcher.ts +112 -112
  162. package/server/modules/orchestration/preview/preview-proxy.ts +60 -60
  163. package/server/modules/orchestration/preview/types.ts +19 -19
  164. package/server/modules/orchestration/security/permission-policy.ts +401 -401
  165. package/server/modules/orchestration/tasks/orchestration-task-store.ts +41 -41
  166. package/server/modules/orchestration/tasks/orchestration-task.routes.ts +81 -81
  167. package/server/modules/orchestration/tasks/orchestration-task.service.ts +201 -201
  168. package/server/modules/orchestration/tasks/orchestration-task.types.ts +40 -40
  169. package/server/modules/orchestration/tasks/task-run-graph.ts +155 -155
  170. package/server/modules/orchestration/workflows/approval-queue.ts +106 -106
  171. package/server/modules/orchestration/workflows/built-in-workflows.ts +127 -127
  172. package/server/modules/orchestration/workflows/context-packet.ts +186 -186
  173. package/server/modules/orchestration/workflows/handoff-artifact.ts +175 -175
  174. package/server/modules/orchestration/workflows/workflow-fallback-policy.ts +161 -161
  175. package/server/modules/orchestration/workflows/workflow-replay.ts +254 -254
  176. package/server/modules/orchestration/workflows/workflow-runner.ts +2062 -2062
  177. package/server/modules/orchestration/workflows/workflow-store.ts +97 -97
  178. package/server/modules/orchestration/workflows/workflow-templates.ts +272 -272
  179. package/server/modules/orchestration/workflows/workflow-trace.ts +424 -424
  180. package/server/modules/orchestration/workflows/workflow.routes.ts +586 -586
  181. package/server/modules/orchestration/workflows/workflow.types.ts +111 -111
  182. package/server/modules/orchestration/workflows/workspace-target.ts +122 -122
  183. package/server/modules/orchestration/workspace/docker-workspace.ts +136 -136
  184. package/server/modules/orchestration/workspace/path-safety.ts +55 -55
  185. package/server/modules/orchestration/workspace/types.ts +52 -52
  186. package/server/modules/orchestration/workspace/workspace-manager.ts +102 -102
  187. package/server/modules/orchestration/workspace/worktree-workspace.ts +126 -126
  188. package/server/modules/providers/index.ts +2 -2
  189. package/server/modules/providers/list/claude/claude-auth.provider.ts +146 -146
  190. package/server/modules/providers/list/claude/claude-mcp.provider.ts +135 -135
  191. package/server/modules/providers/list/claude/claude-sessions.provider.ts +306 -306
  192. package/server/modules/providers/list/claude/claude.provider.ts +15 -15
  193. package/server/modules/providers/list/codex/codex-auth.provider.ts +117 -117
  194. package/server/modules/providers/list/codex/codex-mcp.provider.ts +135 -135
  195. package/server/modules/providers/list/codex/codex-sessions.provider.ts +319 -319
  196. package/server/modules/providers/list/codex/codex.provider.ts +15 -15
  197. package/server/modules/providers/list/cursor/cursor-auth.provider.ts +147 -147
  198. package/server/modules/providers/list/cursor/cursor-mcp.provider.ts +108 -108
  199. package/server/modules/providers/list/cursor/cursor-sessions.provider.ts +421 -421
  200. package/server/modules/providers/list/cursor/cursor.provider.ts +15 -15
  201. package/server/modules/providers/list/gemini/gemini-auth.provider.ts +173 -173
  202. package/server/modules/providers/list/gemini/gemini-mcp.provider.ts +110 -110
  203. package/server/modules/providers/list/gemini/gemini-sessions.provider.ts +227 -227
  204. package/server/modules/providers/list/gemini/gemini.provider.ts +15 -15
  205. package/server/modules/providers/list/opencode/opencode-auth.provider.ts +131 -131
  206. package/server/modules/providers/list/opencode/opencode-sessions.provider.ts +286 -286
  207. package/server/modules/providers/list/qwen/qwen-auth.provider.ts +146 -146
  208. package/server/modules/providers/list/qwen/qwen-sessions.provider.ts +265 -265
  209. package/server/modules/providers/provider.registry.ts +40 -40
  210. package/server/modules/providers/provider.routes.ts +982 -982
  211. package/server/modules/providers/services/mcp.service.ts +86 -86
  212. package/server/modules/providers/services/provider-auth.service.ts +26 -26
  213. package/server/modules/providers/services/sessions.service.ts +45 -45
  214. package/server/modules/providers/shared/base/abstract.provider.ts +20 -20
  215. package/server/modules/providers/shared/mcp/mcp.provider.ts +151 -151
  216. package/server/modules/providers/tests/mcp.test.ts +293 -293
  217. package/server/openai-codex.js +468 -468
  218. package/server/opencode-cli.js +491 -491
  219. package/server/opencode-response-handler.js +111 -111
  220. package/server/projects.js +3135 -3135
  221. package/server/qwen-code-cli.js +410 -410
  222. package/server/routes/agent.js +1462 -1451
  223. package/server/routes/auth.js +298 -298
  224. package/server/routes/codex.js +20 -20
  225. package/server/routes/commands.js +581 -581
  226. package/server/routes/cursor.js +61 -61
  227. package/server/routes/diagnostics.js +44 -44
  228. package/server/routes/gemini.js +25 -25
  229. package/server/routes/git.js +1822 -1822
  230. package/server/routes/live-view.js +434 -434
  231. package/server/routes/mcp-utils.js +13 -13
  232. package/server/routes/messages.js +97 -97
  233. package/server/routes/network.js +143 -143
  234. package/server/routes/platformization.js +231 -231
  235. package/server/routes/plugins.js +690 -690
  236. package/server/routes/production-agent-loop.js +90 -90
  237. package/server/routes/projects.js +918 -918
  238. package/server/routes/public-api.js +34 -34
  239. package/server/routes/qwen.js +27 -27
  240. package/server/routes/remote.js +56 -56
  241. package/server/routes/settings.js +321 -321
  242. package/server/routes/telegram.js +163 -163
  243. package/server/routes/user.js +125 -125
  244. package/server/routes/webhooks.js +63 -63
  245. package/server/services/control-room.js +102 -102
  246. package/server/services/diagnostics.js +165 -165
  247. package/server/services/external-access.js +403 -403
  248. package/server/services/install-jobs.js +715 -715
  249. package/server/services/live-view.js +956 -956
  250. package/server/services/managed-runtimes.js +493 -493
  251. package/server/services/model-registry.js +144 -144
  252. package/server/services/notification-orchestrator.js +365 -365
  253. package/server/services/notification-taxonomy.js +204 -204
  254. package/server/services/platformization.js +1052 -1052
  255. package/server/services/production-agent-loop.js +248 -248
  256. package/server/services/provider-cli-versions.js +149 -149
  257. package/server/services/provider-credentials.js +189 -189
  258. package/server/services/provider-models.js +396 -396
  259. package/server/services/public-api-manifest.js +181 -181
  260. package/server/services/qr-login.js +126 -126
  261. package/server/services/remote-connection.js +127 -127
  262. package/server/services/runtime-manager.js +323 -323
  263. package/server/services/startup-update.js +259 -259
  264. package/server/services/telegram/bot.js +393 -393
  265. package/server/services/telegram/control-center.js +2453 -2453
  266. package/server/services/telegram/telegram-gateway.js +314 -314
  267. package/server/services/telegram/telegram-http-client.js +201 -201
  268. package/server/services/telegram/translations.js +470 -470
  269. package/server/services/webhooks.js +216 -216
  270. package/server/sessionManager.js +225 -225
  271. package/server/setup-wizard.js +283 -283
  272. package/server/shared/interfaces.ts +54 -54
  273. package/server/shared/types.ts +172 -172
  274. package/server/shared/utils.ts +193 -193
  275. package/server/tsconfig.json +36 -36
  276. package/server/utils/colors.js +21 -21
  277. package/server/utils/commandParser.js +305 -305
  278. package/server/utils/frontmatter.js +18 -18
  279. package/server/utils/gitConfig.js +34 -34
  280. package/server/utils/plugin-loader.js +461 -461
  281. package/server/utils/plugin-process-manager.js +185 -185
  282. package/server/utils/port-access.js +227 -227
  283. package/server/utils/runtime-paths.js +37 -37
  284. package/server/utils/security-log.js +84 -84
  285. package/server/utils/url-detection.js +71 -71
  286. package/server/vite-daemon.js +79 -79
  287. package/shared/modelConstants.js +161 -161
  288. package/shared/networkHosts.js +22 -22
@@ -1,461 +1,461 @@
1
- import fs from 'fs';
2
- import path from 'path';
3
- import os from 'os';
4
- import { spawn } from 'child_process';
5
-
6
- import { securityLog } from './security-log.js';
7
-
8
- const PLUGINS_DIR = path.join(os.homedir(), '.pixcode', 'plugins');
9
- const PLUGINS_CONFIG_PATH = path.join(os.homedir(), '.pixcode', 'plugins.json');
10
-
11
- const REQUIRED_MANIFEST_FIELDS = ['name', 'displayName', 'entry'];
12
-
13
- /** Strip embedded credentials from a repo URL before exposing it to the client. */
14
- function sanitizeRepoUrl(raw) {
15
- try {
16
- const u = new URL(raw);
17
- u.username = '';
18
- u.password = '';
19
- return u.toString().replace(/\/$/, '');
20
- } catch {
21
- // Not a parseable URL (e.g. SSH shorthand) — strip user:pass@ segment
22
- return raw.replace(/\/\/[^@/]+@/, '//');
23
- }
24
- }
25
- const ALLOWED_TYPES = ['react', 'module'];
26
- const ALLOWED_SLOTS = ['tab'];
27
-
28
- export function getPluginsDir() {
29
- if (!fs.existsSync(PLUGINS_DIR)) {
30
- fs.mkdirSync(PLUGINS_DIR, { recursive: true });
31
- }
32
- return PLUGINS_DIR;
33
- }
34
-
35
- export function getPluginsConfig() {
36
- try {
37
- if (fs.existsSync(PLUGINS_CONFIG_PATH)) {
38
- return JSON.parse(fs.readFileSync(PLUGINS_CONFIG_PATH, 'utf-8'));
39
- }
40
- } catch {
41
- // Corrupted config, start fresh
42
- }
43
- return {};
44
- }
45
-
46
- export function savePluginsConfig(config) {
47
- const dir = path.dirname(PLUGINS_CONFIG_PATH);
48
- if (!fs.existsSync(dir)) {
49
- fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
50
- }
51
- fs.writeFileSync(PLUGINS_CONFIG_PATH, JSON.stringify(config, null, 2), { mode: 0o600 });
52
- }
53
-
54
- export function validateManifest(manifest) {
55
- if (!manifest || typeof manifest !== 'object') {
56
- return { valid: false, error: 'Manifest must be a JSON object' };
57
- }
58
-
59
- for (const field of REQUIRED_MANIFEST_FIELDS) {
60
- if (!manifest[field] || typeof manifest[field] !== 'string') {
61
- return { valid: false, error: `Missing or invalid required field: ${field}` };
62
- }
63
- }
64
-
65
- // Sanitize name — only allow alphanumeric, hyphens, underscores
66
- if (!/^[a-zA-Z0-9_-]+$/.test(manifest.name)) {
67
- return { valid: false, error: 'Plugin name must only contain letters, numbers, hyphens, and underscores' };
68
- }
69
-
70
- if (manifest.type && !ALLOWED_TYPES.includes(manifest.type)) {
71
- return { valid: false, error: `Invalid plugin type: ${manifest.type}. Must be one of: ${ALLOWED_TYPES.join(', ')}` };
72
- }
73
-
74
- if (manifest.slot && !ALLOWED_SLOTS.includes(manifest.slot)) {
75
- return { valid: false, error: `Invalid plugin slot: ${manifest.slot}. Must be one of: ${ALLOWED_SLOTS.join(', ')}` };
76
- }
77
-
78
- // Validate entry is a relative path without traversal
79
- if (manifest.entry.includes('..') || path.isAbsolute(manifest.entry)) {
80
- return { valid: false, error: 'Entry must be a relative path without ".."' };
81
- }
82
-
83
- if (manifest.server !== undefined && manifest.server !== null) {
84
- if (typeof manifest.server !== 'string' || manifest.server.includes('..') || path.isAbsolute(manifest.server)) {
85
- return { valid: false, error: 'Server entry must be a relative path string without ".."' };
86
- }
87
- }
88
-
89
- if (manifest.permissions !== undefined) {
90
- if (!Array.isArray(manifest.permissions) || !manifest.permissions.every(p => typeof p === 'string')) {
91
- return { valid: false, error: 'Permissions must be an array of strings' };
92
- }
93
- }
94
-
95
- return { valid: true };
96
- }
97
-
98
- const BUILD_TIMEOUT_MS = 60_000;
99
-
100
- /** Run `npm run build` if the plugin's package.json declares a build script. */
101
- function runBuildIfNeeded(dir, packageJsonPath, onSuccess, onError) {
102
- try {
103
- const pkg = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8'));
104
- if (!pkg.scripts?.build) {
105
- return onSuccess();
106
- }
107
- } catch {
108
- return onSuccess(); // Unreadable package.json — skip build
109
- }
110
-
111
- const buildProcess = spawn('npm', ['run', 'build'], {
112
- cwd: dir,
113
- stdio: ['ignore', 'pipe', 'pipe'],
114
- });
115
-
116
- let stderr = '';
117
- let settled = false;
118
-
119
- const timer = setTimeout(() => {
120
- if (settled) return;
121
- settled = true;
122
- buildProcess.removeAllListeners();
123
- buildProcess.kill();
124
- onError(new Error('npm run build timed out'));
125
- }, BUILD_TIMEOUT_MS);
126
-
127
- buildProcess.stderr.on('data', (data) => { stderr += data.toString(); });
128
-
129
- buildProcess.on('close', (code) => {
130
- if (settled) return;
131
- settled = true;
132
- clearTimeout(timer);
133
- if (code !== 0) {
134
- return onError(new Error(`npm run build failed (exit code ${code}): ${stderr.trim()}`));
135
- }
136
- onSuccess();
137
- });
138
-
139
- buildProcess.on('error', (err) => {
140
- if (settled) return;
141
- settled = true;
142
- clearTimeout(timer);
143
- onError(new Error(`Failed to spawn build: ${err.message}`));
144
- });
145
- }
146
-
147
- export function scanPlugins() {
148
- const pluginsDir = getPluginsDir();
149
- const config = getPluginsConfig();
150
- const plugins = [];
151
-
152
- let entries;
153
- try {
154
- entries = fs.readdirSync(pluginsDir, { withFileTypes: true });
155
- } catch {
156
- return plugins;
157
- }
158
-
159
- const seenNames = new Set();
160
-
161
- for (const entry of entries) {
162
- if (!entry.isDirectory()) continue;
163
- // Skip transient temp directories from in-progress installs
164
- if (entry.name.startsWith('.tmp-')) continue;
165
-
166
- const manifestPath = path.join(pluginsDir, entry.name, 'manifest.json');
167
- if (!fs.existsSync(manifestPath)) continue;
168
-
169
- try {
170
- const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
171
- const validation = validateManifest(manifest);
172
- if (!validation.valid) {
173
- console.warn(`[Plugins] Skipping ${entry.name}: ${validation.error}`);
174
- continue;
175
- }
176
-
177
- // Skip duplicate manifest names
178
- if (seenNames.has(manifest.name)) {
179
- console.warn(`[Plugins] Skipping ${entry.name}: duplicate plugin name "${manifest.name}"`);
180
- continue;
181
- }
182
- seenNames.add(manifest.name);
183
-
184
- // Try to read git remote URL
185
- let repoUrl = null;
186
- try {
187
- const gitConfigPath = path.join(pluginsDir, entry.name, '.git', 'config');
188
- if (fs.existsSync(gitConfigPath)) {
189
- const gitConfig = fs.readFileSync(gitConfigPath, 'utf-8');
190
- const match = gitConfig.match(/url\s*=\s*(.+)/);
191
- if (match) {
192
- repoUrl = match[1].trim().replace(/\.git$/, '');
193
- // Convert SSH URLs to HTTPS
194
- if (repoUrl.startsWith('git@')) {
195
- repoUrl = repoUrl.replace(/^git@([^:]+):/, 'https://$1/');
196
- }
197
- // Strip embedded credentials (e.g. https://user:pass@host/...)
198
- repoUrl = sanitizeRepoUrl(repoUrl);
199
- }
200
- }
201
- } catch { /* ignore */ }
202
-
203
- plugins.push({
204
- name: manifest.name,
205
- displayName: manifest.displayName,
206
- version: manifest.version || '0.0.0',
207
- description: manifest.description || '',
208
- author: manifest.author || '',
209
- icon: manifest.icon || 'Puzzle',
210
- type: manifest.type || 'module',
211
- slot: manifest.slot || 'tab',
212
- entry: manifest.entry,
213
- server: manifest.server || null,
214
- permissions: manifest.permissions || [],
215
- enabled: config[manifest.name]?.enabled !== false, // enabled by default
216
- dirName: entry.name,
217
- repoUrl,
218
- });
219
- } catch (err) {
220
- console.warn(`[Plugins] Failed to read manifest for ${entry.name}:`, err.message);
221
- }
222
- }
223
-
224
- return plugins;
225
- }
226
-
227
- export function getPluginDir(name) {
228
- const plugins = scanPlugins();
229
- const plugin = plugins.find(p => p.name === name);
230
- if (!plugin) return null;
231
- return path.join(getPluginsDir(), plugin.dirName);
232
- }
233
-
234
- export function resolvePluginAssetPath(name, assetPath) {
235
- const pluginDir = getPluginDir(name);
236
- if (!pluginDir) return null;
237
-
238
- const resolved = path.resolve(pluginDir, assetPath);
239
-
240
- // Prevent path traversal — canonicalize via realpath to defeat symlink bypasses
241
- if (!fs.existsSync(resolved)) return null;
242
-
243
- const realResolved = fs.realpathSync(resolved);
244
- const realPluginDir = fs.realpathSync(pluginDir);
245
- if (!realResolved.startsWith(realPluginDir + path.sep) && realResolved !== realPluginDir) {
246
- return null;
247
- }
248
-
249
- return realResolved;
250
- }
251
-
252
- export function installPluginFromGit(url) {
253
- return new Promise((resolve, reject) => {
254
- if (typeof url !== 'string' || !url.trim()) {
255
- return reject(new Error('Invalid URL: must be a non-empty string'));
256
- }
257
- if (url.startsWith('-')) {
258
- return reject(new Error('Invalid URL: must not start with "-"'));
259
- }
260
-
261
- // Extract repo name from URL for directory name
262
- const urlClean = url.replace(/\.git$/, '').replace(/\/$/, '');
263
- const repoName = urlClean.split('/').pop();
264
-
265
- if (!repoName || !/^[a-zA-Z0-9_.-]+$/.test(repoName)) {
266
- return reject(new Error('Could not determine a valid directory name from the URL'));
267
- }
268
-
269
- const pluginsDir = getPluginsDir();
270
- const targetDir = path.resolve(pluginsDir, repoName);
271
-
272
- // Ensure the resolved target directory stays within the plugins directory
273
- if (!targetDir.startsWith(pluginsDir + path.sep)) {
274
- return reject(new Error('Invalid plugin directory path'));
275
- }
276
-
277
- if (fs.existsSync(targetDir)) {
278
- return reject(new Error(`Plugin directory "${repoName}" already exists`));
279
- }
280
-
281
- // Clone into a temp directory so scanPlugins() never sees a partially-installed plugin
282
- const tempDir = fs.mkdtempSync(path.join(pluginsDir, `.tmp-${repoName}-`));
283
-
284
- const cleanupTemp = () => {
285
- try { fs.rmSync(tempDir, { recursive: true, force: true }); } catch {}
286
- };
287
-
288
- const finalize = (manifest) => {
289
- try {
290
- fs.renameSync(tempDir, targetDir);
291
- } catch (err) {
292
- cleanupTemp();
293
- return reject(new Error(`Failed to move plugin into place: ${err.message}`));
294
- }
295
- securityLog('plugin_installed', { reason: manifest.name });
296
- resolve(manifest);
297
- };
298
-
299
- const gitProcess = spawn('git', ['clone', '--depth', '1', '--', url, tempDir], {
300
- stdio: ['ignore', 'pipe', 'pipe'],
301
- });
302
-
303
- let stderr = '';
304
- gitProcess.stderr.on('data', (data) => { stderr += data.toString(); });
305
-
306
- gitProcess.on('close', (code) => {
307
- if (code !== 0) {
308
- cleanupTemp();
309
- securityLog('plugin_install_failed', { reason: `git clone failed: exit code ${code}` });
310
- return reject(new Error(`git clone failed (exit code ${code}): ${stderr.trim()}`));
311
- }
312
-
313
- // Validate manifest exists
314
- const manifestPath = path.join(tempDir, 'manifest.json');
315
- if (!fs.existsSync(manifestPath)) {
316
- cleanupTemp();
317
- return reject(new Error('Cloned repository does not contain a manifest.json'));
318
- }
319
-
320
- let manifest;
321
- try {
322
- manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
323
- } catch {
324
- cleanupTemp();
325
- return reject(new Error('manifest.json is not valid JSON'));
326
- }
327
-
328
- const validation = validateManifest(manifest);
329
- if (!validation.valid) {
330
- cleanupTemp();
331
- return reject(new Error(`Invalid manifest: ${validation.error}`));
332
- }
333
-
334
- // Reject if another installed plugin already uses this name
335
- const existing = scanPlugins().find(p => p.name === manifest.name);
336
- if (existing) {
337
- cleanupTemp();
338
- return reject(new Error(`A plugin named "${manifest.name}" is already installed (in "${existing.dirName}")`));
339
- }
340
-
341
- // Run npm install if package.json exists.
342
- // --ignore-scripts prevents postinstall hooks from executing arbitrary code.
343
- const packageJsonPath = path.join(tempDir, 'package.json');
344
- if (fs.existsSync(packageJsonPath)) {
345
- const npmProcess = spawn('npm', ['install', '--ignore-scripts'], {
346
- cwd: tempDir,
347
- stdio: ['ignore', 'pipe', 'pipe'],
348
- });
349
-
350
- npmProcess.on('close', (npmCode) => {
351
- if (npmCode !== 0) {
352
- cleanupTemp();
353
- return reject(new Error(`npm install for ${repoName} failed (exit code ${npmCode})`));
354
- }
355
- runBuildIfNeeded(tempDir, packageJsonPath, () => finalize(manifest), (err) => { cleanupTemp(); reject(err); });
356
- });
357
-
358
- npmProcess.on('error', (err) => {
359
- cleanupTemp();
360
- reject(err);
361
- });
362
- } else {
363
- finalize(manifest);
364
- }
365
- });
366
-
367
- gitProcess.on('error', (err) => {
368
- cleanupTemp();
369
- reject(new Error(`Failed to spawn git: ${err.message}`));
370
- });
371
- });
372
- }
373
-
374
- export function updatePluginFromGit(name) {
375
- return new Promise((resolve, reject) => {
376
- const pluginDir = getPluginDir(name);
377
- if (!pluginDir) {
378
- return reject(new Error(`Plugin "${name}" not found`));
379
- }
380
-
381
- // Only fast-forward to avoid silent divergence
382
- const gitProcess = spawn('git', ['pull', '--ff-only', '--'], {
383
- cwd: pluginDir,
384
- stdio: ['ignore', 'pipe', 'pipe'],
385
- });
386
-
387
- let stderr = '';
388
- gitProcess.stderr.on('data', (data) => { stderr += data.toString(); });
389
-
390
- gitProcess.on('close', (code) => {
391
- if (code !== 0) {
392
- return reject(new Error(`git pull failed (exit code ${code}): ${stderr.trim()}`));
393
- }
394
-
395
- // Re-validate manifest after update
396
- const manifestPath = path.join(pluginDir, 'manifest.json');
397
- let manifest;
398
- try {
399
- manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
400
- } catch {
401
- return reject(new Error('manifest.json is not valid JSON after update'));
402
- }
403
-
404
- const validation = validateManifest(manifest);
405
- if (!validation.valid) {
406
- return reject(new Error(`Invalid manifest after update: ${validation.error}`));
407
- }
408
-
409
- // Re-run npm install if package.json exists
410
- const packageJsonPath = path.join(pluginDir, 'package.json');
411
- if (fs.existsSync(packageJsonPath)) {
412
- const npmProcess = spawn('npm', ['install', '--ignore-scripts'], {
413
- cwd: pluginDir,
414
- stdio: ['ignore', 'pipe', 'pipe'],
415
- });
416
- npmProcess.on('close', (npmCode) => {
417
- if (npmCode !== 0) {
418
- return reject(new Error(`npm install for ${name} failed (exit code ${npmCode})`));
419
- }
420
- runBuildIfNeeded(pluginDir, packageJsonPath, () => resolve(manifest), (err) => reject(err));
421
- });
422
- npmProcess.on('error', (err) => reject(err));
423
- } else {
424
- resolve(manifest);
425
- }
426
- });
427
-
428
- gitProcess.on('error', (err) => {
429
- reject(new Error(`Failed to spawn git: ${err.message}`));
430
- });
431
- });
432
- }
433
-
434
- export async function uninstallPlugin(name) {
435
- const pluginDir = getPluginDir(name);
436
- if (!pluginDir) {
437
- throw new Error(`Plugin "${name}" not found`);
438
- }
439
-
440
- // On Windows, file handles may be released slightly after process exit.
441
- // Retry a few times with a short delay before giving up.
442
- const MAX_RETRIES = 5;
443
- const RETRY_DELAY_MS = 500;
444
- for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
445
- try {
446
- fs.rmSync(pluginDir, { recursive: true, force: true });
447
- break;
448
- } catch (err) {
449
- if (err.code === 'EBUSY' && attempt < MAX_RETRIES) {
450
- await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY_MS));
451
- } else {
452
- throw err;
453
- }
454
- }
455
- }
456
-
457
- // Remove from config
458
- const config = getPluginsConfig();
459
- delete config[name];
460
- savePluginsConfig(config);
461
- }
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import os from 'os';
4
+ import { spawn } from 'child_process';
5
+
6
+ import { securityLog } from './security-log.js';
7
+
8
+ const PLUGINS_DIR = path.join(os.homedir(), '.pixcode', 'plugins');
9
+ const PLUGINS_CONFIG_PATH = path.join(os.homedir(), '.pixcode', 'plugins.json');
10
+
11
+ const REQUIRED_MANIFEST_FIELDS = ['name', 'displayName', 'entry'];
12
+
13
+ /** Strip embedded credentials from a repo URL before exposing it to the client. */
14
+ function sanitizeRepoUrl(raw) {
15
+ try {
16
+ const u = new URL(raw);
17
+ u.username = '';
18
+ u.password = '';
19
+ return u.toString().replace(/\/$/, '');
20
+ } catch {
21
+ // Not a parseable URL (e.g. SSH shorthand) — strip user:pass@ segment
22
+ return raw.replace(/\/\/[^@/]+@/, '//');
23
+ }
24
+ }
25
+ const ALLOWED_TYPES = ['react', 'module'];
26
+ const ALLOWED_SLOTS = ['tab'];
27
+
28
+ export function getPluginsDir() {
29
+ if (!fs.existsSync(PLUGINS_DIR)) {
30
+ fs.mkdirSync(PLUGINS_DIR, { recursive: true });
31
+ }
32
+ return PLUGINS_DIR;
33
+ }
34
+
35
+ export function getPluginsConfig() {
36
+ try {
37
+ if (fs.existsSync(PLUGINS_CONFIG_PATH)) {
38
+ return JSON.parse(fs.readFileSync(PLUGINS_CONFIG_PATH, 'utf-8'));
39
+ }
40
+ } catch {
41
+ // Corrupted config, start fresh
42
+ }
43
+ return {};
44
+ }
45
+
46
+ export function savePluginsConfig(config) {
47
+ const dir = path.dirname(PLUGINS_CONFIG_PATH);
48
+ if (!fs.existsSync(dir)) {
49
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
50
+ }
51
+ fs.writeFileSync(PLUGINS_CONFIG_PATH, JSON.stringify(config, null, 2), { mode: 0o600 });
52
+ }
53
+
54
+ export function validateManifest(manifest) {
55
+ if (!manifest || typeof manifest !== 'object') {
56
+ return { valid: false, error: 'Manifest must be a JSON object' };
57
+ }
58
+
59
+ for (const field of REQUIRED_MANIFEST_FIELDS) {
60
+ if (!manifest[field] || typeof manifest[field] !== 'string') {
61
+ return { valid: false, error: `Missing or invalid required field: ${field}` };
62
+ }
63
+ }
64
+
65
+ // Sanitize name — only allow alphanumeric, hyphens, underscores
66
+ if (!/^[a-zA-Z0-9_-]+$/.test(manifest.name)) {
67
+ return { valid: false, error: 'Plugin name must only contain letters, numbers, hyphens, and underscores' };
68
+ }
69
+
70
+ if (manifest.type && !ALLOWED_TYPES.includes(manifest.type)) {
71
+ return { valid: false, error: `Invalid plugin type: ${manifest.type}. Must be one of: ${ALLOWED_TYPES.join(', ')}` };
72
+ }
73
+
74
+ if (manifest.slot && !ALLOWED_SLOTS.includes(manifest.slot)) {
75
+ return { valid: false, error: `Invalid plugin slot: ${manifest.slot}. Must be one of: ${ALLOWED_SLOTS.join(', ')}` };
76
+ }
77
+
78
+ // Validate entry is a relative path without traversal
79
+ if (manifest.entry.includes('..') || path.isAbsolute(manifest.entry)) {
80
+ return { valid: false, error: 'Entry must be a relative path without ".."' };
81
+ }
82
+
83
+ if (manifest.server !== undefined && manifest.server !== null) {
84
+ if (typeof manifest.server !== 'string' || manifest.server.includes('..') || path.isAbsolute(manifest.server)) {
85
+ return { valid: false, error: 'Server entry must be a relative path string without ".."' };
86
+ }
87
+ }
88
+
89
+ if (manifest.permissions !== undefined) {
90
+ if (!Array.isArray(manifest.permissions) || !manifest.permissions.every(p => typeof p === 'string')) {
91
+ return { valid: false, error: 'Permissions must be an array of strings' };
92
+ }
93
+ }
94
+
95
+ return { valid: true };
96
+ }
97
+
98
+ const BUILD_TIMEOUT_MS = 60_000;
99
+
100
+ /** Run `npm run build` if the plugin's package.json declares a build script. */
101
+ function runBuildIfNeeded(dir, packageJsonPath, onSuccess, onError) {
102
+ try {
103
+ const pkg = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8'));
104
+ if (!pkg.scripts?.build) {
105
+ return onSuccess();
106
+ }
107
+ } catch {
108
+ return onSuccess(); // Unreadable package.json — skip build
109
+ }
110
+
111
+ const buildProcess = spawn('npm', ['run', 'build'], {
112
+ cwd: dir,
113
+ stdio: ['ignore', 'pipe', 'pipe'],
114
+ });
115
+
116
+ let stderr = '';
117
+ let settled = false;
118
+
119
+ const timer = setTimeout(() => {
120
+ if (settled) return;
121
+ settled = true;
122
+ buildProcess.removeAllListeners();
123
+ buildProcess.kill();
124
+ onError(new Error('npm run build timed out'));
125
+ }, BUILD_TIMEOUT_MS);
126
+
127
+ buildProcess.stderr.on('data', (data) => { stderr += data.toString(); });
128
+
129
+ buildProcess.on('close', (code) => {
130
+ if (settled) return;
131
+ settled = true;
132
+ clearTimeout(timer);
133
+ if (code !== 0) {
134
+ return onError(new Error(`npm run build failed (exit code ${code}): ${stderr.trim()}`));
135
+ }
136
+ onSuccess();
137
+ });
138
+
139
+ buildProcess.on('error', (err) => {
140
+ if (settled) return;
141
+ settled = true;
142
+ clearTimeout(timer);
143
+ onError(new Error(`Failed to spawn build: ${err.message}`));
144
+ });
145
+ }
146
+
147
+ export function scanPlugins() {
148
+ const pluginsDir = getPluginsDir();
149
+ const config = getPluginsConfig();
150
+ const plugins = [];
151
+
152
+ let entries;
153
+ try {
154
+ entries = fs.readdirSync(pluginsDir, { withFileTypes: true });
155
+ } catch {
156
+ return plugins;
157
+ }
158
+
159
+ const seenNames = new Set();
160
+
161
+ for (const entry of entries) {
162
+ if (!entry.isDirectory()) continue;
163
+ // Skip transient temp directories from in-progress installs
164
+ if (entry.name.startsWith('.tmp-')) continue;
165
+
166
+ const manifestPath = path.join(pluginsDir, entry.name, 'manifest.json');
167
+ if (!fs.existsSync(manifestPath)) continue;
168
+
169
+ try {
170
+ const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
171
+ const validation = validateManifest(manifest);
172
+ if (!validation.valid) {
173
+ console.warn(`[Plugins] Skipping ${entry.name}: ${validation.error}`);
174
+ continue;
175
+ }
176
+
177
+ // Skip duplicate manifest names
178
+ if (seenNames.has(manifest.name)) {
179
+ console.warn(`[Plugins] Skipping ${entry.name}: duplicate plugin name "${manifest.name}"`);
180
+ continue;
181
+ }
182
+ seenNames.add(manifest.name);
183
+
184
+ // Try to read git remote URL
185
+ let repoUrl = null;
186
+ try {
187
+ const gitConfigPath = path.join(pluginsDir, entry.name, '.git', 'config');
188
+ if (fs.existsSync(gitConfigPath)) {
189
+ const gitConfig = fs.readFileSync(gitConfigPath, 'utf-8');
190
+ const match = gitConfig.match(/url\s*=\s*(.+)/);
191
+ if (match) {
192
+ repoUrl = match[1].trim().replace(/\.git$/, '');
193
+ // Convert SSH URLs to HTTPS
194
+ if (repoUrl.startsWith('git@')) {
195
+ repoUrl = repoUrl.replace(/^git@([^:]+):/, 'https://$1/');
196
+ }
197
+ // Strip embedded credentials (e.g. https://user:pass@host/...)
198
+ repoUrl = sanitizeRepoUrl(repoUrl);
199
+ }
200
+ }
201
+ } catch { /* ignore */ }
202
+
203
+ plugins.push({
204
+ name: manifest.name,
205
+ displayName: manifest.displayName,
206
+ version: manifest.version || '0.0.0',
207
+ description: manifest.description || '',
208
+ author: manifest.author || '',
209
+ icon: manifest.icon || 'Puzzle',
210
+ type: manifest.type || 'module',
211
+ slot: manifest.slot || 'tab',
212
+ entry: manifest.entry,
213
+ server: manifest.server || null,
214
+ permissions: manifest.permissions || [],
215
+ enabled: config[manifest.name]?.enabled !== false, // enabled by default
216
+ dirName: entry.name,
217
+ repoUrl,
218
+ });
219
+ } catch (err) {
220
+ console.warn(`[Plugins] Failed to read manifest for ${entry.name}:`, err.message);
221
+ }
222
+ }
223
+
224
+ return plugins;
225
+ }
226
+
227
+ export function getPluginDir(name) {
228
+ const plugins = scanPlugins();
229
+ const plugin = plugins.find(p => p.name === name);
230
+ if (!plugin) return null;
231
+ return path.join(getPluginsDir(), plugin.dirName);
232
+ }
233
+
234
+ export function resolvePluginAssetPath(name, assetPath) {
235
+ const pluginDir = getPluginDir(name);
236
+ if (!pluginDir) return null;
237
+
238
+ const resolved = path.resolve(pluginDir, assetPath);
239
+
240
+ // Prevent path traversal — canonicalize via realpath to defeat symlink bypasses
241
+ if (!fs.existsSync(resolved)) return null;
242
+
243
+ const realResolved = fs.realpathSync(resolved);
244
+ const realPluginDir = fs.realpathSync(pluginDir);
245
+ if (!realResolved.startsWith(realPluginDir + path.sep) && realResolved !== realPluginDir) {
246
+ return null;
247
+ }
248
+
249
+ return realResolved;
250
+ }
251
+
252
+ export function installPluginFromGit(url) {
253
+ return new Promise((resolve, reject) => {
254
+ if (typeof url !== 'string' || !url.trim()) {
255
+ return reject(new Error('Invalid URL: must be a non-empty string'));
256
+ }
257
+ if (url.startsWith('-')) {
258
+ return reject(new Error('Invalid URL: must not start with "-"'));
259
+ }
260
+
261
+ // Extract repo name from URL for directory name
262
+ const urlClean = url.replace(/\.git$/, '').replace(/\/$/, '');
263
+ const repoName = urlClean.split('/').pop();
264
+
265
+ if (!repoName || !/^[a-zA-Z0-9_.-]+$/.test(repoName)) {
266
+ return reject(new Error('Could not determine a valid directory name from the URL'));
267
+ }
268
+
269
+ const pluginsDir = getPluginsDir();
270
+ const targetDir = path.resolve(pluginsDir, repoName);
271
+
272
+ // Ensure the resolved target directory stays within the plugins directory
273
+ if (!targetDir.startsWith(pluginsDir + path.sep)) {
274
+ return reject(new Error('Invalid plugin directory path'));
275
+ }
276
+
277
+ if (fs.existsSync(targetDir)) {
278
+ return reject(new Error(`Plugin directory "${repoName}" already exists`));
279
+ }
280
+
281
+ // Clone into a temp directory so scanPlugins() never sees a partially-installed plugin
282
+ const tempDir = fs.mkdtempSync(path.join(pluginsDir, `.tmp-${repoName}-`));
283
+
284
+ const cleanupTemp = () => {
285
+ try { fs.rmSync(tempDir, { recursive: true, force: true }); } catch {}
286
+ };
287
+
288
+ const finalize = (manifest) => {
289
+ try {
290
+ fs.renameSync(tempDir, targetDir);
291
+ } catch (err) {
292
+ cleanupTemp();
293
+ return reject(new Error(`Failed to move plugin into place: ${err.message}`));
294
+ }
295
+ securityLog('plugin_installed', { reason: manifest.name });
296
+ resolve(manifest);
297
+ };
298
+
299
+ const gitProcess = spawn('git', ['clone', '--depth', '1', '--', url, tempDir], {
300
+ stdio: ['ignore', 'pipe', 'pipe'],
301
+ });
302
+
303
+ let stderr = '';
304
+ gitProcess.stderr.on('data', (data) => { stderr += data.toString(); });
305
+
306
+ gitProcess.on('close', (code) => {
307
+ if (code !== 0) {
308
+ cleanupTemp();
309
+ securityLog('plugin_install_failed', { reason: `git clone failed: exit code ${code}` });
310
+ return reject(new Error(`git clone failed (exit code ${code}): ${stderr.trim()}`));
311
+ }
312
+
313
+ // Validate manifest exists
314
+ const manifestPath = path.join(tempDir, 'manifest.json');
315
+ if (!fs.existsSync(manifestPath)) {
316
+ cleanupTemp();
317
+ return reject(new Error('Cloned repository does not contain a manifest.json'));
318
+ }
319
+
320
+ let manifest;
321
+ try {
322
+ manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
323
+ } catch {
324
+ cleanupTemp();
325
+ return reject(new Error('manifest.json is not valid JSON'));
326
+ }
327
+
328
+ const validation = validateManifest(manifest);
329
+ if (!validation.valid) {
330
+ cleanupTemp();
331
+ return reject(new Error(`Invalid manifest: ${validation.error}`));
332
+ }
333
+
334
+ // Reject if another installed plugin already uses this name
335
+ const existing = scanPlugins().find(p => p.name === manifest.name);
336
+ if (existing) {
337
+ cleanupTemp();
338
+ return reject(new Error(`A plugin named "${manifest.name}" is already installed (in "${existing.dirName}")`));
339
+ }
340
+
341
+ // Run npm install if package.json exists.
342
+ // --ignore-scripts prevents postinstall hooks from executing arbitrary code.
343
+ const packageJsonPath = path.join(tempDir, 'package.json');
344
+ if (fs.existsSync(packageJsonPath)) {
345
+ const npmProcess = spawn('npm', ['install', '--ignore-scripts'], {
346
+ cwd: tempDir,
347
+ stdio: ['ignore', 'pipe', 'pipe'],
348
+ });
349
+
350
+ npmProcess.on('close', (npmCode) => {
351
+ if (npmCode !== 0) {
352
+ cleanupTemp();
353
+ return reject(new Error(`npm install for ${repoName} failed (exit code ${npmCode})`));
354
+ }
355
+ runBuildIfNeeded(tempDir, packageJsonPath, () => finalize(manifest), (err) => { cleanupTemp(); reject(err); });
356
+ });
357
+
358
+ npmProcess.on('error', (err) => {
359
+ cleanupTemp();
360
+ reject(err);
361
+ });
362
+ } else {
363
+ finalize(manifest);
364
+ }
365
+ });
366
+
367
+ gitProcess.on('error', (err) => {
368
+ cleanupTemp();
369
+ reject(new Error(`Failed to spawn git: ${err.message}`));
370
+ });
371
+ });
372
+ }
373
+
374
+ export function updatePluginFromGit(name) {
375
+ return new Promise((resolve, reject) => {
376
+ const pluginDir = getPluginDir(name);
377
+ if (!pluginDir) {
378
+ return reject(new Error(`Plugin "${name}" not found`));
379
+ }
380
+
381
+ // Only fast-forward to avoid silent divergence
382
+ const gitProcess = spawn('git', ['pull', '--ff-only', '--'], {
383
+ cwd: pluginDir,
384
+ stdio: ['ignore', 'pipe', 'pipe'],
385
+ });
386
+
387
+ let stderr = '';
388
+ gitProcess.stderr.on('data', (data) => { stderr += data.toString(); });
389
+
390
+ gitProcess.on('close', (code) => {
391
+ if (code !== 0) {
392
+ return reject(new Error(`git pull failed (exit code ${code}): ${stderr.trim()}`));
393
+ }
394
+
395
+ // Re-validate manifest after update
396
+ const manifestPath = path.join(pluginDir, 'manifest.json');
397
+ let manifest;
398
+ try {
399
+ manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
400
+ } catch {
401
+ return reject(new Error('manifest.json is not valid JSON after update'));
402
+ }
403
+
404
+ const validation = validateManifest(manifest);
405
+ if (!validation.valid) {
406
+ return reject(new Error(`Invalid manifest after update: ${validation.error}`));
407
+ }
408
+
409
+ // Re-run npm install if package.json exists
410
+ const packageJsonPath = path.join(pluginDir, 'package.json');
411
+ if (fs.existsSync(packageJsonPath)) {
412
+ const npmProcess = spawn('npm', ['install', '--ignore-scripts'], {
413
+ cwd: pluginDir,
414
+ stdio: ['ignore', 'pipe', 'pipe'],
415
+ });
416
+ npmProcess.on('close', (npmCode) => {
417
+ if (npmCode !== 0) {
418
+ return reject(new Error(`npm install for ${name} failed (exit code ${npmCode})`));
419
+ }
420
+ runBuildIfNeeded(pluginDir, packageJsonPath, () => resolve(manifest), (err) => reject(err));
421
+ });
422
+ npmProcess.on('error', (err) => reject(err));
423
+ } else {
424
+ resolve(manifest);
425
+ }
426
+ });
427
+
428
+ gitProcess.on('error', (err) => {
429
+ reject(new Error(`Failed to spawn git: ${err.message}`));
430
+ });
431
+ });
432
+ }
433
+
434
+ export async function uninstallPlugin(name) {
435
+ const pluginDir = getPluginDir(name);
436
+ if (!pluginDir) {
437
+ throw new Error(`Plugin "${name}" not found`);
438
+ }
439
+
440
+ // On Windows, file handles may be released slightly after process exit.
441
+ // Retry a few times with a short delay before giving up.
442
+ const MAX_RETRIES = 5;
443
+ const RETRY_DELAY_MS = 500;
444
+ for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
445
+ try {
446
+ fs.rmSync(pluginDir, { recursive: true, force: true });
447
+ break;
448
+ } catch (err) {
449
+ if (err.code === 'EBUSY' && attempt < MAX_RETRIES) {
450
+ await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY_MS));
451
+ } else {
452
+ throw err;
453
+ }
454
+ }
455
+ }
456
+
457
+ // Remove from config
458
+ const config = getPluginsConfig();
459
+ delete config[name];
460
+ savePluginsConfig(config);
461
+ }