@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.
- package/.github/extensions/web-relay/README.md +190 -0
- package/.github/extensions/web-relay/extension.mjs +972 -0
- package/.github/extensions/web-relay/model-api/model-switching.mjs +338 -0
- package/.github/extensions/web-relay/polling/heartbeat.mjs +55 -0
- package/.github/extensions/web-relay/polling/polling-loop.mjs +1105 -0
- package/.github/extensions/web-relay/runtime/api-client.mjs +34 -0
- package/.github/extensions/web-relay/runtime/banner-state.mjs +126 -0
- package/.github/extensions/web-relay/runtime/config-loader.mjs +104 -0
- package/.github/extensions/web-relay/runtime/debug-log.mjs +27 -0
- package/.github/extensions/web-relay/runtime/send-and-wait-errors.mjs +170 -0
- package/.github/extensions/web-relay/runtime/session-binding.mjs +78 -0
- package/.github/extensions/web-relay/runtime/session-io.mjs +349 -0
- package/.github/extensions/web-relay/runtime/session-join-retry.mjs +44 -0
- package/.github/extensions/web-relay/runtime/session-registry.mjs +29 -0
- package/.github/extensions/web-relay/runtime/session-runtime-manager.mjs +156 -0
- package/.github/extensions/web-relay/runtime/session-sync-bridge.mjs +74 -0
- package/.github/extensions/web-relay/runtime/worker-websocket-link.mjs +325 -0
- package/.github/extensions/web-relay/runtime/workspace-root.mjs +55 -0
- package/.github/extensions/web-relay/server-lifecycle/managed-server.mjs +386 -0
- package/.github/extensions/web-relay/skills/prompt-context.mjs +160 -0
- package/.github/extensions/web-relay/skills/question-bridge.mjs +141 -0
- package/.github/extensions/web-relay/skills/question-routing-hooks.mjs +463 -0
- package/.github/extensions/web-relay/skills/reasoning-stream.mjs +285 -0
- package/.github/extensions/web-relay/skills/subagent-lifecycle.mjs +345 -0
- package/.github/extensions/web-relay/skills/tool-activity.mjs +243 -0
- package/.github/extensions/web-relay/utils/process-utils.mjs +25 -0
- package/.github/extensions/web-relay/utils/token-generation.mjs +58 -0
- package/CHANGELOG.md +25 -0
- package/LICENSE +21 -0
- package/README.md +717 -0
- package/bin/bat/oar.bat +46 -0
- package/bin/oar.js +665 -0
- package/package.json +68 -0
- package/server/README.md +1379 -0
- package/server/claude-worker/claude-attachments.mjs +134 -0
- package/server/claude-worker/claude-preview-tool.mjs +57 -0
- package/server/claude-worker/claude-sdk-adapter.mjs +420 -0
- package/server/claude-worker/claude-session-process.mjs +2391 -0
- package/server/claude-worker/claude-session-test-harness.mjs +236 -0
- package/server/claude-worker/claude-session-worker.mjs +186 -0
- package/server/claude-worker/claude-transcript-relocator.mjs +189 -0
- package/server/claude-worker/claude-turn-publisher.mjs +291 -0
- package/server/claude-worker/fixtures/workflow-run-record.json +1 -0
- package/server/claude-worker/sdk-message-normalizer.mjs +629 -0
- package/server/claude-worker/workflow-progress-digest.mjs +269 -0
- package/server/copilot-sdk-runtime.mjs +126 -0
- package/server/copilot-worker/copilot-attachments.mjs +97 -0
- package/server/copilot-worker/copilot-byok-provider.mjs +121 -0
- package/server/copilot-worker/copilot-continuation-signals.mjs +349 -0
- package/server/copilot-worker/copilot-plan-board.mjs +110 -0
- package/server/copilot-worker/copilot-prompt-context.mjs +86 -0
- package/server/copilot-worker/copilot-question-bridge.mjs +216 -0
- package/server/copilot-worker/copilot-sdk-adapter.mjs +494 -0
- package/server/copilot-worker/copilot-sdk-event-normalizer.mjs +800 -0
- package/server/copilot-worker/copilot-sdk-session-process.mjs +1729 -0
- package/server/copilot-worker/copilot-sdk-session-worker.mjs +167 -0
- package/server/copilot-worker/copilot-sdk-test-harness.mjs +329 -0
- package/server/copilot-worker/fixtures/abort-turn.json +588 -0
- package/server/copilot-worker/fixtures/ask-user-turn.json +1154 -0
- package/server/copilot-worker/fixtures/background-timer-continuation.json +445 -0
- package/server/copilot-worker/fixtures/background-timer-turn.json +497 -0
- package/server/copilot-worker/fixtures/happy-turn.json +566 -0
- package/server/copilot-worker/fixtures/quota-turn.json +647 -0
- package/server/copilot-worker/fixtures/reasoning-turn.json +625 -0
- package/server/copilot-worker/fixtures/resume-turn.json +700 -0
- package/server/copilot-worker/fixtures/subagent-turn.json +1378 -0
- package/server/copilot-worker/fixtures/tool-permission-turn.json +1293 -0
- package/server/cursor-worker/cursor-ask-user-tool.mjs +83 -0
- package/server/cursor-worker/cursor-attachments.mjs +88 -0
- package/server/cursor-worker/cursor-context-usage.mjs +85 -0
- package/server/cursor-worker/cursor-preview-tool.mjs +44 -0
- package/server/cursor-worker/cursor-sdk-adapter.mjs +554 -0
- package/server/cursor-worker/cursor-session-worker.mjs +145 -0
- package/server/cursor-worker/cursor-subagent-roster.mjs +81 -0
- package/server/cursor-worker/cursor-turn-runner.mjs +717 -0
- package/server/cursor-worker/sdk-message-normalizer.mjs +485 -0
- package/server/db-schema.mjs +932 -0
- package/server/features.mjs +106 -0
- package/server/grok-worker/acp-client.mjs +434 -0
- package/server/grok-worker/acp-host-services.mjs +277 -0
- package/server/grok-worker/grok-context-usage.mjs +77 -0
- package/server/grok-worker/grok-sdk-adapter.mjs +334 -0
- package/server/grok-worker/grok-session-worker.mjs +145 -0
- package/server/grok-worker/grok-turn-runner.mjs +523 -0
- package/server/grok-worker/sdk-message-normalizer.mjs +311 -0
- package/server/migrations/0001-add-structured-answer.mjs +80 -0
- package/server/migrations/0002-recent-workspace-roots-path-key.mjs +121 -0
- package/server/migrations/0003-push-subscriptions.mjs +87 -0
- package/server/public/app/action-confirmations.js +267 -0
- package/server/public/app/activity-replay-state.mjs +149 -0
- package/server/public/app/api-client.js +1082 -0
- package/server/public/app/ask-user-view.js +545 -0
- package/server/public/app/attachments-view.js +2112 -0
- package/server/public/app/auto-compact-window-options.mjs +59 -0
- package/server/public/app/background-tasks-view.mjs +494 -0
- package/server/public/app/bootstrap.js +4347 -0
- package/server/public/app/claude-auth-ui.js +394 -0
- package/server/public/app/claude-thinking-options.mjs +38 -0
- package/server/public/app/cli-install-ui.js +455 -0
- package/server/public/app/code-copy.mjs +85 -0
- package/server/public/app/composer-attachment-cache.mjs +151 -0
- package/server/public/app/composer-control-state.mjs +107 -0
- package/server/public/app/composer-paste.mjs +132 -0
- package/server/public/app/context-tier-options.mjs +68 -0
- package/server/public/app/context-usage-view.mjs +338 -0
- package/server/public/app/conversation-draft-timestamp-utils.mjs +11 -0
- package/server/public/app/conversation-preferences.mjs +91 -0
- package/server/public/app/conversation-provider-indicator.mjs +88 -0
- package/server/public/app/conversation-view.js +2898 -0
- package/server/public/app/cwd-menu-placement.mjs +85 -0
- package/server/public/app/cwd-picker.js +605 -0
- package/server/public/app/emoji-view.js +101 -0
- package/server/public/app/external-link-policy.mjs +80 -0
- package/server/public/app/file-preview-navigation.mjs +51 -0
- package/server/public/app/font-scaling.js +262 -0
- package/server/public/app/git-changes-view.js +339 -0
- package/server/public/app/git-diff-model.mjs +109 -0
- package/server/public/app/grok-auth-ui.js +387 -0
- package/server/public/app/image-downscale.mjs +174 -0
- package/server/public/app/in-flight-snapshot.mjs +40 -0
- package/server/public/app/infinite-loader.js +164 -0
- package/server/public/app/journal-view.js +1141 -0
- package/server/public/app/known-cwd-options.mjs +41 -0
- package/server/public/app/launch-cwd-path.mjs +23 -0
- package/server/public/app/live-message-dedupe.mjs +57 -0
- package/server/public/app/message-search-view.js +242 -0
- package/server/public/app/model-selector-options.mjs +31 -0
- package/server/public/app/model-vision-support.mjs +56 -0
- package/server/public/app/new-conversation-model-choice.mjs +67 -0
- package/server/public/app/plan-usage-view.mjs +307 -0
- package/server/public/app/preview-cards.mjs +273 -0
- package/server/public/app/preview-command.mjs +103 -0
- package/server/public/app/push-settings.js +370 -0
- package/server/public/app/pwa-install.js +456 -0
- package/server/public/app/question-schema-view.mjs +71 -0
- package/server/public/app/reasoning-effort-labels.mjs +42 -0
- package/server/public/app/relay-board-view.js +117 -0
- package/server/public/app/relay-dot-state.mjs +69 -0
- package/server/public/app/relay-error-ctas.mjs +52 -0
- package/server/public/app/relay-prompt-sanitizer.mjs +114 -0
- package/server/public/app/relay-thoughts.mjs +98 -0
- package/server/public/app/relay-watchdog-policy.mjs +29 -0
- package/server/public/app/repo-browser-preferences.mjs +79 -0
- package/server/public/app/repo-browser-tree-state.mjs +97 -0
- package/server/public/app/router.js +657 -0
- package/server/public/app/selection-guard.mjs +93 -0
- package/server/public/app/settings-modal.js +1484 -0
- package/server/public/app/settings-tabs.js +129 -0
- package/server/public/app/slash-autocomplete.mjs +188 -0
- package/server/public/app/slash-commands.mjs +183 -0
- package/server/public/app/socket-handlers.js +703 -0
- package/server/public/app/status-store.mjs +242 -0
- package/server/public/app/status-view.mjs +315 -0
- package/server/public/app/store.js +1454 -0
- package/server/public/app/stream-state.mjs +73 -0
- package/server/public/app/streaming-dom-patch.mjs +23 -0
- package/server/public/app/sync-outbox.mjs +208 -0
- package/server/public/app/tap-actions.js +62 -0
- package/server/public/app/thread-order.mjs +54 -0
- package/server/public/app/tmux-inspector-view.js +422 -0
- package/server/public/app/transcript-separators.mjs +282 -0
- package/server/public/app-icon-192.png +0 -0
- package/server/public/app-icon-512.png +0 -0
- package/server/public/app-icon.svg +4 -0
- package/server/public/favicon.ico +0 -0
- package/server/public/index.html +5743 -0
- package/server/public/manifest.webmanifest +32 -0
- package/server/public/sw.js +307 -0
- package/server/relay-exit-codes.mjs +4 -0
- package/server/relay-self-restart.mjs +170 -0
- package/server/relay-tools.md +27 -0
- package/server/relay.mjs +1026 -0
- package/server/repositories/image-conversation-repository.mjs +317 -0
- package/server/repositories/message-repository.mjs +434 -0
- package/server/repositories/question-repository.mjs +161 -0
- package/server/repositories/session-repository.mjs +320 -0
- package/server/repositories/share-visibility-statements.mjs +24 -0
- package/server/routes/ask-user-routes.mjs +292 -0
- package/server/routes/cache-routes.mjs +44 -0
- package/server/routes/git-routes.mjs +92 -0
- package/server/routes/messages-routes-test-harness.mjs +111 -0
- package/server/routes/messages-routes.mjs +7107 -0
- package/server/routes/preview-routes.mjs +108 -0
- package/server/routes/push-routes.mjs +59 -0
- package/server/routes/relay-board-routes.mjs +268 -0
- package/server/routes/sessions-routes.mjs +6150 -0
- package/server/server-runtime.mjs +7463 -0
- package/server/server.js +24 -0
- package/server/services/ask-user-routing-service.mjs +117 -0
- package/server/services/cache-rebuild-service.mjs +236 -0
- package/server/services/claude-auth-service.mjs +578 -0
- package/server/services/claude-context-usage.mjs +240 -0
- package/server/services/claude-session-root-service.mjs +228 -0
- package/server/services/cli-install-service.mjs +1152 -0
- package/server/services/cli-process-runner.mjs +181 -0
- package/server/services/cloudflared-tunnel-service.mjs +358 -0
- package/server/services/context-snapshot-service.mjs +499 -0
- package/server/services/context-usage-view.mjs +160 -0
- package/server/services/conversation-preferences-service.mjs +208 -0
- package/server/services/cursor-dashboard-usage.mjs +217 -0
- package/server/services/cursor-session-root-service.mjs +62 -0
- package/server/services/delete-archive-service.mjs +154 -0
- package/server/services/drives-path-helpers.mjs +66 -0
- package/server/services/fixtures/claude-auth-stub.sh +62 -0
- package/server/services/fixtures/cli-install-stub.sh +75 -0
- package/server/services/fixtures/grok-stub.sh +86 -0
- package/server/services/generated-image-cleanup-service.mjs +114 -0
- package/server/services/git-changes-service.mjs +195 -0
- package/server/services/github-billing-usage.mjs +133 -0
- package/server/services/grok-auth-service.mjs +593 -0
- package/server/services/grok-billing-usage.mjs +120 -0
- package/server/services/image-context-service.mjs +106 -0
- package/server/services/image-operation-service.mjs +135 -0
- package/server/services/keyed-mutex-service.mjs +82 -0
- package/server/services/mime-sniffer.mjs +118 -0
- package/server/services/oar-cli-helpers.mjs +109 -0
- package/server/services/oar-state-migration-service.mjs +243 -0
- package/server/services/plan-usage-claude.mjs +387 -0
- package/server/services/plan-usage-contract.mjs +271 -0
- package/server/services/plan-usage-copilot.mjs +421 -0
- package/server/services/plan-usage-cursor.mjs +566 -0
- package/server/services/plan-usage-grok.mjs +436 -0
- package/server/services/plan-usage-service.mjs +487 -0
- package/server/services/preview-health-probe.mjs +82 -0
- package/server/services/preview-proxy-server.mjs +498 -0
- package/server/services/preview-registry-service.mjs +457 -0
- package/server/services/preview-static-handler.mjs +237 -0
- package/server/services/provider-model-selection.mjs +91 -0
- package/server/services/provider-reasoning-effort.mjs +94 -0
- package/server/services/push-active-device-service.mjs +57 -0
- package/server/services/push-dispatch-service.mjs +459 -0
- package/server/services/rate-limit-service.mjs +63 -0
- package/server/services/relay-bridge-owner-service.mjs +84 -0
- package/server/services/relay-cli-launcher-service.mjs +209 -0
- package/server/services/relay-prompt-sanitizer.mjs +137 -0
- package/server/services/relay-queue-gate-service.mjs +38 -0
- package/server/services/relay-restart-orchestrator-service.mjs +935 -0
- package/server/services/relay-singleton-guard.mjs +124 -0
- package/server/services/safe-served-content.mjs +57 -0
- package/server/services/sdk-session-import-service.mjs +271 -0
- package/server/services/sdk-session-sync-service.mjs +207 -0
- package/server/services/session-history-refresh-service.mjs +497 -0
- package/server/services/session-transcript-service.mjs +419 -0
- package/server/services/session-worker-launch-service.mjs +786 -0
- package/server/services/session-worker-process-service.mjs +319 -0
- package/server/services/session-worker-registry-service.mjs +194 -0
- package/server/services/session-worker-stop-service.mjs +141 -0
- package/server/services/session-worker-supervisor-service.mjs +898 -0
- package/server/services/session-worker-websocket-service.mjs +483 -0
- package/server/services/ssh-tunnel-manager-service.mjs +423 -0
- package/server/services/status-event-service.mjs +158 -0
- package/server/services/tmux-inspector-access-policy.mjs +77 -0
- package/server/services/tmux-inspector-socket-service.mjs +191 -0
- package/server/services/tmux-inspector-stream-service.mjs +552 -0
- package/server/services/tunnel-worker-path-guard.mjs +107 -0
- package/server/services/upload-sweep.mjs +60 -0
- package/server/services/usage-snapshot-helpers.mjs +209 -0
- package/server/services/windows-autostart-service.mjs +115 -0
- package/server/services/workspace-root-defaults-service.mjs +34 -0
- package/server/services/workspace-root-path-policy.mjs +196 -0
- package/server/services/workspace-root-relaunch-service.mjs +132 -0
- package/server/services/workspace-symlink-guard.mjs +54 -0
- package/server/tools/capture-proxy.mjs +173 -0
- package/server/tty-console-bootstrap.mjs +75 -0
- package/server/uploads/README.md +36 -0
- package/server/uploads/image_input.md +540 -0
- package/server/windows-terminal-launcher.mjs +29 -0
- package/server/workspace-root.mjs +87 -0
- package/shared/ask-user-bridge.mjs +131 -0
- package/shared/auto-compact-window.mjs +116 -0
- package/shared/background-task-timeout.mjs +52 -0
- package/shared/claude-thinking.mjs +101 -0
- package/shared/context-window-fallbacks.mjs +148 -0
- package/shared/control-poller.mjs +115 -0
- package/shared/empty-turn-completion.mjs +20 -0
- package/shared/model-catalog-freshness.mjs +11 -0
- package/shared/model-descriptors.mjs +118 -0
- package/shared/model-id.mjs +147 -0
- package/shared/model-refresh.mjs +24 -0
- package/shared/openai-reasoning.mjs +13 -0
- package/shared/plan-lines.mjs +13 -0
- package/shared/preview-instructions.mjs +77 -0
- package/shared/preview-tool-core.mjs +191 -0
- package/shared/provider-routing.mjs +29 -0
- package/shared/question-schema.mjs +315 -0
- package/shared/question-timeout.mjs +18 -0
- package/shared/stream-emit-gating.mjs +28 -0
- package/shared/subagent-run-id.mjs +17 -0
- package/shared/thought-cap.mjs +13 -0
- package/shared/tool-activity.mjs +98 -0
- package/shared/turn-ceiling.mjs +53 -0
- package/shared/worker-bootstrap.mjs +44 -0
- 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
|
+
}
|