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.
- package/AGENTS.md +11 -5
- package/README.md +26 -15
- package/cli/docs/install.md +11 -20
- package/cli/docs/platforms.md +16 -44
- package/cli/docs/sync-layers.md +10 -2
- package/cli/docs/toolchain.md +17 -5
- package/cli/src/commands/docs.ts +1 -1
- package/cli/src/commands/toolchain.ts +1 -1
- package/cli/src/engine-native/DESIGN.md +19 -27
- package/cli/src/engine-native/bun.ts +7 -25
- package/cli/src/engine-native/claudeRuntime.ts +6 -15
- package/cli/src/engine-native/claudeSync.ts +5 -34
- package/cli/src/engine-native/codexSync.ts +3 -1
- package/cli/src/engine-native/deps.ts +34 -49
- package/cli/src/engine-native/exec.ts +6 -13
- package/cli/src/engine-native/modes.ts +4 -1
- package/cli/src/engine-native/os.ts +7 -12
- package/cli/src/engine-native/services.ts +2 -4
- package/cli/src/engine-native/sessionRelayCli.ts +257 -0
- package/cli/src/engine-native/skillsSync.ts +13 -38
- package/cli/src/engine-native/toolchain.ts +2 -0
- package/cli/src/engine.ts +23 -15
- package/cli/src/generated/sotPayload.ts +3 -3
- package/docks-kit +11 -10
- package/package.json +5 -1
- package/cli/src/engine-native/powershell.ts +0 -11
|
@@ -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
|
|
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
|
|
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"
|
|
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
|
|
120
|
+
const findBun = (exec: ProbeExecutor): { command: string; path: string } | undefined => {
|
|
124
121
|
const onPath = exec.which("bun")
|
|
125
|
-
if (onPath !== "" &&
|
|
126
|
-
return { command:
|
|
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
|
|
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 !== "" &&
|
|
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
|
|
141
|
-
const bun = findBun(exec
|
|
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
|
|
164
|
-
const strictBun = findBun(exec
|
|
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
|
|
170
|
-
|
|
171
|
-
|
|
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 === "
|
|
181
|
-
? "
|
|
182
|
-
:
|
|
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 === "
|
|
218
|
-
? "
|
|
219
|
-
:
|
|
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 === "
|
|
226
|
-
? "
|
|
227
|
-
:
|
|
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 === "
|
|
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
|
-
(
|
|
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
|
-
(
|
|
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
|
|
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
|
-
(
|
|
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
|
|
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 === "
|
|
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
|
|
21
|
+
/** `command -v` — resolve an executable name on PATH. */
|
|
26
22
|
export function which(name: string): string {
|
|
27
|
-
if (isAbsolute(name) || 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
|
-
|
|
34
|
-
|
|
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
|
-
|
|
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)
|
|
3
|
-
*
|
|
4
|
-
*
|
|
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" | "
|
|
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" :
|
|
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
|
|
21
|
+
/** Shell-rc exports apply only on supported hosts. */
|
|
27
22
|
export function shellRcApplicable(): boolean {
|
|
28
|
-
return
|
|
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
|
|
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
|
|
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
|
+
}
|