docks-kit 0.8.2 → 0.10.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.
@@ -32,6 +32,7 @@ import { mergeSettings, reconcileSettings } from "./settings"
32
32
  import { ensure, field } from "./toolchain"
33
33
  import { payloadBytes, payloadDisplayPath, payloadText } from "../payload"
34
34
  import { renderDefaultWorkflowInstructions } from "./workflowDeploy"
35
+ import { ensureSessionRelayCli } from "./sessionRelayCli"
35
36
 
36
37
  export type ClaudeRuntimeState =
37
38
  | { readonly kind: "ready"; readonly paths: ClaudeRuntimePaths }
@@ -61,8 +62,7 @@ export function claudeSync(ctx: Ctx): ClaudeRuntimeState {
61
62
  }
62
63
  const materialized = materializeClaudeSettings(
63
64
  template,
64
- runtime.kind === "ready" ? runtime.paths : undefined,
65
- ctx.services.platform
65
+ runtime.kind === "ready" ? runtime.paths : undefined
66
66
  )
67
67
  const prepared = ctx.dryRun ? undefined : prepareClaudeSettings(ctx, claudeDir, materialized)
68
68
 
@@ -82,6 +82,7 @@ export function claudeSync(ctx: Ctx): ClaudeRuntimeState {
82
82
  syncClaudeAdvisor(ctx, ctx.claudeAdvisor)
83
83
  syncClaudeJson(ctx)
84
84
  syncConnectorEnv(ctx)
85
+ ensureSessionRelayCli(ctx)
85
86
  syncPlugins(ctx, claudeDir)
86
87
  syncOptionalPlugins(ctx, claudeDir)
87
88
  syncLspServers(ctx)
@@ -148,12 +149,7 @@ function syncRtk(ctx: Ctx, claudeDir: string): void {
148
149
  return
149
150
  }
150
151
 
151
- if (ctx.services.platform.isWindows()) {
152
- if (ctx.services.deps.probe("rtk").state === "missing") {
153
- warn("rtk not installed — the kit's auto-install is Unix-only. Install natively (winget, or the rtk-*-windows-msvc.zip release), then re-run sync")
154
- return
155
- }
156
- } else if (ensureRtk(ctx, "cannot download RTK installer; continuing sync without RTK", 0) !== 0) {
152
+ if (ensureRtk(ctx, "cannot download RTK installer; continuing sync without RTK", 0) !== 0) {
157
153
  warn("RTK bootstrap failed — continuing sync without it")
158
154
  }
159
155
 
@@ -410,32 +406,7 @@ function syncClaudeJson(ctx: Ctx): void {
410
406
  // -------------------------------------------------------- connector env ----
411
407
 
412
408
  function syncConnectorEnv(ctx: Ctx): void {
413
- const { change, echo, verbose, warn } = ctx.services.logger
414
- // win32: Claude Code launches from PowerShell/GUI, so the flag must be a
415
- // real user env var (setx), not a Git-Bash-only shell-rc export. Never
416
- // clobbers an existing value (set =true yourself to keep connectors).
417
- if (ctx.services.platform.isWindows()) {
418
- const existing = spawnSync("reg", ["query", "HKCU\\Environment", "/v", "ENABLE_CLAUDEAI_MCP_SERVERS"], {
419
- stdio: "ignore"
420
- })
421
- if (existing.error === undefined && existing.status === 0) {
422
- if (ctx.dryRun) echo("[dry-run] ENABLE_CLAUDEAI_MCP_SERVERS already in user environment — would skip")
423
- else verbose("claude.ai connectors: ENABLE_CLAUDEAI_MCP_SERVERS already set in user environment (left as-is)")
424
- return
425
- }
426
- if (ctx.dryRun) {
427
- echo("[dry-run] setx ENABLE_CLAUDEAI_MCP_SERVERS false (user environment)")
428
- return
429
- }
430
- const res = spawnSync("setx", ["ENABLE_CLAUDEAI_MCP_SERVERS", "false"], { stdio: "ignore" })
431
- if (res.error === undefined && res.status === 0) {
432
- change("claude.ai connectors disabled via setx (open a new terminal to apply)")
433
- ctx.nextStepTriggers.claudeRestart = true
434
- } else {
435
- warn("setx ENABLE_CLAUDEAI_MCP_SERVERS false failed — set it manually in System Properties > Environment Variables")
436
- }
437
- return
438
- }
409
+ const { change, echo, verbose } = ctx.services.logger
439
410
 
440
411
  const line = "export ENABLE_CLAUDEAI_MCP_SERVERS=false"
441
412
  const marker = "# docks-kit: disable claude.ai cloud MCP connectors (set =true to keep them)"
@@ -13,6 +13,7 @@ import { compareCodepoints, isObject, jqStringify, parseJson, type Json } from "
13
13
  import { sessionRelayReadiness } from "./sessionRelayReadiness"
14
14
  import { payloadBytes, payloadDisplayPath, payloadPaths, payloadText, type PayloadPath } from "../payload"
15
15
  import { renderDefaultWorkflowInstructions } from "./workflowDeploy"
16
+ import { ensureSessionRelayCli } from "./sessionRelayCli"
16
17
 
17
18
  export function codexSync(ctx: Ctx): void {
18
19
  const codexDir = p(ctx.home, ".codex")
@@ -28,6 +29,7 @@ export function codexSync(ctx: Ctx): void {
28
29
  syncAgentsMd(ctx, renderDefaultWorkflowInstructions(payloadText("SoT/.codex/AGENTS.md")), p(codexDir, "AGENTS.md"))
29
30
  syncMarketplace(ctx, payloadText("SoT/.codex/plugins/marketplace.json"), p(ctx.agentsDir, "plugins", "marketplace.json"))
30
31
  removeLegacyDocksMarketplace(ctx, userConfig)
32
+ ensureSessionRelayCli(ctx)
31
33
  syncPlugins(ctx, sotConfig)
32
34
  }
33
35
 
@@ -84,7 +86,7 @@ function bwrapSupportedOs(ctx: Ctx): boolean {
84
86
  const { warn } = ctx.services.logger
85
87
  const pn = ctx.services.platform.name()
86
88
  if (pn === "linux") return true
87
- if (pn === "darwin" || pn === "windows") return false
89
+ if (pn === "darwin") return false
88
90
  warn("Unknown OS — skipping bubblewrap check; Codex sandbox may not work")
89
91
  return false
90
92
  }
@@ -29,6 +29,7 @@ export type ToolId =
29
29
  | "bwrap"
30
30
  | "agent-browser"
31
31
  | "effect-solutions"
32
+ | "session-relay"
32
33
  | "chrome-for-testing"
33
34
  | "ffplay"
34
35
  | "intelephense"
@@ -111,34 +112,29 @@ const home = (): string => {
111
112
  return envHome !== undefined && envHome !== "" ? envHome : homedir()
112
113
  }
113
114
 
114
- const absoluteWindowsExe = (path: string): boolean =>
115
- /\.exe$/i.test(path) && (/^[A-Za-z]:[\\/]/.test(path) || /^\\\\/.test(path))
116
115
 
117
116
  // The resolved path gets persisted into global direct-exec hooks, so a
118
117
  // relative `which` hit (relative PATH entry, relative BUN_INSTALL) would
119
118
  // break outside the sync working directory.
120
- const absoluteExecutable = (path: string, platform: NodeJS.Platform): boolean =>
121
- platform === "win32" ? absoluteWindowsExe(path) : isAbsolute(path)
122
119
 
123
- const findBun = (exec: ProbeExecutor, platform: NodeJS.Platform = rawPlatform()): { command: string; path: string } | undefined => {
120
+ const findBun = (exec: ProbeExecutor): { command: string; path: string } | undefined => {
124
121
  const onPath = exec.which("bun")
125
- if (onPath !== "" && absoluteExecutable(onPath, platform)) {
126
- return { command: platform === "win32" ? onPath : "bun", path: onPath }
122
+ if (onPath !== "" && isAbsolute(onPath)) {
123
+ return { command: "bun", path: onPath }
127
124
  }
128
125
  const root =
129
126
  process.env["BUN_INSTALL"] !== undefined && process.env["BUN_INSTALL"] !== ""
130
127
  ? process.env["BUN_INSTALL"]!
131
128
  : p(home(), ".bun")
132
- const name = platform === "win32" ? "bun.exe" : "bun"
133
- for (const candidate of [p(root, "bin", name), p(home(), ".bun", "bin", name)]) {
129
+ for (const candidate of [p(root, "bin", "bun"), p(home(), ".bun", "bin", "bun")]) {
134
130
  const found = exec.which(candidate)
135
- if (found !== "" && absoluteExecutable(found, platform)) return { command: found, path: found }
131
+ if (found !== "" && isAbsolute(found)) return { command: found, path: found }
136
132
  }
137
133
  return undefined
138
134
  }
139
135
 
140
- const resolveBun = (exec: ProbeExecutor, platform: NodeJS.Platform): ProbeResult => {
141
- const bun = findBun(exec, platform)
136
+ const resolveBun = (exec: ProbeExecutor): ProbeResult => {
137
+ const bun = findBun(exec)
142
138
  return bun === undefined
143
139
  ? { state: "missing" }
144
140
  : { state: "present", path: bun.path }
@@ -160,28 +156,23 @@ const versionEffectSolutions = (exec: ProbeExecutor): string => {
160
156
  return match?.[1] ?? ""
161
157
  }
162
158
 
163
- const locateEffectSolutions = (exec: ProbeExecutor, platform: NodeJS.Platform): DependencyLocation => {
164
- const strictBun = findBun(exec, platform)
159
+ const locateEffectSolutions = (exec: ProbeExecutor): DependencyLocation => {
160
+ const strictBun = findBun(exec)
165
161
  const pathBun = exec.which("bun")
166
162
  const bun = strictBun ?? (pathBun !== "" ? { command: "bun", path: pathBun } : undefined)
167
163
  if (bun === undefined) return { path: "", binDir: "" }
168
164
  const globalBin = exec.capture(bun.command, ["pm", "-g", "bin"])
169
- const names =
170
- platform === "win32"
171
- ? ["effect-solutions.exe", "effect-solutions.cmd", "effect-solutions.bunx"]
172
- : ["effect-solutions"]
173
- const path = names.map((name) => p(globalBin, name)).find((candidate) => globalBin !== "" && exec.which(candidate) !== "")
174
- return { path: path ?? "", binDir: globalBin }
165
+ const path = globalBin !== "" ? p(globalBin, "effect-solutions") : ""
166
+ const resolved = path !== "" && exec.which(path) !== "" ? path : ""
167
+ return { path: resolved, binDir: globalBin }
175
168
  }
176
169
 
177
170
  const resolveChrome = (exec: ProbeExecutor, platform: NodeJS.Platform): ProbeResult => {
178
171
  const root = p(home(), ".agent-browser", "browsers")
179
172
  const relative =
180
- platform === "win32"
181
- ? "chrome.exe"
182
- : platform === "darwin"
183
- ? "Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing"
184
- : "chrome"
173
+ platform === "darwin"
174
+ ? "Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing"
175
+ : "chrome"
185
176
  if (existsSync(root)) {
186
177
  for (const directory of readdirSync(root).filter((name) => name.startsWith("chrome-")).sort().reverse()) {
187
178
  const path = exec.which(p(root, directory, relative))
@@ -214,23 +205,19 @@ export const DEPENDENCIES: Record<ToolId, DependencySpec> = {
214
205
  "git",
215
206
  "optional",
216
207
  (pf = rawPlatform()) =>
217
- pf === "win32"
218
- ? "winget install Git.Git (then open a new terminal)"
219
- : pf === "darwin"
220
- ? "brew install git"
221
- : "sudo apt install -y git (or your distro's package manager)",
208
+ pf === "darwin"
209
+ ? "brew install git"
210
+ : "sudo apt install -y git (or your distro's package manager)",
222
211
  { version: versionProbe("git") }
223
212
  ),
224
213
  jq: spec("jq", "optional", (pf = rawPlatform()) =>
225
- pf === "win32"
226
- ? "winget install jqlang.jq (then open a new terminal)"
227
- : pf === "darwin"
228
- ? "brew install jq"
229
- : "sudo apt install -y jq",
214
+ pf === "darwin"
215
+ ? "brew install jq"
216
+ : "sudo apt install -y jq",
230
217
  { version: versionProbe("jq") }
231
218
  ),
232
219
  curl: spec("curl", "optional", (pf = rawPlatform()) =>
233
- pf === "win32" ? "winget install cURL.cURL" : pf === "darwin" ? "brew install curl" : "sudo apt install -y curl",
220
+ pf === "darwin" ? "brew install curl" : "sudo apt install -y curl",
234
221
  { version: versionProbe("curl") }
235
222
  ),
236
223
  node: spec("node", "optional", () => "install Node.js via https://nodejs.org (or your package manager)", {
@@ -241,34 +228,32 @@ export const DEPENDENCIES: Record<ToolId, DependencySpec> = {
241
228
  claude: spec(
242
229
  "claude",
243
230
  "optional",
244
- (pf = rawPlatform()) =>
245
- pf === "win32"
246
- ? "winget install Anthropic.ClaudeCode"
247
- : "curl -fsSL https://claude.ai/install.sh -o /tmp/claude-install.sh && bash /tmp/claude-install.sh",
231
+ () => "curl -fsSL https://claude.ai/install.sh -o /tmp/claude-install.sh && bash /tmp/claude-install.sh",
248
232
  { version: versionProbe("claude") }
249
233
  ),
250
234
  codex: spec(
251
235
  "codex",
252
236
  "optional",
253
- (pf = rawPlatform()) =>
254
- pf === "win32"
255
- ? `powershell -ExecutionPolicy ByPass -c "irm https://chatgpt.com/codex/install.ps1 | iex"`
256
- : 'tmp=$(mktemp) && curl -fsSL https://chatgpt.com/codex/install.sh -o "$tmp" && CODEX_NON_INTERACTIVE=1 sh "$tmp"',
237
+ () => 'tmp=$(mktemp) && curl -fsSL https://chatgpt.com/codex/install.sh -o "$tmp" && CODEX_NON_INTERACTIVE=1 sh "$tmp"',
257
238
  { version: versionProbe("codex") }
258
239
  ),
259
- rtk: spec("rtk", "optional", () => "see https://github.com/rtk-ai/rtk (kit auto-install is Unix-only)", {
240
+ rtk: spec("rtk", "optional", () => "see https://github.com/rtk-ai/rtk (kit auto-install is Linux/macOS-only)", {
260
241
  version: versionProbe("rtk"),
261
242
  latest: latestRtk
262
243
  }),
244
+ "session-relay": spec("session-relay", "optional", () => "docks-kit toolchain ensure session-relay", {
245
+ resolve: (exec) => pathProbe(p(home(), ".local", "bin", "session-relay"))(exec),
246
+ version: (exec) => exec.capture(p(home(), ".local", "bin", "session-relay"), ["--version"]),
247
+ locate: () => ({ path: p(home(), ".local", "bin", "session-relay"), binDir: p(home(), ".local", "bin") })
248
+ }),
263
249
  bun: spec(
264
250
  "bun",
265
251
  "optional",
266
- (pf = rawPlatform()) =>
267
- pf === "win32" ? `powershell -c "irm bun.sh/install.ps1 | iex"` : "curl -fsSL https://bun.sh/install | bash",
252
+ () => "curl -fsSL https://bun.sh/install | bash",
268
253
  {
269
254
  resolve: resolveBun,
270
255
  version: (exec) => exec.capture(versionBunCommand(exec), ["--version"]),
271
- locate: (exec, platform) => ({ path: findBun(exec, platform)?.path ?? "", binDir: "" })
256
+ locate: (exec) => ({ path: findBun(exec)?.path ?? "", binDir: "" })
272
257
  }
273
258
  ),
274
259
  bwrap: spec("bwrap", "optional", () => "sudo apt install -y bubblewrap (or dnf/pacman/zypper equivalent)"),
@@ -292,7 +277,7 @@ export const DEPENDENCIES: Record<ToolId, DependencySpec> = {
292
277
  "ffplay",
293
278
  "optional",
294
279
  (pf = rawPlatform()) =>
295
- pf === "win32" ? "winget install Gyan.FFmpeg" : pf === "darwin" ? "brew install ffmpeg" : "sudo apt install -y ffmpeg",
280
+ pf === "darwin" ? "brew install ffmpeg" : "sudo apt install -y ffmpeg",
296
281
  { versionArgs: ["-version"], resolve: pathProbe("ffplay") }
297
282
  ),
298
283
  intelephense: spec("intelephense", "optional", () => "npm install -g intelephense", {
@@ -7,11 +7,7 @@ import { spawnSync } from "node:child_process"
7
7
  import { accessSync, chmodSync, constants, existsSync, readFileSync, statSync, writeFileSync } from "node:fs"
8
8
  import { delimiter, isAbsolute, join } from "node:path"
9
9
 
10
- /**
11
- * Engine paths are built with "/" because they appear verbatim in output
12
- * (dry-run lines, warns), where node:path.join would print "\" on Windows
13
- * and break the golden contract. fs accepts "/" on every platform.
14
- */
10
+ /** Keep engine paths slash-separated so rendered output is host-stable. */
15
11
  export function p(...parts: Array<string>): string {
16
12
  return parts.join("/")
17
13
  }
@@ -22,18 +18,15 @@ export function capture(cmd: string, args: ReadonlyArray<string>): string {
22
18
  return (res.stdout ?? "").replace(/[\r\n]+$/, "")
23
19
  }
24
20
 
25
- /** `command -v` — resolve a name on PATH (PATHEXT-aware on Windows). */
21
+ /** `command -v` — resolve an executable name on PATH. */
26
22
  export function which(name: string): string {
27
- if (isAbsolute(name) || name.includes("/") || name.includes("\\")) {
23
+ if (isAbsolute(name) || name.includes("/")) {
28
24
  return isExecutable(name) ? name : ""
29
25
  }
30
- const exts = process.platform === "win32" ? (process.env["PATHEXT"] ?? ".EXE;.CMD;.BAT;.COM").split(";").concat("") : [""]
31
26
  for (const dir of (process.env["PATH"] ?? "").split(delimiter)) {
32
27
  if (dir === "") continue
33
- for (const ext of exts) {
34
- const cand = join(dir, name + ext.toLowerCase())
35
- if (isExecutable(cand)) return cand
36
- }
28
+ const candidate = join(dir, name)
29
+ if (isExecutable(candidate)) return candidate
37
30
  }
38
31
  return ""
39
32
  }
@@ -45,7 +38,7 @@ export function commandExists(name: string): boolean {
45
38
  export function isExecutable(p: string): boolean {
46
39
  try {
47
40
  if (!statSync(p).isFile()) return false
48
- if (process.platform !== "win32") accessSync(p, constants.X_OK)
41
+ accessSync(p, constants.X_OK)
49
42
  return true
50
43
  } catch {
51
44
  return false
@@ -12,6 +12,7 @@ import type { Ctx } from "./index"
12
12
  import { isObject, parseJson, type Json } from "./jq"
13
13
  import { printModels, validateClaudeModel, validateCodexModel } from "./models"
14
14
  import { ensureRtk } from "./claudeSync"
15
+ import { ensureSessionRelayCli } from "./sessionRelayCli"
15
16
  import { bunBootstrap } from "./bun"
16
17
  import { agentBrowserInstall, effectSolutionsInstall } from "./skillsSync"
17
18
  import { ensure, report } from "./toolchain"
@@ -142,8 +143,10 @@ export function modeToolchain(ctx: Ctx, args: ReadonlyArray<string>): number {
142
143
  return ensure(ctx, "effect-solutions", effectSolutionsInstall(ctx))
143
144
  case "agent-browser":
144
145
  return ensure(ctx, "agent-browser", agentBrowserInstall)
146
+ case "session-relay":
147
+ return ensureSessionRelayCli(ctx)
145
148
  default:
146
- err("toolchain ensure supports managed tools only (rtk, bun, effect-solutions, agent-browser)")
149
+ err("toolchain ensure supports managed tools only (rtk, bun, effect-solutions, agent-browser, session-relay)")
147
150
  return 2
148
151
  }
149
152
  }
@@ -1,29 +1,24 @@
1
1
  /**
2
- * Platform capability seam (Output Policy in DESIGN.md) the only engine
3
- * module that reads process.platform besides exec.ts's PATH/executability
4
- * primitives. Per-tool package identifiers stay in deps.ts; symlink handling
5
- * stays try-then-fallback at the call site (capability-driven, not predicted).
2
+ * Platform capability seam (Output Policy in DESIGN.md). Per-tool package
3
+ * identifiers stay in deps.ts; symlink handling stays try-then-fallback at
4
+ * the call site (capability-driven, not predicted).
6
5
  */
7
6
 
8
- export type PlatformName = "linux" | "darwin" | "windows" | "unknown"
7
+ export type PlatformName = "linux" | "darwin" | "unknown"
9
8
 
10
9
  export function rawPlatform(): NodeJS.Platform {
11
10
  return process.platform
12
11
  }
13
12
 
14
13
  export function platformName(pf: NodeJS.Platform = rawPlatform()): PlatformName {
15
- return pf === "linux" ? "linux" : pf === "darwin" ? "darwin" : pf === "win32" ? "windows" : "unknown"
16
- }
17
-
18
- export function isWindows(): boolean {
19
- return rawPlatform() === "win32"
14
+ return pf === "linux" ? "linux" : pf === "darwin" ? "darwin" : "unknown"
20
15
  }
21
16
 
22
17
  export function isLinux(): boolean {
23
18
  return rawPlatform() === "linux"
24
19
  }
25
20
 
26
- /** Shell-rc exports (bashrc/zshrc) apply only off Windows. */
21
+ /** Shell-rc exports apply only on supported hosts. */
27
22
  export function shellRcApplicable(): boolean {
28
- return !isWindows()
23
+ return platformName() !== "unknown"
29
24
  }
@@ -36,7 +36,6 @@ export interface DependencyManager {
36
36
  export interface Platform {
37
37
  readonly raw: () => NodeJS.Platform
38
38
  readonly name: () => PlatformName
39
- readonly isWindows: () => boolean
40
39
  readonly isLinux: () => boolean
41
40
  readonly shellRcApplicable: () => boolean
42
41
  }
@@ -51,13 +50,12 @@ export interface EngineServiceOptions {
51
50
  readonly sinks?: LoggerSinks
52
51
  }
53
52
 
54
- /** Platform view over an injectable platform id (tests pass e.g. "win32"). */
53
+ /** Platform view over an injectable platform id. */
55
54
  export const makePlatform = (pf: NodeJS.Platform = rawPlatform()): Platform => ({
56
55
  raw: () => pf,
57
56
  name: () => platformName(pf),
58
- isWindows: () => pf === "win32",
59
57
  isLinux: () => pf === "linux",
60
- shellRcApplicable: () => pf !== "win32"
58
+ shellRcApplicable: () => pf === "linux" || pf === "darwin"
61
59
  })
62
60
 
63
61
  /** DependencyManager whose hints default to the INJECTED platform, not the host. */
@@ -0,0 +1,257 @@
1
+ import { spawnSync } from "node:child_process"
2
+ import { createHash, randomBytes } from "node:crypto"
3
+ import {
4
+ chmodSync,
5
+ existsSync,
6
+ mkdirSync,
7
+ readFileSync,
8
+ renameSync,
9
+ rmSync,
10
+ statSync
11
+ } from "node:fs"
12
+ import { dirname, join } from "node:path"
13
+
14
+ import { isObject, parseJson, type Json } from "./jq"
15
+ import type { Ctx } from "./index"
16
+ import { ExitError } from "./parseArgs"
17
+ import { payloadText } from "../payload"
18
+
19
+ const VERSION = "0.12.0"
20
+ const REPOSITORY = "DocksDocks/docks"
21
+ const TAG = "session-relay--v0.12.0"
22
+ const PLUGIN_ID = "session-relay@docks"
23
+ const INSTALL_PATH = "~/.local/bin/session-relay"
24
+ const TARGETS = [
25
+ "x86_64-unknown-linux-musl",
26
+ "aarch64-unknown-linux-musl",
27
+ "x86_64-apple-darwin",
28
+ "aarch64-apple-darwin"
29
+ ] as const
30
+
31
+ export type SessionRelayTarget = typeof TARGETS[number]
32
+
33
+ export interface SessionRelayManifest {
34
+ readonly kind: "managed-release"
35
+ readonly policy: "exact"
36
+ readonly verified: typeof VERSION
37
+ readonly repository: typeof REPOSITORY
38
+ readonly tag: typeof TAG
39
+ readonly plugin_id: typeof PLUGIN_ID
40
+ readonly plugin_version: typeof VERSION
41
+ readonly install_path: typeof INSTALL_PATH
42
+ readonly assets: Readonly<Record<SessionRelayTarget, string>>
43
+ }
44
+
45
+ export interface SessionRelayInstallOps {
46
+ readonly download: (url: string, destination: string) => boolean
47
+ readonly chmod: (path: string, mode: number) => void
48
+ readonly runVersion: (path: string) => { readonly ok: boolean; readonly stdout: string }
49
+ readonly rename: (from: string, to: string) => void
50
+ readonly uniqueSuffix: () => string
51
+ }
52
+
53
+ export interface SessionRelayInstallInput {
54
+ readonly home: string
55
+ readonly dryRun: boolean
56
+ readonly platform: string
57
+ readonly arch: string
58
+ readonly manifestText: string
59
+ readonly log: (line: string) => void
60
+ readonly error?: (line: string) => void
61
+ }
62
+
63
+ function fail(input: SessionRelayInstallInput, message: string): never {
64
+ input.error?.(message)
65
+ const error = new ExitError(1)
66
+ error.message = message
67
+ throw error
68
+ }
69
+
70
+ function exactKeys(value: { [key: string]: Json }, expected: ReadonlyArray<string>, label: string): void {
71
+ const actual = Object.keys(value).sort()
72
+ const wanted = [...expected].sort()
73
+ if (actual.length !== wanted.length || actual.some((key, index) => key !== wanted[index])) {
74
+ throw new Error(`${label} violates the closed Session Relay manifest schema`)
75
+ }
76
+ }
77
+
78
+ export function parseSessionRelayManifest(text: string): SessionRelayManifest {
79
+ const value = parseJson(text)
80
+ if (value === undefined || !isObject(value)) throw new Error("Session Relay manifest is not a JSON object")
81
+ exactKeys(
82
+ value,
83
+ ["kind", "policy", "verified", "repository", "tag", "plugin_id", "plugin_version", "install_path", "assets"],
84
+ "Session Relay manifest"
85
+ )
86
+ const expected = {
87
+ kind: "managed-release",
88
+ policy: "exact",
89
+ verified: VERSION,
90
+ repository: REPOSITORY,
91
+ tag: TAG,
92
+ plugin_id: PLUGIN_ID,
93
+ plugin_version: VERSION,
94
+ install_path: INSTALL_PATH
95
+ } as const
96
+ for (const [key, wanted] of Object.entries(expected)) {
97
+ if (value[key] !== wanted) throw new Error(`Session Relay manifest ${key.replaceAll("_", " ")} must be ${wanted}`)
98
+ }
99
+ const assets = value["assets"]
100
+ if (!isObject(assets)) throw new Error("Session Relay manifest assets must be an object")
101
+ exactKeys(assets, TARGETS, "Session Relay manifest assets target set")
102
+ const parsedAssets = {} as Record<SessionRelayTarget, string>
103
+ for (const target of TARGETS) {
104
+ const digest = assets[target]
105
+ if (typeof digest !== "string" || !/^[0-9a-f]{64}$/.test(digest)) {
106
+ throw new Error(`Session Relay manifest digest for ${target} must be 64 lowercase hex characters`)
107
+ }
108
+ parsedAssets[target] = digest
109
+ }
110
+ return { ...expected, assets: parsedAssets }
111
+ }
112
+
113
+ function manifestEntry(): string {
114
+ const document = parseJson(payloadText("SoT/toolchain.json"))
115
+ const tools = document !== undefined && isObject(document) ? document["tools"] : undefined
116
+ const entry = tools !== undefined && isObject(tools) ? tools["session-relay"] : undefined
117
+ if (entry === undefined) throw new Error("Embedded toolchain manifest has no session-relay entry")
118
+ return JSON.stringify(entry)
119
+ }
120
+
121
+ export function sessionRelayTarget(platform: string, arch: string): SessionRelayTarget {
122
+ if (platform === "linux" && arch === "x64") return "x86_64-unknown-linux-musl"
123
+ if (platform === "linux" && arch === "arm64") return "aarch64-unknown-linux-musl"
124
+ if (platform === "darwin" && arch === "x64") return "x86_64-apple-darwin"
125
+ if (platform === "darwin" && arch === "arm64") return "aarch64-apple-darwin"
126
+ throw new Error(`Unsupported host for Session Relay CLI: ${platform}/${arch}; supported: linux|darwin x64|arm64`)
127
+ }
128
+
129
+ function trimOneLineEnding(text: string): string {
130
+ if (text.endsWith("\r\n")) return text.slice(0, -2)
131
+ if (text.endsWith("\n")) return text.slice(0, -1)
132
+ return text
133
+ }
134
+
135
+ function exactVersion(ops: SessionRelayInstallOps, path: string): boolean {
136
+ const result = ops.runVersion(path)
137
+ return result.ok && trimOneLineEnding(result.stdout) === `session-relay ${VERSION}`
138
+ }
139
+
140
+ function selectedChecksum(text: string, assetName: string): string {
141
+ const selected = text.split("\n").filter((line) => line.endsWith(` ${assetName}`))
142
+ if (selected.length !== 1 || !new RegExp(`^[0-9a-f]{64} ${assetName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`).test(selected[0]!)) {
143
+ throw new Error(`SHA256SUMS must contain exactly one canonical row for ${assetName}`)
144
+ }
145
+ return selected[0]!.slice(0, 64)
146
+ }
147
+
148
+ const defaultOps: SessionRelayInstallOps = {
149
+ download: (url, destination) => {
150
+ const result = spawnSync("curl", ["-fL", "--retry", "2", "--connect-timeout", "10", "--output", destination, url], {
151
+ stdio: "inherit"
152
+ })
153
+ return result.error === undefined && result.status === 0
154
+ },
155
+ chmod: chmodSync,
156
+ runVersion: (path) => {
157
+ const result = spawnSync(path, ["--version"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] })
158
+ return { ok: result.error === undefined && result.status === 0, stdout: result.stdout ?? "" }
159
+ },
160
+ rename: renameSync,
161
+ uniqueSuffix: () => `${process.pid}-${randomBytes(8).toString("hex")}`
162
+ }
163
+
164
+ export function installSessionRelayCli(
165
+ input: SessionRelayInstallInput,
166
+ ops: SessionRelayInstallOps = defaultOps
167
+ ): void {
168
+ let manifest: SessionRelayManifest
169
+ let target: SessionRelayTarget
170
+ try {
171
+ manifest = parseSessionRelayManifest(input.manifestText)
172
+ target = sessionRelayTarget(input.platform, input.arch)
173
+ } catch (error) {
174
+ fail(input, error instanceof Error ? error.message : String(error))
175
+ }
176
+
177
+ if (input.dryRun) {
178
+ input.log(
179
+ `[dry-run] ensure Session Relay CLI ${manifest.verified} from ${manifest.repository}@${manifest.tag} (${target}) -> ${manifest.install_path}`
180
+ )
181
+ return
182
+ }
183
+
184
+ const stable = join(input.home, ".local", "bin", "session-relay")
185
+ if (existsSync(stable) && exactVersion(ops, stable)) return
186
+
187
+ const parent = dirname(stable)
188
+ try {
189
+ mkdirSync(parent, { recursive: true, mode: 0o755 })
190
+ if (!statSync(parent).isDirectory()) {
191
+ fail(input, `Session Relay install parent is not a directory: ${parent}`)
192
+ }
193
+ } catch (error) {
194
+ if (error instanceof ExitError) throw error
195
+ fail(input, `Cannot prepare Session Relay install directory ${parent}: ${error instanceof Error ? error.message : String(error)}`)
196
+ }
197
+
198
+ const suffix = ops.uniqueSuffix()
199
+ const stage = join(parent, `.session-relay.stage-${suffix}`)
200
+ const checksumFile = join(parent, `.session-relay.checksums-${suffix}`)
201
+ const assetName = `session-relay-${target}`
202
+ const baseUrl = `https://github.com/${manifest.repository}/releases/download/${manifest.tag}`
203
+
204
+ try {
205
+ if (!ops.download(`${baseUrl}/${assetName}`, stage)) fail(input, `Failed to download pinned Session Relay asset ${assetName}`)
206
+ if (!ops.download(`${baseUrl}/SHA256SUMS`, checksumFile)) fail(input, "Failed to download pinned Session Relay SHA256SUMS")
207
+
208
+ let checksumDigest: string
209
+ try {
210
+ checksumDigest = selectedChecksum(readFileSync(checksumFile, "utf8"), assetName)
211
+ } catch (error) {
212
+ fail(input, error instanceof Error ? error.message : String(error))
213
+ }
214
+ const sourceDigest = manifest.assets[target]
215
+ if (checksumDigest !== sourceDigest) fail(input, `Session Relay source pin does not match SHA256SUMS for ${assetName}`)
216
+ const downloadedDigest = createHash("sha256").update(readFileSync(stage)).digest("hex")
217
+ if (downloadedDigest !== sourceDigest) fail(input, `Downloaded Session Relay checksum mismatch for ${assetName}`)
218
+
219
+ try {
220
+ ops.chmod(stage, 0o755)
221
+ } catch (error) {
222
+ fail(input, `Failed to chmod staged Session Relay CLI: ${error instanceof Error ? error.message : String(error)}`)
223
+ }
224
+ if (!exactVersion(ops, stage)) fail(input, `Staged Session Relay CLI did not report exact version session-relay ${VERSION}`)
225
+ try {
226
+ ops.rename(stage, stable)
227
+ } catch (error) {
228
+ fail(input, `Failed to atomically replace Session Relay CLI: ${error instanceof Error ? error.message : String(error)}`)
229
+ }
230
+ input.log(`Session Relay CLI ready (${VERSION})`)
231
+ } finally {
232
+ try {
233
+ rmSync(stage, { force: true })
234
+ } catch {
235
+ // A cleanup failure must not turn a successful atomic replacement into
236
+ // an install failure or alter a pre-existing stable executable.
237
+ }
238
+ try {
239
+ rmSync(checksumFile, { force: true })
240
+ } catch {
241
+ // Same failure-preservation rule as the staged executable above.
242
+ }
243
+ }
244
+ }
245
+
246
+ export function ensureSessionRelayCli(ctx: Ctx): number {
247
+ installSessionRelayCli({
248
+ home: ctx.home,
249
+ dryRun: ctx.dryRun,
250
+ platform: ctx.services.platform.raw(),
251
+ arch: process.arch,
252
+ manifestText: manifestEntry(),
253
+ log: ctx.dryRun ? ctx.services.logger.echo : ctx.services.logger.change,
254
+ error: ctx.services.logger.err
255
+ })
256
+ return 0
257
+ }