@oar-sh/oar 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (293) hide show
  1. package/.github/extensions/web-relay/README.md +190 -0
  2. package/.github/extensions/web-relay/extension.mjs +972 -0
  3. package/.github/extensions/web-relay/model-api/model-switching.mjs +338 -0
  4. package/.github/extensions/web-relay/polling/heartbeat.mjs +55 -0
  5. package/.github/extensions/web-relay/polling/polling-loop.mjs +1105 -0
  6. package/.github/extensions/web-relay/runtime/api-client.mjs +34 -0
  7. package/.github/extensions/web-relay/runtime/banner-state.mjs +126 -0
  8. package/.github/extensions/web-relay/runtime/config-loader.mjs +104 -0
  9. package/.github/extensions/web-relay/runtime/debug-log.mjs +27 -0
  10. package/.github/extensions/web-relay/runtime/send-and-wait-errors.mjs +170 -0
  11. package/.github/extensions/web-relay/runtime/session-binding.mjs +78 -0
  12. package/.github/extensions/web-relay/runtime/session-io.mjs +349 -0
  13. package/.github/extensions/web-relay/runtime/session-join-retry.mjs +44 -0
  14. package/.github/extensions/web-relay/runtime/session-registry.mjs +29 -0
  15. package/.github/extensions/web-relay/runtime/session-runtime-manager.mjs +156 -0
  16. package/.github/extensions/web-relay/runtime/session-sync-bridge.mjs +74 -0
  17. package/.github/extensions/web-relay/runtime/worker-websocket-link.mjs +325 -0
  18. package/.github/extensions/web-relay/runtime/workspace-root.mjs +55 -0
  19. package/.github/extensions/web-relay/server-lifecycle/managed-server.mjs +386 -0
  20. package/.github/extensions/web-relay/skills/prompt-context.mjs +160 -0
  21. package/.github/extensions/web-relay/skills/question-bridge.mjs +141 -0
  22. package/.github/extensions/web-relay/skills/question-routing-hooks.mjs +463 -0
  23. package/.github/extensions/web-relay/skills/reasoning-stream.mjs +285 -0
  24. package/.github/extensions/web-relay/skills/subagent-lifecycle.mjs +345 -0
  25. package/.github/extensions/web-relay/skills/tool-activity.mjs +243 -0
  26. package/.github/extensions/web-relay/utils/process-utils.mjs +25 -0
  27. package/.github/extensions/web-relay/utils/token-generation.mjs +58 -0
  28. package/CHANGELOG.md +25 -0
  29. package/LICENSE +21 -0
  30. package/README.md +717 -0
  31. package/bin/bat/oar.bat +46 -0
  32. package/bin/oar.js +665 -0
  33. package/package.json +68 -0
  34. package/server/README.md +1379 -0
  35. package/server/claude-worker/claude-attachments.mjs +134 -0
  36. package/server/claude-worker/claude-preview-tool.mjs +57 -0
  37. package/server/claude-worker/claude-sdk-adapter.mjs +420 -0
  38. package/server/claude-worker/claude-session-process.mjs +2391 -0
  39. package/server/claude-worker/claude-session-test-harness.mjs +236 -0
  40. package/server/claude-worker/claude-session-worker.mjs +186 -0
  41. package/server/claude-worker/claude-transcript-relocator.mjs +189 -0
  42. package/server/claude-worker/claude-turn-publisher.mjs +291 -0
  43. package/server/claude-worker/fixtures/workflow-run-record.json +1 -0
  44. package/server/claude-worker/sdk-message-normalizer.mjs +629 -0
  45. package/server/claude-worker/workflow-progress-digest.mjs +269 -0
  46. package/server/copilot-sdk-runtime.mjs +126 -0
  47. package/server/copilot-worker/copilot-attachments.mjs +97 -0
  48. package/server/copilot-worker/copilot-byok-provider.mjs +121 -0
  49. package/server/copilot-worker/copilot-continuation-signals.mjs +349 -0
  50. package/server/copilot-worker/copilot-plan-board.mjs +110 -0
  51. package/server/copilot-worker/copilot-prompt-context.mjs +86 -0
  52. package/server/copilot-worker/copilot-question-bridge.mjs +216 -0
  53. package/server/copilot-worker/copilot-sdk-adapter.mjs +494 -0
  54. package/server/copilot-worker/copilot-sdk-event-normalizer.mjs +800 -0
  55. package/server/copilot-worker/copilot-sdk-session-process.mjs +1729 -0
  56. package/server/copilot-worker/copilot-sdk-session-worker.mjs +167 -0
  57. package/server/copilot-worker/copilot-sdk-test-harness.mjs +329 -0
  58. package/server/copilot-worker/fixtures/abort-turn.json +588 -0
  59. package/server/copilot-worker/fixtures/ask-user-turn.json +1154 -0
  60. package/server/copilot-worker/fixtures/background-timer-continuation.json +445 -0
  61. package/server/copilot-worker/fixtures/background-timer-turn.json +497 -0
  62. package/server/copilot-worker/fixtures/happy-turn.json +566 -0
  63. package/server/copilot-worker/fixtures/quota-turn.json +647 -0
  64. package/server/copilot-worker/fixtures/reasoning-turn.json +625 -0
  65. package/server/copilot-worker/fixtures/resume-turn.json +700 -0
  66. package/server/copilot-worker/fixtures/subagent-turn.json +1378 -0
  67. package/server/copilot-worker/fixtures/tool-permission-turn.json +1293 -0
  68. package/server/cursor-worker/cursor-ask-user-tool.mjs +83 -0
  69. package/server/cursor-worker/cursor-attachments.mjs +88 -0
  70. package/server/cursor-worker/cursor-context-usage.mjs +85 -0
  71. package/server/cursor-worker/cursor-preview-tool.mjs +44 -0
  72. package/server/cursor-worker/cursor-sdk-adapter.mjs +554 -0
  73. package/server/cursor-worker/cursor-session-worker.mjs +145 -0
  74. package/server/cursor-worker/cursor-subagent-roster.mjs +81 -0
  75. package/server/cursor-worker/cursor-turn-runner.mjs +717 -0
  76. package/server/cursor-worker/sdk-message-normalizer.mjs +485 -0
  77. package/server/db-schema.mjs +932 -0
  78. package/server/features.mjs +106 -0
  79. package/server/grok-worker/acp-client.mjs +434 -0
  80. package/server/grok-worker/acp-host-services.mjs +277 -0
  81. package/server/grok-worker/grok-context-usage.mjs +77 -0
  82. package/server/grok-worker/grok-sdk-adapter.mjs +334 -0
  83. package/server/grok-worker/grok-session-worker.mjs +145 -0
  84. package/server/grok-worker/grok-turn-runner.mjs +523 -0
  85. package/server/grok-worker/sdk-message-normalizer.mjs +311 -0
  86. package/server/migrations/0001-add-structured-answer.mjs +80 -0
  87. package/server/migrations/0002-recent-workspace-roots-path-key.mjs +121 -0
  88. package/server/migrations/0003-push-subscriptions.mjs +87 -0
  89. package/server/public/app/action-confirmations.js +267 -0
  90. package/server/public/app/activity-replay-state.mjs +149 -0
  91. package/server/public/app/api-client.js +1082 -0
  92. package/server/public/app/ask-user-view.js +545 -0
  93. package/server/public/app/attachments-view.js +2112 -0
  94. package/server/public/app/auto-compact-window-options.mjs +59 -0
  95. package/server/public/app/background-tasks-view.mjs +494 -0
  96. package/server/public/app/bootstrap.js +4347 -0
  97. package/server/public/app/claude-auth-ui.js +394 -0
  98. package/server/public/app/claude-thinking-options.mjs +38 -0
  99. package/server/public/app/cli-install-ui.js +455 -0
  100. package/server/public/app/code-copy.mjs +85 -0
  101. package/server/public/app/composer-attachment-cache.mjs +151 -0
  102. package/server/public/app/composer-control-state.mjs +107 -0
  103. package/server/public/app/composer-paste.mjs +132 -0
  104. package/server/public/app/context-tier-options.mjs +68 -0
  105. package/server/public/app/context-usage-view.mjs +338 -0
  106. package/server/public/app/conversation-draft-timestamp-utils.mjs +11 -0
  107. package/server/public/app/conversation-preferences.mjs +91 -0
  108. package/server/public/app/conversation-provider-indicator.mjs +88 -0
  109. package/server/public/app/conversation-view.js +2898 -0
  110. package/server/public/app/cwd-menu-placement.mjs +85 -0
  111. package/server/public/app/cwd-picker.js +605 -0
  112. package/server/public/app/emoji-view.js +101 -0
  113. package/server/public/app/external-link-policy.mjs +80 -0
  114. package/server/public/app/file-preview-navigation.mjs +51 -0
  115. package/server/public/app/font-scaling.js +262 -0
  116. package/server/public/app/git-changes-view.js +339 -0
  117. package/server/public/app/git-diff-model.mjs +109 -0
  118. package/server/public/app/grok-auth-ui.js +387 -0
  119. package/server/public/app/image-downscale.mjs +174 -0
  120. package/server/public/app/in-flight-snapshot.mjs +40 -0
  121. package/server/public/app/infinite-loader.js +164 -0
  122. package/server/public/app/journal-view.js +1141 -0
  123. package/server/public/app/known-cwd-options.mjs +41 -0
  124. package/server/public/app/launch-cwd-path.mjs +23 -0
  125. package/server/public/app/live-message-dedupe.mjs +57 -0
  126. package/server/public/app/message-search-view.js +242 -0
  127. package/server/public/app/model-selector-options.mjs +31 -0
  128. package/server/public/app/model-vision-support.mjs +56 -0
  129. package/server/public/app/new-conversation-model-choice.mjs +67 -0
  130. package/server/public/app/plan-usage-view.mjs +307 -0
  131. package/server/public/app/preview-cards.mjs +273 -0
  132. package/server/public/app/preview-command.mjs +103 -0
  133. package/server/public/app/push-settings.js +370 -0
  134. package/server/public/app/pwa-install.js +456 -0
  135. package/server/public/app/question-schema-view.mjs +71 -0
  136. package/server/public/app/reasoning-effort-labels.mjs +42 -0
  137. package/server/public/app/relay-board-view.js +117 -0
  138. package/server/public/app/relay-dot-state.mjs +69 -0
  139. package/server/public/app/relay-error-ctas.mjs +52 -0
  140. package/server/public/app/relay-prompt-sanitizer.mjs +114 -0
  141. package/server/public/app/relay-thoughts.mjs +98 -0
  142. package/server/public/app/relay-watchdog-policy.mjs +29 -0
  143. package/server/public/app/repo-browser-preferences.mjs +79 -0
  144. package/server/public/app/repo-browser-tree-state.mjs +97 -0
  145. package/server/public/app/router.js +657 -0
  146. package/server/public/app/selection-guard.mjs +93 -0
  147. package/server/public/app/settings-modal.js +1484 -0
  148. package/server/public/app/settings-tabs.js +129 -0
  149. package/server/public/app/slash-autocomplete.mjs +188 -0
  150. package/server/public/app/slash-commands.mjs +183 -0
  151. package/server/public/app/socket-handlers.js +703 -0
  152. package/server/public/app/status-store.mjs +242 -0
  153. package/server/public/app/status-view.mjs +315 -0
  154. package/server/public/app/store.js +1454 -0
  155. package/server/public/app/stream-state.mjs +73 -0
  156. package/server/public/app/streaming-dom-patch.mjs +23 -0
  157. package/server/public/app/sync-outbox.mjs +208 -0
  158. package/server/public/app/tap-actions.js +62 -0
  159. package/server/public/app/thread-order.mjs +54 -0
  160. package/server/public/app/tmux-inspector-view.js +422 -0
  161. package/server/public/app/transcript-separators.mjs +282 -0
  162. package/server/public/app-icon-192.png +0 -0
  163. package/server/public/app-icon-512.png +0 -0
  164. package/server/public/app-icon.svg +4 -0
  165. package/server/public/favicon.ico +0 -0
  166. package/server/public/index.html +5743 -0
  167. package/server/public/manifest.webmanifest +32 -0
  168. package/server/public/sw.js +307 -0
  169. package/server/relay-exit-codes.mjs +4 -0
  170. package/server/relay-self-restart.mjs +170 -0
  171. package/server/relay-tools.md +27 -0
  172. package/server/relay.mjs +1026 -0
  173. package/server/repositories/image-conversation-repository.mjs +317 -0
  174. package/server/repositories/message-repository.mjs +434 -0
  175. package/server/repositories/question-repository.mjs +161 -0
  176. package/server/repositories/session-repository.mjs +320 -0
  177. package/server/repositories/share-visibility-statements.mjs +24 -0
  178. package/server/routes/ask-user-routes.mjs +292 -0
  179. package/server/routes/cache-routes.mjs +44 -0
  180. package/server/routes/git-routes.mjs +92 -0
  181. package/server/routes/messages-routes-test-harness.mjs +111 -0
  182. package/server/routes/messages-routes.mjs +7107 -0
  183. package/server/routes/preview-routes.mjs +108 -0
  184. package/server/routes/push-routes.mjs +59 -0
  185. package/server/routes/relay-board-routes.mjs +268 -0
  186. package/server/routes/sessions-routes.mjs +6150 -0
  187. package/server/server-runtime.mjs +7463 -0
  188. package/server/server.js +24 -0
  189. package/server/services/ask-user-routing-service.mjs +117 -0
  190. package/server/services/cache-rebuild-service.mjs +236 -0
  191. package/server/services/claude-auth-service.mjs +578 -0
  192. package/server/services/claude-context-usage.mjs +240 -0
  193. package/server/services/claude-session-root-service.mjs +228 -0
  194. package/server/services/cli-install-service.mjs +1152 -0
  195. package/server/services/cli-process-runner.mjs +181 -0
  196. package/server/services/cloudflared-tunnel-service.mjs +358 -0
  197. package/server/services/context-snapshot-service.mjs +499 -0
  198. package/server/services/context-usage-view.mjs +160 -0
  199. package/server/services/conversation-preferences-service.mjs +208 -0
  200. package/server/services/cursor-dashboard-usage.mjs +217 -0
  201. package/server/services/cursor-session-root-service.mjs +62 -0
  202. package/server/services/delete-archive-service.mjs +154 -0
  203. package/server/services/drives-path-helpers.mjs +66 -0
  204. package/server/services/fixtures/claude-auth-stub.sh +62 -0
  205. package/server/services/fixtures/cli-install-stub.sh +75 -0
  206. package/server/services/fixtures/grok-stub.sh +86 -0
  207. package/server/services/generated-image-cleanup-service.mjs +114 -0
  208. package/server/services/git-changes-service.mjs +195 -0
  209. package/server/services/github-billing-usage.mjs +133 -0
  210. package/server/services/grok-auth-service.mjs +593 -0
  211. package/server/services/grok-billing-usage.mjs +120 -0
  212. package/server/services/image-context-service.mjs +106 -0
  213. package/server/services/image-operation-service.mjs +135 -0
  214. package/server/services/keyed-mutex-service.mjs +82 -0
  215. package/server/services/mime-sniffer.mjs +118 -0
  216. package/server/services/oar-cli-helpers.mjs +109 -0
  217. package/server/services/oar-state-migration-service.mjs +243 -0
  218. package/server/services/plan-usage-claude.mjs +387 -0
  219. package/server/services/plan-usage-contract.mjs +271 -0
  220. package/server/services/plan-usage-copilot.mjs +421 -0
  221. package/server/services/plan-usage-cursor.mjs +566 -0
  222. package/server/services/plan-usage-grok.mjs +436 -0
  223. package/server/services/plan-usage-service.mjs +487 -0
  224. package/server/services/preview-health-probe.mjs +82 -0
  225. package/server/services/preview-proxy-server.mjs +498 -0
  226. package/server/services/preview-registry-service.mjs +457 -0
  227. package/server/services/preview-static-handler.mjs +237 -0
  228. package/server/services/provider-model-selection.mjs +91 -0
  229. package/server/services/provider-reasoning-effort.mjs +94 -0
  230. package/server/services/push-active-device-service.mjs +57 -0
  231. package/server/services/push-dispatch-service.mjs +459 -0
  232. package/server/services/rate-limit-service.mjs +63 -0
  233. package/server/services/relay-bridge-owner-service.mjs +84 -0
  234. package/server/services/relay-cli-launcher-service.mjs +209 -0
  235. package/server/services/relay-prompt-sanitizer.mjs +137 -0
  236. package/server/services/relay-queue-gate-service.mjs +38 -0
  237. package/server/services/relay-restart-orchestrator-service.mjs +935 -0
  238. package/server/services/relay-singleton-guard.mjs +124 -0
  239. package/server/services/safe-served-content.mjs +57 -0
  240. package/server/services/sdk-session-import-service.mjs +271 -0
  241. package/server/services/sdk-session-sync-service.mjs +207 -0
  242. package/server/services/session-history-refresh-service.mjs +497 -0
  243. package/server/services/session-transcript-service.mjs +419 -0
  244. package/server/services/session-worker-launch-service.mjs +786 -0
  245. package/server/services/session-worker-process-service.mjs +319 -0
  246. package/server/services/session-worker-registry-service.mjs +194 -0
  247. package/server/services/session-worker-stop-service.mjs +141 -0
  248. package/server/services/session-worker-supervisor-service.mjs +898 -0
  249. package/server/services/session-worker-websocket-service.mjs +483 -0
  250. package/server/services/ssh-tunnel-manager-service.mjs +423 -0
  251. package/server/services/status-event-service.mjs +158 -0
  252. package/server/services/tmux-inspector-access-policy.mjs +77 -0
  253. package/server/services/tmux-inspector-socket-service.mjs +191 -0
  254. package/server/services/tmux-inspector-stream-service.mjs +552 -0
  255. package/server/services/tunnel-worker-path-guard.mjs +107 -0
  256. package/server/services/upload-sweep.mjs +60 -0
  257. package/server/services/usage-snapshot-helpers.mjs +209 -0
  258. package/server/services/windows-autostart-service.mjs +115 -0
  259. package/server/services/workspace-root-defaults-service.mjs +34 -0
  260. package/server/services/workspace-root-path-policy.mjs +196 -0
  261. package/server/services/workspace-root-relaunch-service.mjs +132 -0
  262. package/server/services/workspace-symlink-guard.mjs +54 -0
  263. package/server/tools/capture-proxy.mjs +173 -0
  264. package/server/tty-console-bootstrap.mjs +75 -0
  265. package/server/uploads/README.md +36 -0
  266. package/server/uploads/image_input.md +540 -0
  267. package/server/windows-terminal-launcher.mjs +29 -0
  268. package/server/workspace-root.mjs +87 -0
  269. package/shared/ask-user-bridge.mjs +131 -0
  270. package/shared/auto-compact-window.mjs +116 -0
  271. package/shared/background-task-timeout.mjs +52 -0
  272. package/shared/claude-thinking.mjs +101 -0
  273. package/shared/context-window-fallbacks.mjs +148 -0
  274. package/shared/control-poller.mjs +115 -0
  275. package/shared/empty-turn-completion.mjs +20 -0
  276. package/shared/model-catalog-freshness.mjs +11 -0
  277. package/shared/model-descriptors.mjs +118 -0
  278. package/shared/model-id.mjs +147 -0
  279. package/shared/model-refresh.mjs +24 -0
  280. package/shared/openai-reasoning.mjs +13 -0
  281. package/shared/plan-lines.mjs +13 -0
  282. package/shared/preview-instructions.mjs +77 -0
  283. package/shared/preview-tool-core.mjs +191 -0
  284. package/shared/provider-routing.mjs +29 -0
  285. package/shared/question-schema.mjs +315 -0
  286. package/shared/question-timeout.mjs +18 -0
  287. package/shared/stream-emit-gating.mjs +28 -0
  288. package/shared/subagent-run-id.mjs +17 -0
  289. package/shared/thought-cap.mjs +13 -0
  290. package/shared/tool-activity.mjs +98 -0
  291. package/shared/turn-ceiling.mjs +53 -0
  292. package/shared/worker-bootstrap.mjs +44 -0
  293. package/shared/worker-crash-guard.mjs +60 -0
@@ -0,0 +1,1152 @@
1
+ 'use strict';
2
+ import { spawn } from 'child_process';
3
+ import fs from 'fs';
4
+ import os from 'os';
5
+ import path from 'path';
6
+
7
+ import {
8
+ CLI_SPAWN_DISABLED_ERROR,
9
+ MAX_CAPTURED_OUTPUT_CHARS,
10
+ killTree as killProcessTree,
11
+ runToCompletion as runProcessToCompletion,
12
+ stripTerminalEscapes,
13
+ tailOf,
14
+ } from './cli-process-runner.mjs';
15
+
16
+ /**
17
+ * Install / update a provider CLI on the relay host, from the web UI.
18
+ *
19
+ * The trigger is a dead end the relay exists to avoid: a Grok turn fails with
20
+ * `relay.grok-cli-missing` and the only fix is a shell on the host. So the
21
+ * vendor one-liners live here as frozen literals, the relay runs them, streams
22
+ * the output, and binds the resulting binary into the worker spawn environment
23
+ * without a restart.
24
+ *
25
+ * Security shape (docs/plans/relay-cli-install-and-grok-auth.md §6):
26
+ * **nothing a caller sends ever reaches a command.** A request body carries a
27
+ * descriptor id and an action name; both are looked up in the frozen table
28
+ * below, and an id that is not in it is a 400 before any spawn is considered.
29
+ * `curl … | bash` is the vendor's own distribution channel — the control that
30
+ * matters is that the URL is a literal in this file.
31
+ *
32
+ * One install at a time, relay-wide (two installers writing `~/.local/bin`
33
+ * concurrently is asking for it):
34
+ *
35
+ * idle -> running -> success | error | cancelled
36
+ *
37
+ * The three terminal states are *display* states: the session is already
38
+ * released, and the outcome stays readable until the next install or a
39
+ * `cancel()` that resets it to idle.
40
+ */
41
+
42
+ export const CLI_INSTALL_TIMEOUT_MS = 10 * 60 * 1000;
43
+ export const CLI_PROBE_TIMEOUT_MS = 5_000;
44
+ export const CLI_STATUS_TTL_MS = 30_000;
45
+ // Ceiling on install-log broadcasts: four a second is well under what a live
46
+ // log needs to read as live, and far under what a progress-bar installer would
47
+ // otherwise push to every connected socket.
48
+ export const CLI_INSTALL_LOG_FLUSH_MS = 250;
49
+ const KILL_ESCALATION_MS = 2_000;
50
+
51
+ export const CLI_INSTALL_STATES = Object.freeze({
52
+ IDLE: 'idle',
53
+ RUNNING: 'running',
54
+ SUCCESS: 'success',
55
+ ERROR: 'error',
56
+ CANCELLED: 'cancelled',
57
+ });
58
+
59
+ /** Stand-in for the resolved binary in a descriptor's update/migrate argv. */
60
+ const BIN_PLACEHOLDER = '<bin>';
61
+
62
+ // Last-resort version scrape: every descriptor carries its own pattern, and
63
+ // this only runs when that one misses, so a CLI that reworks its --version
64
+ // banner degrades to "installed (version unknown)" instead of "not installed"
65
+ // (plan risk row 1: every scrape degrades, none of them blocks).
66
+ const GENERIC_VERSION_PATTERN = /(\d+\.\d+(?:\.\d+)*)/;
67
+
68
+ /**
69
+ * The frozen descriptor table: the only place an install command exists.
70
+ *
71
+ * Cursor is deliberately absent — `@cursor/sdk` is a pure npm package and the
72
+ * relay never invokes a `cursor-agent` binary, so an install button there would
73
+ * install a CLI nothing runs (plan §2.4). Adding it back later is a data change
74
+ * here, not a code change.
75
+ *
76
+ * Copilot is present but `detectOnly`: its CLI is npm-global under a prefix the
77
+ * relay user cannot write (plan §2.5), so it reports version and path and
78
+ * offers no buttons. Without the entry its settings sub-tab would be the only
79
+ * one with no CLI row at all.
80
+ */
81
+ const CLI_DESCRIPTORS = Object.freeze({
82
+ grok: Object.freeze({
83
+ id: 'grok',
84
+ label: 'Grok CLI',
85
+ binary: 'grok',
86
+ // Bound onto the relay's own environment after a successful install; read
87
+ // by grok-session-worker.mjs and by the ACP model-discovery probe.
88
+ envVar: 'GROK_CLI_COMMAND',
89
+ // The installer lands the binary in ~/.grok/bin and *tries* to symlink it
90
+ // into the first writable ~/.local/bin or /usr/local/bin. All three are
91
+ // searched (and PATH-prepended) so resolution does not depend on which of
92
+ // those happened to be writable.
93
+ extraBinDirs: Object.freeze(['~/.grok/bin', '~/.local/bin', '/usr/local/bin']),
94
+ versionArgs: Object.freeze(['--version']),
95
+ // `grok 1.0.13 (5e9a58528b76) [stable]`
96
+ versionPattern: /grok\s+v?(\d[\w.-]*)/i,
97
+ commands: Object.freeze({
98
+ install: Object.freeze({
99
+ posix: Object.freeze({
100
+ argv: Object.freeze(['bash', '-lc', 'curl -fsSL https://x.ai/cli/install.sh | bash']),
101
+ display: 'curl -fsSL https://x.ai/cli/install.sh | bash',
102
+ targetDir: '~/.grok/bin',
103
+ }),
104
+ win32: Object.freeze({
105
+ argv: Object.freeze(['powershell', '-NoProfile', '-Command', 'irm https://x.ai/cli/install.ps1 | iex']),
106
+ display: 'irm https://x.ai/cli/install.ps1 | iex',
107
+ targetDir: '%USERPROFILE%\\.grok\\bin',
108
+ }),
109
+ }),
110
+ // `installer: "internal"` in the update check: Grok self-updates, so the
111
+ // Update button is never a re-run of the install script.
112
+ update: Object.freeze({
113
+ anyPlatform: Object.freeze({
114
+ argv: Object.freeze([BIN_PLACEHOLDER, 'update']),
115
+ display: 'grok update',
116
+ targetDir: '~/.grok/bin',
117
+ }),
118
+ }),
119
+ }),
120
+ // Machine-readable and exits 0 either way, so the exit code is ignored and
121
+ // only the payload is read.
122
+ checkUpdate: Object.freeze({
123
+ args: Object.freeze(['update', '--check', '--json']),
124
+ parse: (json) => ({
125
+ updateAvailable: json?.updateAvailable === true,
126
+ latestVersion: normalizeText(json?.latestVersion) || null,
127
+ }),
128
+ }),
129
+ }),
130
+ claude: Object.freeze({
131
+ id: 'claude',
132
+ label: 'Claude Code CLI',
133
+ binary: 'claude',
134
+ envVar: 'CLAUDE_CODE_EXECUTABLE',
135
+ extraBinDirs: Object.freeze(['~/.local/bin', '/usr/local/bin']),
136
+ versionArgs: Object.freeze(['--version']),
137
+ // `2.1.251 (Claude Code)`
138
+ versionPattern: /v?(\d[\w.-]*)\s*\(Claude Code\)/i,
139
+ commands: Object.freeze({
140
+ install: Object.freeze({
141
+ posix: Object.freeze({
142
+ argv: Object.freeze(['bash', '-lc', 'curl -fsSL https://claude.ai/install.sh | bash']),
143
+ display: 'curl -fsSL https://claude.ai/install.sh | bash',
144
+ targetDir: '~/.local/bin',
145
+ }),
146
+ win32: Object.freeze({
147
+ argv: Object.freeze(['powershell', '-NoProfile', '-Command', 'irm https://claude.ai/install.ps1 | iex']),
148
+ display: 'irm https://claude.ai/install.ps1 | iex',
149
+ targetDir: '%USERPROFILE%\\.local\\bin',
150
+ }),
151
+ }),
152
+ update: Object.freeze({
153
+ anyPlatform: Object.freeze({
154
+ argv: Object.freeze([BIN_PLACEHOLDER, 'update']),
155
+ display: 'claude update',
156
+ targetDir: '~/.local/bin',
157
+ }),
158
+ }),
159
+ // Anthropic's own prescribed fix for the npm-global-not-writable case
160
+ // `claude doctor` reports (plan §2.3/§5.1): switch to the native build,
161
+ // which takes PATH precedence while the npm copy stays put. Offered by
162
+ // the UI only when the doctor warning is present, never implicitly.
163
+ migrate: Object.freeze({
164
+ anyPlatform: Object.freeze({
165
+ argv: Object.freeze([BIN_PLACEHOLDER, 'install']),
166
+ display: 'claude install',
167
+ targetDir: '~/.local/bin',
168
+ }),
169
+ }),
170
+ }),
171
+ // Read-only, exits 0, and reports install method + path + auto-update
172
+ // health, so the update action is decidable without heuristics.
173
+ doctor: Object.freeze({ args: Object.freeze(['doctor']) }),
174
+ }),
175
+ copilot: Object.freeze({
176
+ id: 'copilot',
177
+ label: 'GitHub Copilot CLI',
178
+ binary: 'copilot',
179
+ envVar: null,
180
+ extraBinDirs: Object.freeze(['~/.local/bin', '/usr/local/bin']),
181
+ versionArgs: Object.freeze(['--version']),
182
+ // `GitHub Copilot CLI 1.0.82.`
183
+ versionPattern: /Copilot CLI\s+v?(\d+\.\d+(?:\.\d+)*)/i,
184
+ detectOnly: true,
185
+ blockedReason: 'Managed with npm on this host — install and update it from a shell.',
186
+ commands: Object.freeze({}),
187
+ }),
188
+ });
189
+
190
+ export const CLI_PROVIDER_IDS = Object.freeze(Object.keys(CLI_DESCRIPTORS));
191
+
192
+ function normalizeText(value) {
193
+ return String(value == null ? '' : value).trim();
194
+ }
195
+
196
+ /**
197
+ * `claude doctor`, verbatim from a host whose npm global folder is not
198
+ * writable:
199
+ *
200
+ * Running: npm-global (2.1.247)
201
+ * Path: /usr/lib/node_modules/@anthropic-ai/claude-code/bin/claude.exe
202
+ * Auto-updates: enabled
203
+ * Last update attempt: failed (no_permissions) — 2026-08-26
204
+ * 1 warning found
205
+ * - Can't auto-update: npm global folder isn't writable
206
+ * Fix: Run claude install to switch to the native installer (no sudo) …
207
+ *
208
+ * Every field is optional: a doctor release that drops or renames a line leaves
209
+ * that field null and the row falls back to the resolved path and `--version`.
210
+ */
211
+ export function parseClaudeDoctor(rawOutput) {
212
+ const text = stripTerminalEscapes(rawOutput);
213
+ if (!normalizeText(text)) return null;
214
+ const running = text.match(/^\s*Running:\s*([^\s(]+)\s*(?:\(([^)]*)\))?/m);
215
+ const warnings = [];
216
+ let inWarnings = false;
217
+ for (const line of text.split('\n')) {
218
+ if (/^\s*\d+\s+warnings?\s+found/i.test(line)) {
219
+ inWarnings = true;
220
+ continue;
221
+ }
222
+ if (!inWarnings) continue;
223
+ const bullet = line.match(/^\s*[-*]\s+(\S.*?)\s*$/);
224
+ if (bullet) {
225
+ warnings.push(bullet[1]);
226
+ continue;
227
+ }
228
+ // An indented follow-on line (" Fix: …") belongs to the warning above it.
229
+ const continuation = line.match(/^\s+(\S.*?)\s*$/);
230
+ if (continuation && warnings.length) {
231
+ warnings[warnings.length - 1] = `${warnings[warnings.length - 1]} ${continuation[1]}`;
232
+ }
233
+ }
234
+ const method = normalizeText(running?.[1]) || null;
235
+ const lastUpdateAttempt = normalizeText(text.match(/^\s*Last update attempt:\s*(.+)$/m)?.[1]) || null;
236
+ const notWritable = /not writable|isn't writable|no_permissions/i.test(
237
+ [...warnings, lastUpdateAttempt || ''].join(' '),
238
+ );
239
+ return {
240
+ method,
241
+ version: normalizeText(running?.[2]) || null,
242
+ path: normalizeText(text.match(/^\s*Path:\s*(.+)$/m)?.[1]) || null,
243
+ autoUpdates: normalizeText(text.match(/^\s*Auto-updates:\s*(.+)$/m)?.[1]) || null,
244
+ lastUpdateAttempt,
245
+ warnings,
246
+ // Pinned as a flag rather than left for the UI to re-derive from prose:
247
+ // this is the one case that gets its own affordance (`claude install`).
248
+ npmGlobalNotWritable: method === 'npm-global' && notWritable,
249
+ };
250
+ }
251
+
252
+ /**
253
+ * File-backed half of the `writeBoundBinaries` seam: merges `cliBinaries` into
254
+ * the relay's config.json and leaves every other key exactly as it was on disk.
255
+ *
256
+ * **A read that fails is never followed by a write.** config.json holds the auth
257
+ * token, the port and the localhost-only flag, and boot deliberately tolerates a
258
+ * config it cannot parse by falling back to defaults (server-runtime.mjs:1540) —
259
+ * so a blind rewrite after a failed read would turn a merely-unparseable file
260
+ * into a valid one containing nothing but `cliBinaries`, and the next boot would
261
+ * mint a fresh auth token and lock out every paired client. A *missing* file is
262
+ * refused for the same reason: recreating it here would recreate it without the
263
+ * token. Losing a CLI binding costs one re-run of the install button; losing the
264
+ * token is not undoable, so the asymmetry decides it.
265
+ *
266
+ * The write itself is tmp-file-then-rename inside the config directory, for the
267
+ * same reason: a crash (or a full disk) part-way through a truncating write
268
+ * leaves a half-written config.json, which is the corrupt-config case above
269
+ * arriving by a different road.
270
+ *
271
+ * Throws on any refusal — the caller keeps the in-memory binding and surfaces
272
+ * the reason (`bindResolvedBinary` logs it), rather than the failure passing
273
+ * silently as a success.
274
+ */
275
+ export function writeCliBinariesToConfigFile(configPath, binaries, { fsImpl = fs, pathImpl = path } = {}) {
276
+ const target = normalizeText(configPath);
277
+ if (!target) throw new Error('no config path to persist the CLI binaries into');
278
+ let onDisk = null;
279
+ try {
280
+ onDisk = JSON.parse(fsImpl.readFileSync(target, 'utf8'));
281
+ } catch (error) {
282
+ throw new Error(`refusing to rewrite ${target}: it could not be read (${error?.message || error})`);
283
+ }
284
+ if (!onDisk || typeof onDisk !== 'object' || Array.isArray(onDisk)) {
285
+ throw new Error(`refusing to rewrite ${target}: it does not hold a JSON object`);
286
+ }
287
+ const merged = { ...onDisk, cliBinaries: { ...binaries } };
288
+ const tmpPath = pathImpl.join(
289
+ pathImpl.dirname(target),
290
+ `.${pathImpl.basename(target)}.${process.pid}.${Date.now()}.tmp`,
291
+ );
292
+ try {
293
+ // Same directory as the target so the rename is a rename and not a
294
+ // cross-device copy (which would reintroduce the partial-write window).
295
+ fsImpl.writeFileSync(tmpPath, JSON.stringify(merged, null, 2), { mode: 0o600 });
296
+ // `mode` is masked by umask on creation, so the chmod is what actually makes
297
+ // it owner-only — and it runs before the rename, so the token is never even
298
+ // briefly readable under a name something else might open.
299
+ try { fsImpl.chmodSync(tmpPath, 0o600); } catch {}
300
+ fsImpl.renameSync(tmpPath, target);
301
+ } catch (error) {
302
+ try { fsImpl.unlinkSync(tmpPath); } catch {}
303
+ throw error;
304
+ }
305
+ return merged;
306
+ }
307
+
308
+ /** Tolerates leading noise (npm shim banners) by taking the last JSON object. */
309
+ function parseJsonPayload(rawOutput) {
310
+ const text = stripTerminalEscapes(rawOutput).trim();
311
+ const start = text.indexOf('{');
312
+ const end = text.lastIndexOf('}');
313
+ if (start < 0 || end <= start) return null;
314
+ try {
315
+ return JSON.parse(text.slice(start, end + 1));
316
+ } catch {
317
+ return null;
318
+ }
319
+ }
320
+
321
+ export function createCliInstallService({
322
+ env = process.env,
323
+ platform = process.platform,
324
+ spawnImpl = spawn,
325
+ processKillImpl = process.kill.bind(process),
326
+ setTimeoutImpl = setTimeout,
327
+ clearTimeoutImpl = clearTimeout,
328
+ now = () => Date.now(),
329
+ fsImpl = fs,
330
+ pathImpl = path,
331
+ homedirImpl = () => os.homedir(),
332
+ logger = console,
333
+ // Persistence seams (§4.3 step 1). The relay stores the resolved absolute
334
+ // paths as `cliBinaries` in its config file; tests and the route-level
335
+ // harness hand in their own pair.
336
+ readBoundBinaries = () => ({}),
337
+ writeBoundBinaries = () => {},
338
+ installTimeoutMs = CLI_INSTALL_TIMEOUT_MS,
339
+ probeTimeoutMs = CLI_PROBE_TIMEOUT_MS,
340
+ statusTtlMs = CLI_STATUS_TTL_MS,
341
+ logFlushMs = CLI_INSTALL_LOG_FLUSH_MS,
342
+ } = {}) {
343
+ const listeners = new Set();
344
+ const successHooks = new Set();
345
+ const install = {
346
+ state: CLI_INSTALL_STATES.IDLE,
347
+ providerId: null,
348
+ action: null,
349
+ command: '',
350
+ log: '',
351
+ // Monotonic across the whole relay lifetime, never reset: a client that
352
+ // connects mid-install renders the retained buffer and can tell whether the
353
+ // next broadcast it sees carries anything new.
354
+ logSeq: 0,
355
+ error: '',
356
+ startedAt: null,
357
+ finishedAt: null,
358
+ };
359
+ let session = null;
360
+ // Bumped by every start/cancel so a slow post-close continuation can tell
361
+ // whether it still owns the visible install state.
362
+ let installGeneration = 0;
363
+ // Open throttle window for log broadcasts, plus "a chunk landed inside it".
364
+ let logFlushTimer = null;
365
+ let logEmitPending = false;
366
+ let boundBinaries = {};
367
+ const probeCache = new Map();
368
+ const probeInFlight = new Map();
369
+ // Serialises probes per provider: a forced read never joins an older
370
+ // in-flight probe (it could predate the install that prompted the force), but
371
+ // it waits for it rather than running two `--version` spawns at once.
372
+ const probeChains = new Map();
373
+
374
+ const log = (message) => {
375
+ try { logger?.log?.(`[cli-install] ${message}`); } catch {}
376
+ };
377
+
378
+ function getDescriptor(providerId) {
379
+ const id = normalizeText(providerId).toLowerCase();
380
+ return Object.prototype.hasOwnProperty.call(CLI_DESCRIPTORS, id) ? CLI_DESCRIPTORS[id] : null;
381
+ }
382
+
383
+ // ─── Binary resolution ─────────────────────────────────────────────────────
384
+
385
+ function expandHome(dir) {
386
+ const value = normalizeText(dir);
387
+ if (!value) return '';
388
+ if (value === '~') return normalizeText(homedirImpl());
389
+ if (value.startsWith('~/') || value.startsWith('~\\')) {
390
+ const home = normalizeText(homedirImpl());
391
+ return home ? pathImpl.join(home, value.slice(2)) : '';
392
+ }
393
+ return value;
394
+ }
395
+
396
+ /**
397
+ * Node has no `which`, so PATH is walked by hand. The descriptor's own bin
398
+ * dirs come after PATH: what the shell would pick wins, and the extras only
399
+ * cover a host where neither of the installer's symlink targets was writable
400
+ * or on PATH.
401
+ */
402
+ function searchDirs(descriptor) {
403
+ const dirs = [];
404
+ // Ops/e2e seam: PINS resolution to these directories, PATH and the
405
+ // descriptor's own dirs included. An isolated test relay inherits the
406
+ // host's PATH (it has to — it runs node), so merely *preferring* a stub dir
407
+ // would still let the host's real CLIs show up in the status rows.
408
+ const pinned = normalizeText(env?.COPILOT_WEB_RELAY_CLI_BIN_DIR);
409
+ if (pinned) {
410
+ for (const dir of pinned.split(pathImpl.delimiter)) {
411
+ if (normalizeText(dir)) dirs.push(normalizeText(dir));
412
+ }
413
+ return dirs;
414
+ }
415
+ for (const dir of String(env?.PATH || '').split(pathImpl.delimiter)) {
416
+ if (normalizeText(dir)) dirs.push(normalizeText(dir));
417
+ }
418
+ for (const dir of (descriptor.extraBinDirs || [])) {
419
+ const expanded = expandHome(dir);
420
+ if (expanded) dirs.push(expanded);
421
+ }
422
+ const seen = new Set();
423
+ return dirs.filter((dir) => {
424
+ const key = platform === 'win32' ? dir.toLowerCase() : dir;
425
+ if (seen.has(key)) return false;
426
+ seen.add(key);
427
+ return true;
428
+ });
429
+ }
430
+
431
+ /** On Windows the bare name is not executable; PATHEXT supplies the suffix. */
432
+ function candidateNames(binary) {
433
+ if (platform !== 'win32') return [binary];
434
+ const extensions = String(env?.PATHEXT || '.COM;.EXE;.BAT;.CMD')
435
+ .split(';')
436
+ .map((ext) => normalizeText(ext).toLowerCase())
437
+ .filter((ext) => ext.startsWith('.'));
438
+ return [...extensions.map((ext) => `${binary}${ext}`), binary];
439
+ }
440
+
441
+ function isExecutableFile(candidate) {
442
+ try {
443
+ if (!fsImpl.statSync(candidate).isFile()) return false;
444
+ } catch {
445
+ return false;
446
+ }
447
+ try {
448
+ fsImpl.accessSync(candidate, fs.constants.X_OK);
449
+ return true;
450
+ } catch {
451
+ return false;
452
+ }
453
+ }
454
+
455
+ /**
456
+ * First executable match for the descriptor's binary, with the symlink
457
+ * resolved: `realPath` is what classifies the install method (a path under
458
+ * `…/node_modules/…` is an npm global, whatever the launcher is called).
459
+ */
460
+ function resolveCliBinary(providerId) {
461
+ const descriptor = getDescriptor(providerId);
462
+ if (!descriptor) return null;
463
+ for (const dir of searchDirs(descriptor)) {
464
+ for (const name of candidateNames(descriptor.binary)) {
465
+ const candidate = pathImpl.join(dir, name);
466
+ if (!isExecutableFile(candidate)) continue;
467
+ let realPath = candidate;
468
+ try { realPath = fsImpl.realpathSync(candidate); } catch {}
469
+ return { path: candidate, realPath };
470
+ }
471
+ }
472
+ return null;
473
+ }
474
+
475
+ function classifyInstallMethod(resolved) {
476
+ const real = String(resolved?.realPath || resolved?.path || '').replace(/\\/g, '/');
477
+ if (!real) return null;
478
+ if (/\/node_modules\//i.test(real)) return 'npm-global';
479
+ const home = normalizeText(homedirImpl()).replace(/\\/g, '/');
480
+ if (home && real.toLowerCase().startsWith(`${home.toLowerCase()}/`)) return 'native';
481
+ return 'system';
482
+ }
483
+
484
+ // ─── Spawning ──────────────────────────────────────────────────────────────
485
+
486
+ function cliSpawnsDisabled() {
487
+ // Same kill switch (and same truthiness rule) as
488
+ // session-worker-launch-service.mjs and claude-auth-service.mjs: a relay
489
+ // started with it must never run a real CLI — least of all an installer.
490
+ if (!normalizeText(env?.COPILOT_WEB_RELAY_DISABLE_CLI_SPAWN)) return false;
491
+ // Narrow escape hatch for the e2e harness, which keeps the kill switch on
492
+ // (no real workers) but still drives the whole install flow. Both halves
493
+ // are required: the opt-in flag AND an explicit command override, so
494
+ // "spawns disabled" can never end up running a real vendor installer.
495
+ return !(
496
+ normalizeText(env?.COPILOT_WEB_RELAY_CLI_INSTALL_ALLOW_STUB_SPAWN)
497
+ && normalizeText(env?.COPILOT_WEB_RELAY_CLI_INSTALL_COMMAND)
498
+ );
499
+ }
500
+
501
+ /**
502
+ * Windows cannot `CreateProcess` a `.cmd`/`.bat` at all, and Node has refused
503
+ * to pretend otherwise since the CVE-2024-27980 fix — it fails the spawn with
504
+ * EINVAL unless a shell is asked for explicitly. `candidateNames()` resolves
505
+ * exactly those extensions through PATHEXT, and npm's shim for `copilot` on a
506
+ * Windows host *is* `copilot.cmd` — so without this, that row would report
507
+ * "installed, version unknown" on every probe and any `.cmd`-shimmed
508
+ * update/migrate would die instantly.
509
+ */
510
+ function isBatchLauncher(command) {
511
+ return platform === 'win32' && /\.(cmd|bat)$/i.test(String(command || ''));
512
+ }
513
+
514
+ /**
515
+ * `shell: true` makes Node join `[command, ...args]` with spaces into one
516
+ * string and hand it to `cmd.exe /d /s /c` *without quoting anything itself*,
517
+ * so every token has to be quoted here — `C:\Program Files\…\copilot.cmd`
518
+ * would otherwise split on its own space.
519
+ *
520
+ * This is belt-and-braces, not the security boundary: the argv is frozen
521
+ * literals from the descriptor table plus a path this module resolved off the
522
+ * filesystem, never anything a caller sent (§6). cmd.exe has no escape for a
523
+ * quote inside a quoted token — doubling it is the closest thing, and a
524
+ * Windows path cannot contain one anyway. `%VAR%` still expands inside double
525
+ * quotes; a bin directory named after a *set* environment variable would
526
+ * mis-resolve, which is the one residual gap and is still better than the
527
+ * alternative of not running batch launchers at all.
528
+ */
529
+ function quoteForCmd(value) {
530
+ return `"${String(value).replace(/"/g, '""')}"`;
531
+ }
532
+
533
+ function spawnCliProcess(argv) {
534
+ if (cliSpawnsDisabled()) throw new Error(CLI_SPAWN_DISABLED_ERROR);
535
+ const [command, ...args] = argv;
536
+ const batch = isBatchLauncher(command);
537
+ const child = spawnImpl(batch ? quoteForCmd(command) : command, batch ? args.map(quoteForCmd) : args, {
538
+ // No installer prompts, and nothing of ours to say: stdin is closed so a
539
+ // CLI that would have asked something fails fast instead of hanging out
540
+ // the full ten minutes.
541
+ stdio: ['ignore', 'pipe', 'pipe'],
542
+ env: { ...env },
543
+ ...(platform === 'win32'
544
+ ? { windowsHide: true, ...(batch ? { shell: true } : {}) }
545
+ // Detached so the whole group can be signalled: the vendor one-liner is
546
+ // `bash -lc 'curl … | bash'`, and SIGTERM on the outer shell alone would
547
+ // leave the real installer running.
548
+ : { detached: true }),
549
+ });
550
+ return child;
551
+ }
552
+
553
+ function killTree(child, signal) {
554
+ killProcessTree(child, signal, { platform, processKillImpl });
555
+ }
556
+
557
+ function terminate(child) {
558
+ killTree(child, 'SIGTERM');
559
+ // Deliberately not stored on the session: the session is released as soon
560
+ // as the child closes, so a session-owned timer could be cleared before it
561
+ // escalated and a SIGTERM-ignoring installer would live forever. Unref'd
562
+ // and closed over the child, so it is a no-op once the child exits.
563
+ const escalation = setTimeoutImpl(() => killTree(child, 'SIGKILL'), KILL_ESCALATION_MS);
564
+ escalation?.unref?.();
565
+ }
566
+
567
+ // ─── Status probes ─────────────────────────────────────────────────────────
568
+
569
+ function runProbeCommand(argv) {
570
+ return runProcessToCompletion(() => spawnCliProcess(argv), {
571
+ timeoutMs: probeTimeoutMs,
572
+ setTimeoutImpl,
573
+ clearTimeoutImpl,
574
+ killChild: (child) => terminate(child),
575
+ });
576
+ }
577
+
578
+ function parseVersion(descriptor, output) {
579
+ const visible = stripTerminalEscapes(output);
580
+ const specific = descriptor.versionPattern ? visible.match(descriptor.versionPattern) : null;
581
+ if (specific) return specific[1];
582
+ const generic = visible.match(GENERIC_VERSION_PATTERN);
583
+ return generic ? generic[1] : null;
584
+ }
585
+
586
+ function commandsFor(descriptor, { installed }) {
587
+ const out = {};
588
+ for (const [action, spec] of Object.entries(descriptor.commands || {})) {
589
+ const entry = spec.anyPlatform || (platform === 'win32' ? spec.win32 : spec.posix);
590
+ if (!entry) continue;
591
+ // update/migrate run the installed binary, so they only exist once there
592
+ // is one to run.
593
+ if (action !== 'install' && !installed) continue;
594
+ if (action === 'install' && installed) continue;
595
+ out[action] = { display: entry.display, targetDir: entry.targetDir };
596
+ }
597
+ return out;
598
+ }
599
+
600
+ function notInstalledStatus(descriptor, { error = null } = {}) {
601
+ const spawnsDisabled = cliSpawnsDisabled();
602
+ const commands = spawnsDisabled ? {} : commandsFor(descriptor, { installed: false });
603
+ return {
604
+ id: descriptor.id,
605
+ label: descriptor.label,
606
+ binary: descriptor.binary,
607
+ installed: false,
608
+ version: null,
609
+ path: null,
610
+ realPath: null,
611
+ installMethod: null,
612
+ updateAvailable: null,
613
+ latestVersion: null,
614
+ canInstall: Boolean(commands.install),
615
+ canUpdate: false,
616
+ blockedReason: descriptor.detectOnly
617
+ ? descriptor.blockedReason
618
+ : (spawnsDisabled ? CLI_SPAWN_DISABLED_ERROR : null),
619
+ commands,
620
+ doctor: null,
621
+ bound: boundBinaries[descriptor.id] || null,
622
+ error,
623
+ checkedAt: new Date(now()).toISOString(),
624
+ };
625
+ }
626
+
627
+ async function runProbe(descriptor) {
628
+ const resolved = resolveCliBinary(descriptor.id);
629
+ if (!resolved) return notInstalledStatus(descriptor);
630
+ const versionRun = await runProbeCommand([resolved.path, ...(descriptor.versionArgs || [])]);
631
+ // A binary that resolves but cannot be run at all (kill switch, exec format
632
+ // error) is still reported as installed with its path — the row says
633
+ // "version unknown" rather than lying about the install.
634
+ //
635
+ // Only a *successful* probe is scraped, though. The generic fallback pattern
636
+ // is "any dotted number anywhere", and a failed probe's stderr is full of
637
+ // them: `…/libc.so.6: version 'GLIBC_2.32' not found` would otherwise be
638
+ // reported as version "2.32" with `error: null`, i.e. a broken binary
639
+ // rendered as a healthy install. A version is a claim about a CLI that ran.
640
+ const version = versionRun.ok ? parseVersion(descriptor, versionRun.output) : null;
641
+
642
+ let doctor = null;
643
+ if (descriptor.doctor) {
644
+ const doctorRun = await runProbeCommand([resolved.path, ...descriptor.doctor.args]);
645
+ doctor = parseClaudeDoctor(doctorRun.output);
646
+ }
647
+ let updateAvailable = null;
648
+ let latestVersion = null;
649
+ if (descriptor.checkUpdate) {
650
+ // Exit code is deliberately ignored: `grok update --check --json` exits 0
651
+ // either way, so only the payload counts.
652
+ const checkRun = await runProbeCommand([resolved.path, ...descriptor.checkUpdate.args]);
653
+ const parsed = parseJsonPayload(checkRun.output);
654
+ if (parsed) {
655
+ const summary = descriptor.checkUpdate.parse(parsed) || {};
656
+ updateAvailable = summary.updateAvailable === true;
657
+ latestVersion = summary.latestVersion || null;
658
+ }
659
+ }
660
+ const spawnsDisabled = cliSpawnsDisabled();
661
+ const commands = spawnsDisabled ? {} : commandsFor(descriptor, { installed: true });
662
+ return {
663
+ id: descriptor.id,
664
+ label: descriptor.label,
665
+ binary: descriptor.binary,
666
+ installed: true,
667
+ version: version || doctor?.version || null,
668
+ path: resolved.path,
669
+ realPath: resolved.realPath,
670
+ // `claude doctor` names the method itself; everything else is classified
671
+ // from the realpath the launcher points at.
672
+ installMethod: doctor?.method || classifyInstallMethod(resolved),
673
+ updateAvailable,
674
+ latestVersion,
675
+ canInstall: false,
676
+ canUpdate: Boolean(commands.update),
677
+ blockedReason: descriptor.detectOnly
678
+ ? descriptor.blockedReason
679
+ : (spawnsDisabled ? CLI_SPAWN_DISABLED_ERROR : null),
680
+ commands,
681
+ doctor,
682
+ bound: boundBinaries[descriptor.id] || null,
683
+ // A failed probe always says *something*: a silent non-zero exit would
684
+ // otherwise render as "installed, version unknown" with nothing to go on.
685
+ error: version ? null : (
686
+ versionRun.error
687
+ || tailOf(stripTerminalEscapes(versionRun.output))
688
+ || (versionRun.ok ? null : `${descriptor.binary} ${(descriptor.versionArgs || []).join(' ')} exited with code ${versionRun.code}`)
689
+ ),
690
+ checkedAt: new Date(now()).toISOString(),
691
+ };
692
+ }
693
+
694
+ /**
695
+ * Resolve + probe one provider. Never throws: a failed probe resolves as a
696
+ * status row carrying the reason, mirroring `claudeAuth.getStatus()`.
697
+ */
698
+ function probeCliStatus(providerId, { force = false } = {}) {
699
+ const descriptor = getDescriptor(providerId);
700
+ if (!descriptor) return Promise.resolve(null);
701
+ const id = descriptor.id;
702
+ const cached = probeCache.get(id);
703
+ if (!force && cached && (now() - cached.at) < statusTtlMs) return Promise.resolve(cached.value);
704
+ const running = probeInFlight.get(id);
705
+ if (running && !force) return running;
706
+ const pending = (probeChains.get(id) || Promise.resolve())
707
+ .then(() => runProbe(descriptor))
708
+ .then((value) => {
709
+ probeCache.set(id, { at: now(), value });
710
+ return value;
711
+ })
712
+ .catch((error) => {
713
+ log(`probe failed for ${id}: ${error?.message || error}`);
714
+ return notInstalledStatus(descriptor, { error: error?.message || String(error) });
715
+ });
716
+ probeChains.set(id, pending.then(() => {}, () => {}));
717
+ probeInFlight.set(id, pending);
718
+ const clear = () => { if (probeInFlight.get(id) === pending) probeInFlight.delete(id); };
719
+ pending.then(clear, clear);
720
+ return pending;
721
+ }
722
+
723
+ /**
724
+ * Last probe for each provider, without ever spawning. A provider that has
725
+ * never been probed gets the not-installed shape with a null `checkedAt`, so
726
+ * a broadcast carries the full payload rather than dropping the field and the
727
+ * UI can tell "not there" from "not looked at yet".
728
+ */
729
+ function getCachedProviders() {
730
+ const providers = {};
731
+ for (const descriptor of Object.values(CLI_DESCRIPTORS)) {
732
+ providers[descriptor.id] = probeCache.get(descriptor.id)?.value
733
+ || { ...notInstalledStatus(descriptor), checkedAt: null };
734
+ }
735
+ return providers;
736
+ }
737
+
738
+ async function getStatusSnapshot({ force = false } = {}) {
739
+ const ids = Object.keys(CLI_DESCRIPTORS);
740
+ const results = await Promise.all(ids.map((id) => probeCliStatus(id, { force })));
741
+ const providers = {};
742
+ ids.forEach((id, index) => { providers[id] = results[index]; });
743
+ return { providers, install: installSnapshot() };
744
+ }
745
+
746
+ function getCachedStatusSnapshot() {
747
+ return { providers: getCachedProviders(), install: installSnapshot() };
748
+ }
749
+
750
+ // ─── Binding (§4.3) ────────────────────────────────────────────────────────
751
+
752
+ /**
753
+ * Moves directories to the front of the relay's PATH, in the order given, so
754
+ * anything that resolves the CLI *by name* finds the copy this service bound —
755
+ * the Grok ACP adapter defaults to `command: 'grok'`, and a host where the
756
+ * installer's symlink targets were unwritable would otherwise still fail after
757
+ * a successful install. Non-existent dirs are dropped so PATH does not collect
758
+ * dead entries.
759
+ *
760
+ * A directory already on PATH is *moved*, not skipped: a binding is a claim
761
+ * that this copy wins, and a `~/.local/bin` sitting behind the npm global dir
762
+ * would leave the launcher it replaced winning instead.
763
+ *
764
+ * Every child the relay spawns inherits this PATH, so callers hand it only
765
+ * what a binding (or a just-finished install of that CLI) actually requires —
766
+ * never every descriptor's dirs on every boot.
767
+ */
768
+ function hoistOntoPath(dirs) {
769
+ const key = (dir) => (platform === 'win32' ? dir.toLowerCase() : dir);
770
+ const wanted = [];
771
+ const seen = new Set();
772
+ for (const dir of (dirs || [])) {
773
+ const expanded = expandHome(dir);
774
+ if (!expanded || seen.has(key(expanded))) continue;
775
+ try { if (!fsImpl.statSync(expanded).isDirectory()) continue; } catch { continue; }
776
+ seen.add(key(expanded));
777
+ wanted.push(expanded);
778
+ }
779
+ if (!wanted.length) return;
780
+ const existing = String(env?.PATH || '').split(pathImpl.delimiter).filter(Boolean);
781
+ const next = [...wanted, ...existing.filter((dir) => !seen.has(key(dir)))].join(pathImpl.delimiter);
782
+ if (next !== env.PATH) env.PATH = next;
783
+ }
784
+
785
+ function applyEnvBinding(descriptor, binaryPath) {
786
+ if (descriptor.envVar && binaryPath) env[descriptor.envVar] = binaryPath;
787
+ // Only the bound binary's own directory. That is the whole of what "resolve
788
+ // `grok` by name and get *this* copy" needs, and it keeps a boot with one
789
+ // binding from reshuffling PATH for every child the relay ever spawns.
790
+ if (binaryPath) hoistOntoPath([pathImpl.dirname(binaryPath)]);
791
+ }
792
+
793
+ /**
794
+ * Startup half of the binding: re-apply the paths a previous install
795
+ * persisted, so a relay that was restarted resolves exactly what it resolved
796
+ * before.
797
+ *
798
+ * Nothing is touched when nothing is bound — in particular PATH is left alone.
799
+ * This used to prepend every descriptor's bin dirs unconditionally, which
800
+ * reordered the PATH of the relay and of every process it spawns on every
801
+ * boot, on hosts that had never installed a CLI through it at all.
802
+ *
803
+ * Nothing here stops or restarts a running worker — like the Claude account
804
+ * switch, in-flight sessions keep the binary they launched with.
805
+ */
806
+ function applyPersistedBindings() {
807
+ const stored = readBoundBinaries() || {};
808
+ const applied = {};
809
+ for (const descriptor of Object.values(CLI_DESCRIPTORS)) {
810
+ if (descriptor.detectOnly) continue;
811
+ const storedPath = normalizeText(stored[descriptor.id]);
812
+ if (!storedPath) continue;
813
+ if (!isExecutableFile(storedPath)) {
814
+ log(`bound ${descriptor.id} binary is gone, ignoring: ${storedPath}`);
815
+ continue;
816
+ }
817
+ applyEnvBinding(descriptor, storedPath);
818
+ applied[descriptor.id] = storedPath;
819
+ }
820
+ boundBinaries = applied;
821
+ return { ...applied };
822
+ }
823
+
824
+ function bindResolvedBinary(descriptor, binaryPath) {
825
+ applyEnvBinding(descriptor, binaryPath);
826
+ boundBinaries = { ...boundBinaries, [descriptor.id]: binaryPath };
827
+ try {
828
+ writeBoundBinaries({ ...boundBinaries });
829
+ } catch (error) {
830
+ // A config write that fails costs the binding across a restart, not this
831
+ // one: the env vars are already applied in-process.
832
+ log(`failed to persist the resolved ${descriptor.id} binary: ${error?.message || error}`);
833
+ }
834
+ }
835
+
836
+ function getBoundBinaries() {
837
+ return { ...boundBinaries };
838
+ }
839
+
840
+ // ─── Install / update ──────────────────────────────────────────────────────
841
+
842
+ function installSnapshot() {
843
+ return {
844
+ state: install.state,
845
+ providerId: install.providerId,
846
+ action: install.action,
847
+ command: install.command || null,
848
+ log: install.log,
849
+ logSeq: install.logSeq,
850
+ error: install.error || null,
851
+ startedAt: install.startedAt || null,
852
+ finishedAt: install.finishedAt || null,
853
+ active: Boolean(session),
854
+ };
855
+ }
856
+
857
+ function emitState() {
858
+ const snapshot = installSnapshot();
859
+ for (const listener of [...listeners]) {
860
+ try { listener(snapshot); } catch (error) { log(`listener failed: ${error?.message || error}`); }
861
+ }
862
+ }
863
+
864
+ /**
865
+ * Log broadcasts are coalesced; state transitions are not.
866
+ *
867
+ * Every emit carries the *whole* retained 16 KB buffer to every connected
868
+ * socket, and a progress-bar installer produces a chunk per frame — one emit
869
+ * each would flood every client for up to the full ten-minute timeout. So the
870
+ * first chunk goes out at once (the log appears immediately), further chunks
871
+ * inside the window collapse into a single trailing emit, and a window with
872
+ * nothing pending simply lapses so the next chunk is immediate again.
873
+ *
874
+ * The payload shape is untouched: `log` is still the entire retained buffer
875
+ * and `logSeq` still counts every chunk — a client just sees it advance by
876
+ * more than one between broadcasts, which is exactly what the sequence number
877
+ * exists to let it notice.
878
+ */
879
+ function clearLogFlush() {
880
+ if (logFlushTimer) clearTimeoutImpl(logFlushTimer);
881
+ logFlushTimer = null;
882
+ logEmitPending = false;
883
+ }
884
+
885
+ function armLogFlush() {
886
+ logFlushTimer = setTimeoutImpl(() => {
887
+ logFlushTimer = null;
888
+ if (!logEmitPending) return;
889
+ logEmitPending = false;
890
+ emitState();
891
+ // Re-armed only while output is still arriving, so a quiet installer is
892
+ // not left holding a timer.
893
+ armLogFlush();
894
+ }, logFlushMs);
895
+ logFlushTimer?.unref?.();
896
+ }
897
+
898
+ function emitLogState() {
899
+ if (logFlushTimer) {
900
+ logEmitPending = true;
901
+ return;
902
+ }
903
+ emitState();
904
+ armLogFlush();
905
+ }
906
+
907
+ function setInstallState(nextState, { error } = {}) {
908
+ install.state = nextState;
909
+ if (error !== undefined) install.error = error || '';
910
+ // A transition is never coalesced, and it cancels any pending flush: the
911
+ // snapshot it is about to emit already carries the complete log, so the
912
+ // terminal state always ships the whole thing.
913
+ clearLogFlush();
914
+ emitState();
915
+ }
916
+
917
+ function appendInstallLog(current, text) {
918
+ if (session !== current) return;
919
+ // Escape-stripped before it is retained, so no ANSI/OSC payload ever
920
+ // reaches a browser (it still goes through escHtml on the way to the DOM).
921
+ install.log = `${install.log}${stripTerminalEscapes(text)}`.slice(-MAX_CAPTURED_OUTPUT_CHARS);
922
+ install.logSeq += 1;
923
+ emitLogState();
924
+ }
925
+
926
+ /**
927
+ * Resolves the frozen argv for `action`, with `<bin>` replaced by the
928
+ * resolved absolute binary. Returns null when this platform (or this
929
+ * provider) has no such command — never a partially-built one.
930
+ */
931
+ function resolveActionCommand(descriptor, action, resolvedPath) {
932
+ const spec = descriptor.commands?.[action];
933
+ if (!spec) return null;
934
+ const entry = spec.anyPlatform || (platform === 'win32' ? spec.win32 : spec.posix);
935
+ if (!entry) return null;
936
+ const override = normalizeText(env?.COPILOT_WEB_RELAY_CLI_INSTALL_COMMAND);
937
+ if (override) {
938
+ // Stub path for the e2e harness: the descriptor id and action are the
939
+ // frozen values resolved above, never the raw request body.
940
+ return { argv: [override, descriptor.id, action], display: entry.display, targetDir: entry.targetDir };
941
+ }
942
+ if (!entry.argv.includes(BIN_PLACEHOLDER)) {
943
+ return { argv: [...entry.argv], display: entry.display, targetDir: entry.targetDir };
944
+ }
945
+ if (!resolvedPath) return null;
946
+ return {
947
+ argv: entry.argv.map((part) => (part === BIN_PLACEHOLDER ? resolvedPath : part)),
948
+ display: entry.display,
949
+ targetDir: entry.targetDir,
950
+ };
951
+ }
952
+
953
+ function runInstall(providerId, { action = 'install' } = {}) {
954
+ const descriptor = getDescriptor(providerId);
955
+ // The id never reaches a command, and it is not echoed back either: an
956
+ // unknown one is simply not a provider.
957
+ if (!descriptor) {
958
+ return { ok: false, statusCode: 400, error: 'Unknown CLI provider', install: installSnapshot() };
959
+ }
960
+ if (descriptor.detectOnly) {
961
+ return {
962
+ ok: false,
963
+ statusCode: 400,
964
+ error: descriptor.blockedReason || `${descriptor.label} cannot be installed from the relay`,
965
+ install: installSnapshot(),
966
+ };
967
+ }
968
+ const normalizedAction = normalizeText(action).toLowerCase() || 'install';
969
+ if (!Object.prototype.hasOwnProperty.call(descriptor.commands || {}, normalizedAction)) {
970
+ return { ok: false, statusCode: 400, error: 'Unknown install action', install: installSnapshot() };
971
+ }
972
+ if (session) {
973
+ // A double-clicked button joins the run already in flight; anything else
974
+ // is refused, because two installers writing ~/.local/bin at once is the
975
+ // failure this single-flight exists to prevent.
976
+ if (session.descriptor.id === descriptor.id && session.action === normalizedAction) {
977
+ return { ok: true, reused: true, install: installSnapshot() };
978
+ }
979
+ return {
980
+ ok: false,
981
+ statusCode: 409,
982
+ error: `Another CLI install is already running (${session.descriptor.label})`,
983
+ install: installSnapshot(),
984
+ };
985
+ }
986
+ if (cliSpawnsDisabled()) {
987
+ installGeneration += 1;
988
+ install.providerId = descriptor.id;
989
+ install.action = normalizedAction;
990
+ install.command = '';
991
+ setInstallState(CLI_INSTALL_STATES.ERROR, { error: CLI_SPAWN_DISABLED_ERROR });
992
+ return { ok: false, statusCode: 503, error: CLI_SPAWN_DISABLED_ERROR, install: installSnapshot() };
993
+ }
994
+ const resolvedPath = resolveCliBinary(descriptor.id)?.path || '';
995
+ const command = resolveActionCommand(descriptor, normalizedAction, resolvedPath);
996
+ if (!command) {
997
+ return {
998
+ ok: false,
999
+ statusCode: 409,
1000
+ error: resolvedPath
1001
+ ? `${descriptor.label} has no ${normalizedAction} command on this platform`
1002
+ : `${descriptor.label} is not installed, so it cannot be ${normalizedAction}d`,
1003
+ install: installSnapshot(),
1004
+ };
1005
+ }
1006
+
1007
+ installGeneration += 1;
1008
+ // Stamped on the session so a late close from a cancelled (or superseded)
1009
+ // run cannot publish an outcome over a newer one.
1010
+ const current = { descriptor, action: normalizedAction, child: null, generation: installGeneration };
1011
+ session = current;
1012
+ install.providerId = descriptor.id;
1013
+ install.action = normalizedAction;
1014
+ install.command = command.display;
1015
+ install.log = '';
1016
+ install.error = '';
1017
+ install.startedAt = new Date(now()).toISOString();
1018
+ install.finishedAt = null;
1019
+ setInstallState(CLI_INSTALL_STATES.RUNNING, { error: '' });
1020
+
1021
+ void runProcessToCompletion(() => spawnCliProcess(command.argv), {
1022
+ timeoutMs: installTimeoutMs,
1023
+ setTimeoutImpl,
1024
+ clearTimeoutImpl,
1025
+ killChild: (child) => terminate(child),
1026
+ maxOutputChars: MAX_CAPTURED_OUTPUT_CHARS,
1027
+ onChild: (child) => { current.child = child; },
1028
+ onOutput: (text) => appendInstallLog(current, text),
1029
+ }).then((result) => finishInstall(current, result));
1030
+
1031
+ return { ok: true, reused: false, install: installSnapshot() };
1032
+ }
1033
+
1034
+ async function finishInstall(current, result) {
1035
+ if (session === current) session = null;
1036
+ // A cancel (or a newer install, or dispose) already owns the visible state.
1037
+ if (current.generation !== installGeneration) return;
1038
+ install.finishedAt = new Date(now()).toISOString();
1039
+ if (!result.ok) {
1040
+ const tail = tailOf(stripTerminalEscapes(result.output));
1041
+ setInstallState(CLI_INSTALL_STATES.ERROR, {
1042
+ error: result.error
1043
+ || tail
1044
+ || `${current.descriptor.label} ${current.action} exited with code ${result.code}`,
1045
+ });
1046
+ return;
1047
+ }
1048
+ // Before the post-install resolve, never after it. `claude migrate` installs
1049
+ // the native build into the descriptor's own bin dir while the npm-global
1050
+ // launcher it exists to replace is still ahead of that dir on PATH, so a
1051
+ // resolve against the untouched PATH would bind — and persist — the very
1052
+ // launcher the migration was run to get away from, and report SUCCESS doing
1053
+ // it. Hoisting first also keeps the bound path and name-resolution in
1054
+ // agreement: whatever `grok` means to a spawned child is what got bound.
1055
+ hoistOntoPath(current.descriptor.extraBinDirs);
1056
+ // Success is decided by exit code AND a post-install resolve, never by
1057
+ // parsing installer output: an installer that changes its banner must not
1058
+ // be able to turn a working install into a failure, or the reverse.
1059
+ const status = await probeCliStatus(current.descriptor.id, { force: true });
1060
+ if (current.generation !== installGeneration) {
1061
+ log('skipping post-install binding: a newer install took over');
1062
+ return;
1063
+ }
1064
+ if (!status?.installed || !status.path) {
1065
+ setInstallState(CLI_INSTALL_STATES.ERROR, {
1066
+ error: `${current.descriptor.label} ${current.action} finished but ${current.descriptor.binary} was not found on PATH`,
1067
+ });
1068
+ return;
1069
+ }
1070
+ bindResolvedBinary(current.descriptor, status.path);
1071
+ // Re-read so the broadcast carries the freshly bound path.
1072
+ probeCache.set(current.descriptor.id, {
1073
+ at: now(),
1074
+ value: { ...status, bound: boundBinaries[current.descriptor.id] || null },
1075
+ });
1076
+ setInstallState(CLI_INSTALL_STATES.SUCCESS, { error: '' });
1077
+ for (const hook of [...successHooks]) {
1078
+ try {
1079
+ await hook({ providerId: current.descriptor.id, action: current.action, status });
1080
+ } catch (error) {
1081
+ log(`post-install hook failed: ${error?.message || error}`);
1082
+ }
1083
+ }
1084
+ }
1085
+
1086
+ /**
1087
+ * Kills the running install, and doubles as the "dismiss the terminal state"
1088
+ * path for the UI. An install killed mid-download leaves a partial file in
1089
+ * the vendor's own download dir; both installers re-download, so pressing
1090
+ * Install again recovers.
1091
+ */
1092
+ function cancel() {
1093
+ const current = session;
1094
+ installGeneration += 1;
1095
+ if (current) {
1096
+ if (current.child) terminate(current.child);
1097
+ session = null;
1098
+ install.finishedAt = new Date(now()).toISOString();
1099
+ setInstallState(CLI_INSTALL_STATES.CANCELLED, {
1100
+ error: `${current.descriptor.label} ${current.action} cancelled`,
1101
+ });
1102
+ return { ok: true, install: installSnapshot() };
1103
+ }
1104
+ install.providerId = null;
1105
+ install.action = null;
1106
+ install.command = '';
1107
+ install.startedAt = null;
1108
+ install.finishedAt = null;
1109
+ setInstallState(CLI_INSTALL_STATES.IDLE, { error: '' });
1110
+ return { ok: true, install: installSnapshot() };
1111
+ }
1112
+
1113
+ function subscribe(listener) {
1114
+ if (typeof listener !== 'function') return () => {};
1115
+ listeners.add(listener);
1116
+ return () => listeners.delete(listener);
1117
+ }
1118
+
1119
+ function onInstallSuccess(hook) {
1120
+ if (typeof hook !== 'function') return () => {};
1121
+ successHooks.add(hook);
1122
+ return () => successHooks.delete(hook);
1123
+ }
1124
+
1125
+ function dispose() {
1126
+ const current = session;
1127
+ if (current) {
1128
+ installGeneration += 1;
1129
+ if (current.child) terminate(current.child);
1130
+ session = null;
1131
+ }
1132
+ clearLogFlush();
1133
+ listeners.clear();
1134
+ successHooks.clear();
1135
+ }
1136
+
1137
+ return {
1138
+ getDescriptor,
1139
+ resolveCliBinary,
1140
+ probeCliStatus,
1141
+ getStatusSnapshot,
1142
+ getCachedStatusSnapshot,
1143
+ getInstallState: installSnapshot,
1144
+ applyPersistedBindings,
1145
+ getBoundBinaries,
1146
+ runInstall,
1147
+ cancel,
1148
+ subscribe,
1149
+ onInstallSuccess,
1150
+ dispose,
1151
+ };
1152
+ }