docks-kit 0.15.2 → 0.15.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/AGENTS.md +21 -15
  2. package/README.md +33 -31
  3. package/cli/docs/flags.md +0 -1
  4. package/cli/docs/install.md +28 -13
  5. package/cli/docs/overview.md +2 -2
  6. package/cli/docs/platforms.md +5 -2
  7. package/cli/docs/sync-layers.md +3 -4
  8. package/cli/docs/toolchain.md +26 -34
  9. package/cli/src/commands/docs.ts +3 -3
  10. package/cli/src/commands/sync.ts +0 -5
  11. package/cli/src/commands/toolchain.ts +4 -7
  12. package/cli/src/commands/update.ts +77 -26
  13. package/cli/src/engine-native/DESIGN.md +31 -22
  14. package/cli/src/engine-native/bun.ts +10 -8
  15. package/cli/src/engine-native/claudeRuntime.ts +17 -9
  16. package/cli/src/engine-native/claudeSync.ts +52 -24
  17. package/cli/src/engine-native/codexSync.ts +10 -5
  18. package/cli/src/engine-native/deps.ts +27 -88
  19. package/cli/src/engine-native/exec.ts +41 -10
  20. package/cli/src/engine-native/index.ts +0 -2
  21. package/cli/src/engine-native/modes.ts +23 -14
  22. package/cli/src/engine-native/os/darwin.ts +62 -0
  23. package/cli/src/engine-native/os/index.ts +42 -0
  24. package/cli/src/engine-native/os/linux.ts +62 -0
  25. package/cli/src/engine-native/os/targets.ts +73 -0
  26. package/cli/src/engine-native/os/types.ts +75 -0
  27. package/cli/src/engine-native/os/windows.ts +176 -0
  28. package/cli/src/engine-native/parseArgs.ts +0 -4
  29. package/cli/src/engine-native/services.ts +0 -6
  30. package/cli/src/engine-native/skillsSync.ts +125 -77
  31. package/cli/src/engine-native/toolchain.ts +4 -150
  32. package/cli/src/engine.ts +3 -2
  33. package/cli/src/generated/sotPayload.ts +6 -6
  34. package/cli/src/manifests.ts +12 -2
  35. package/docks-kit +1 -1
  36. package/docks-kit.ps1 +123 -0
  37. package/package.json +9 -5
  38. package/cli/src/engine-native/os.ts +0 -16
@@ -2,17 +2,17 @@
2
2
  * DependencyManager — one home for external-tool identity, presence probing,
3
3
  * and platform-correct install hints (Output Policy in DESIGN.md).
4
4
  *
5
- * Ownership split: SoT/toolchain.json + toolchain.ts keep version floors,
6
- * pin policy, and managed install/upgrade orchestration; this registry owns
7
- * WHICH external tools exist, whether they are required, and the one-line
8
- * command that installs a missing one.
5
+ * Ownership split: SoT/toolchain.json + toolchain.ts keep version floors, pin
6
+ * policy, and the doctor report, while bun.ts bunBootstrap owns the one managed
7
+ * install; this registry owns WHICH external tools exist, whether they are
8
+ * required, and the one-line command that installs a missing one.
9
9
  */
10
10
  import { homedir } from "node:os"
11
11
  import { isAbsolute } from "node:path"
12
12
 
13
13
  import { capture, commandExists, p, which } from "./exec"
14
14
  import { isObject, parseJson } from "./jq"
15
- import { rawPlatform } from "./os"
15
+ import { hostOs, platformName, rawPlatform } from "./os"
16
16
 
17
17
  export type ToolId =
18
18
  | "git"
@@ -25,7 +25,6 @@ export type ToolId =
25
25
  | "codex"
26
26
  | "bun"
27
27
  | "bwrap"
28
- | "effect-solutions"
29
28
  | "ffplay"
30
29
  | "intelephense"
31
30
  | "typescript-language-server"
@@ -42,11 +41,6 @@ export type ProbeResult =
42
41
  | { readonly state: "present"; readonly path?: string }
43
42
  | { readonly state: "missing" }
44
43
 
45
- export interface DependencyLocation {
46
- readonly path: string
47
- readonly binDir: string
48
- }
49
-
50
44
  export interface ProbeExecutor {
51
45
  readonly commandExists: (name: string) => boolean
52
46
  readonly capture: (cmd: string, args: ReadonlyArray<string>) => Promise<string>
@@ -61,16 +55,12 @@ export interface DependencySpec {
61
55
  readonly installHint: (platform?: NodeJS.Platform) => string
62
56
  readonly resolve?: (exec: ProbeExecutor, platform: NodeJS.Platform) => ProbeResult
63
57
  readonly version?: (exec: ProbeExecutor) => Promise<string>
64
- readonly locate?: (exec: ProbeExecutor, platform: NodeJS.Platform) => Promise<DependencyLocation>
65
- readonly latest?: (exec: ProbeExecutor) => Promise<string>
66
58
  }
67
59
 
68
60
  interface SpecOptions {
69
61
  readonly versionArgs?: ReadonlyArray<string>
70
62
  readonly resolve?: (exec: ProbeExecutor, platform: NodeJS.Platform) => ProbeResult
71
63
  readonly version?: (exec: ProbeExecutor) => Promise<string>
72
- readonly locate?: (exec: ProbeExecutor, platform: NodeJS.Platform) => Promise<DependencyLocation>
73
- readonly latest?: (exec: ProbeExecutor) => Promise<string>
74
64
  }
75
65
 
76
66
  const spec = (
@@ -84,16 +74,17 @@ const spec = (
84
74
  versionArgs: options.versionArgs ?? ["--version"],
85
75
  installHint,
86
76
  resolve: options.resolve,
87
- version: options.version,
88
- locate: options.locate,
89
- latest: options.latest
77
+ version: options.version
90
78
  })
91
79
 
80
+ // Presence is the injected executor's verdict; `which` only resolves WHERE the
81
+ // tool is, which on Windows is the `.cmd`/`.exe` candidate rather than the name.
92
82
  const pathProbe = (id: string): ((exec: ProbeExecutor) => ProbeResult) =>
93
- (exec) =>
94
- exec.commandExists(id)
95
- ? { state: "present", path: exec.which(id) }
96
- : { state: "missing" }
83
+ (exec) => {
84
+ if (!exec.commandExists(id)) return { state: "missing" }
85
+ const path = exec.which(id)
86
+ return path === "" ? { state: "present" } : { state: "present", path }
87
+ }
97
88
 
98
89
  const versionProbe = (
99
90
  id: string,
@@ -135,31 +126,6 @@ const resolveBun = (exec: ProbeExecutor): ProbeResult => {
135
126
  : { state: "present", path: bun.path }
136
127
  }
137
128
 
138
- const resolveEffectSolutions = (exec: ProbeExecutor): ProbeResult => {
139
- return exec.commandExists("effect-solutions")
140
- ? { state: "present", path: exec.which("effect-solutions") }
141
- : { state: "missing" }
142
- }
143
-
144
- const versionEffectSolutions = async (exec: ProbeExecutor): Promise<string> => {
145
- const bun = findBun(exec)
146
- if (bun === undefined) return ""
147
- const match = /effect-solutions@([0-9][0-9.]*)/.exec(await exec.capture(bun.command, ["pm", "-g", "ls"]))
148
- return match?.[1] ?? ""
149
- }
150
-
151
- const locateEffectSolutions = async (exec: ProbeExecutor): Promise<DependencyLocation> => {
152
- const strictBun = findBun(exec)
153
- const pathBun = exec.which("bun")
154
- // This site may accept a relative hit because it does not persist the Bun path.
155
- const bunForGlobalBin = strictBun?.command ?? (pathBun !== "" ? "bun" : undefined)
156
- if (bunForGlobalBin === undefined) return { path: "", binDir: "" }
157
- const globalBin = await exec.capture(bunForGlobalBin, ["pm", "-g", "bin"])
158
- const path = globalBin !== "" ? p(globalBin, "effect-solutions") : ""
159
- const resolved = path !== "" && exec.which(path) !== "" ? path : ""
160
- return { path: resolved, binDir: globalBin }
161
- }
162
-
163
129
  const npmGlobalCache = new WeakMap<ProbeExecutor, Promise<{ [k: string]: string }>>()
164
130
 
165
131
  const npmGlobalVersions = (exec: ProbeExecutor): Promise<{ [k: string]: string }> => {
@@ -183,31 +149,21 @@ const npmGlobalVersions = (exec: ProbeExecutor): Promise<{ [k: string]: string }
183
149
  const versionNpmGlobal = (pkg: string) => async (exec: ProbeExecutor): Promise<string> =>
184
150
  (await npmGlobalVersions(exec))[pkg] ?? ""
185
151
 
186
- const latestNpm = (id: "effect-solutions") => async (exec: ProbeExecutor): Promise<string> =>
187
- exec.commandExists("npm") ? await exec.capture("npm", ["view", id, "version"]) : ""
188
-
189
152
  export const defaultProbeExecutor: ProbeExecutor = { commandExists, capture, which }
190
153
 
191
154
  export const DEPENDENCIES: Record<ToolId, DependencySpec> = {
192
155
  git: spec(
193
156
  "git",
194
157
  "optional",
195
- (pf = rawPlatform()) =>
196
- pf === "darwin"
197
- ? "brew install git"
198
- : "sudo apt install -y git (or your distro's package manager)",
158
+ (pf = rawPlatform()) => hostOs(platformName(pf)).installHint("git"),
199
159
  { version: versionProbe("git") }
200
160
  ),
201
- jq: spec("jq", "optional", (pf = rawPlatform()) =>
202
- pf === "darwin"
203
- ? "brew install jq"
204
- : "sudo apt install -y jq",
205
- { version: versionProbe("jq") }
206
- ),
207
- curl: spec("curl", "optional", (pf = rawPlatform()) =>
208
- pf === "darwin" ? "brew install curl" : "sudo apt install -y curl",
209
- { version: versionProbe("curl") }
210
- ),
161
+ jq: spec("jq", "optional", (pf = rawPlatform()) => hostOs(platformName(pf)).installHint("jq"), {
162
+ version: versionProbe("jq")
163
+ }),
164
+ curl: spec("curl", "optional", (pf = rawPlatform()) => hostOs(platformName(pf)).installHint("curl"), {
165
+ version: versionProbe("curl")
166
+ }),
211
167
  node: spec("node", "optional", () => "install Node.js via https://nodejs.org (or your package manager)", {
212
168
  version: versionProbe("node")
213
169
  }),
@@ -216,13 +172,13 @@ export const DEPENDENCIES: Record<ToolId, DependencySpec> = {
216
172
  claude: spec(
217
173
  "claude",
218
174
  "optional",
219
- () => "curl -fsSL https://claude.ai/install.sh -o /tmp/claude-install.sh && bash /tmp/claude-install.sh",
175
+ (pf = rawPlatform()) => hostOs(platformName(pf)).installHint("claude"),
220
176
  { version: versionProbe("claude") }
221
177
  ),
222
178
  codex: spec(
223
179
  "codex",
224
180
  "optional",
225
- () => 'tmp=$(mktemp) && curl -fsSL https://chatgpt.com/codex/install.sh -o "$tmp" && CODEX_NON_INTERACTIVE=1 sh "$tmp"',
181
+ (pf = rawPlatform()) => hostOs(platformName(pf)).installHint("codex"),
226
182
  { version: versionProbe("codex") }
227
183
  ),
228
184
  bun: spec(
@@ -235,24 +191,16 @@ export const DEPENDENCIES: Record<ToolId, DependencySpec> = {
235
191
  const bun = findBun(exec)
236
192
  if (bun === undefined) return ""
237
193
  return await exec.capture(bun.command, ["--version"])
238
- },
239
- locate: async (exec) => ({ path: findBun(exec)?.path ?? "", binDir: "" })
194
+ }
240
195
  }
241
196
  ),
242
197
  bwrap: spec("bwrap", "optional", () => "sudo apt install -y bubblewrap (or dnf/pacman/zypper equivalent)", {
243
198
  version: versionProbe("bwrap")
244
199
  }),
245
- "effect-solutions": spec("effect-solutions", "optional", () => "bun add -g effect-solutions", {
246
- resolve: resolveEffectSolutions,
247
- version: versionEffectSolutions,
248
- locate: locateEffectSolutions,
249
- latest: latestNpm("effect-solutions")
250
- }),
251
200
  ffplay: spec(
252
201
  "ffplay",
253
202
  "optional",
254
- (pf = rawPlatform()) =>
255
- pf === "darwin" ? "brew install ffmpeg" : "sudo apt install -y ffmpeg",
203
+ (pf = rawPlatform()) => hostOs(platformName(pf)).installHint("ffplay"),
256
204
  { versionArgs: ["-version"], version: versionProbe("ffplay", ["-version"]), resolve: pathProbe("ffplay") }
257
205
  ),
258
206
  intelephense: spec("intelephense", "optional", () => "npm install -g intelephense", {
@@ -288,20 +236,11 @@ export async function resolveVersion(specification: DependencySpec, exec: ProbeE
288
236
  return await (specification.version ?? versionProbe(specification.id, specification.versionArgs))(exec)
289
237
  }
290
238
 
291
- export async function resolveLocation(
292
- specification: DependencySpec,
293
- exec: ProbeExecutor,
294
- platform: NodeJS.Platform = rawPlatform()
295
- ): Promise<DependencyLocation> {
296
- if (specification.locate !== undefined) return await specification.locate(exec, platform)
297
- const result = resolveDependency(specification, exec, platform)
298
- return { path: result.state === "present" ? (result.path ?? exec.which(specification.id)) : "", binDir: "" }
299
- }
300
-
301
239
  export async function resolvePath(
302
240
  specification: DependencySpec,
303
241
  exec: ProbeExecutor,
304
- platform?: NodeJS.Platform
242
+ platform: NodeJS.Platform = rawPlatform()
305
243
  ): Promise<string> {
306
- return (await resolveLocation(specification, exec, platform)).path
244
+ const result = resolveDependency(specification, exec, platform)
245
+ return result.state === "present" ? (result.path ?? exec.which(specification.id)) : ""
307
246
  }
@@ -5,7 +5,8 @@
5
5
  */
6
6
  import { spawn, type ChildProcess, type SpawnOptions } from "node:child_process"
7
7
  import { accessSync, constants, existsSync, readFileSync, statSync, writeFileSync } from "node:fs"
8
- import { delimiter, isAbsolute, join } from "node:path"
8
+ import { delimiter, extname, isAbsolute, join } from "node:path"
9
+ import { hostOs, type HostOs } from "./os"
9
10
 
10
11
  /** Keep engine paths slash-separated so rendered output is host-stable. */
11
12
  export function p(...parts: Array<string>): string {
@@ -21,6 +22,8 @@ export interface AsyncProcessResult {
21
22
 
22
23
  export interface AsyncProcessOptions {
23
24
  readonly stdio?: SpawnOptions["stdio"]
25
+ /** Host whose executable resolution and argv shaping apply; tests inject it. */
26
+ readonly host?: HostOs
24
27
  }
25
28
 
26
29
  export function spawnProcess(
@@ -29,9 +32,25 @@ export function spawnProcess(
29
32
  options: AsyncProcessOptions = {}
30
33
  ): Promise<AsyncProcessResult> {
31
34
  const { promise, resolve } = Promise.withResolvers<AsyncProcessResult>()
35
+ const host = options.host ?? hostOs()
36
+ const resolvesSuffixes = host.executableSuffixes.some((suffix) => suffix !== "")
37
+ const executablePath = resolvesSuffixes ? which(cmd, host.executableSuffixes) : cmd
38
+ if (executablePath === "") {
39
+ // Never hand a pathless name to a host that resolves suffixes: CreateProcess
40
+ // searches the parent's current directory before the system one, so an
41
+ // untrusted checkout could answer for a missing tool.
42
+ resolve({ exitCode: null, stdout: "", stderr: "", error: new Error(`command not found on PATH: ${cmd}`) })
43
+ return promise
44
+ }
32
45
  let child: ChildProcess
33
46
  try {
34
- child = spawn(cmd, [...args], { stdio: options.stdio ?? ["ignore", "pipe", "ignore"] })
47
+ // Inside the try: a host whose invocation encoding rejects a value it
48
+ // cannot represent reports it like any other spawn failure.
49
+ const invocation = host.invoke(executablePath, args)
50
+ child = spawn(invocation.command, [...invocation.args], {
51
+ stdio: options.stdio ?? ["ignore", "pipe", "ignore"],
52
+ windowsVerbatimArguments: invocation.windowsVerbatimArguments
53
+ })
35
54
  } catch (cause) {
36
55
  const error = cause instanceof Error ? cause : new Error(String(cause))
37
56
  resolve({ exitCode: null, stdout: "", stderr: "", error })
@@ -75,14 +94,20 @@ export async function capture(cmd: string, args: ReadonlyArray<string>): Promise
75
94
  }
76
95
 
77
96
  /** `command -v` — resolve an executable name on PATH. */
78
- export function which(name: string): string {
79
- if (isAbsolute(name) || name.includes("/")) {
80
- return isExecutable(name) ? name : ""
97
+ export function which(name: string, suffixes: ReadonlyArray<string> = hostOs().executableSuffixes): string {
98
+ const runnableCandidate = (base: string): string => {
99
+ for (const suffix of suffixes) {
100
+ const candidate = `${base}${suffix}`
101
+ if (isExecutable(candidate, suffixes)) return candidate
102
+ }
103
+ return ""
81
104
  }
105
+
106
+ if (isAbsolute(name) || name.includes("/")) return runnableCandidate(name)
82
107
  for (const dir of (process.env["PATH"] ?? "").split(delimiter)) {
83
108
  if (dir === "") continue
84
- const candidate = join(dir, name)
85
- if (isExecutable(candidate)) return candidate
109
+ const candidate = runnableCandidate(join(dir, name))
110
+ if (candidate !== "") return candidate
86
111
  }
87
112
  return ""
88
113
  }
@@ -91,10 +116,16 @@ export function commandExists(name: string): boolean {
91
116
  return which(name) !== ""
92
117
  }
93
118
 
94
- export function isExecutable(p: string): boolean {
119
+ export function isExecutable(path: string, suffixes: ReadonlyArray<string> = hostOs().executableSuffixes): boolean {
95
120
  try {
96
- if (!statSync(p).isFile()) return false
97
- accessSync(p, constants.X_OK)
121
+ if (!statSync(path).isFile()) return false
122
+ if (suffixes.some((suffix) => suffix !== "")) {
123
+ const lowerPath = path.toLowerCase()
124
+ return suffixes.some((suffix) =>
125
+ suffix === "" ? extname(path) === "" : lowerPath.endsWith(suffix.toLowerCase())
126
+ )
127
+ }
128
+ accessSync(path, constants.X_OK)
98
129
  return true
99
130
  } catch {
100
131
  return false
@@ -87,7 +87,6 @@ export interface Ctx {
87
87
  skipPluginRefresh?: boolean
88
88
  reconcile: boolean
89
89
  prune: boolean
90
- assumeYes: boolean
91
90
  claudeCompactWindow: string
92
91
  claudePermissive: boolean
93
92
  claudePlugins: Array<string>
@@ -140,7 +139,6 @@ function makeCtx(services: EngineServices): Ctx {
140
139
  skipPluginRefresh: false,
141
140
  reconcile: env["RECONCILE"] === "1",
142
141
  prune: env["PRUNE"] === "1",
143
- assumeYes: env["ASSUME_YES"] === "1",
144
142
  claudeCompactWindow,
145
143
  claudePermissive: env["CLAUDE_PERMISSIVE"] === "1",
146
144
  claudePlugins,
@@ -12,8 +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 { bunBootstrap } from "./bun"
15
- import { effectSolutionsInstall } from "./skillsSync"
16
- import { ensure, report } from "./toolchain"
15
+ import { installedVersion, present, report } from "./toolchain"
17
16
 
18
17
  export function modeModel(ctx: Ctx, args: ReadonlyArray<string>): number {
19
18
  const { echo, err, warn } = ctx.services.logger
@@ -119,15 +118,12 @@ function tomlModelText(text: string): string {
119
118
  }
120
119
 
121
120
  export async function modeToolchain(ctx: Ctx, args: ReadonlyArray<string>): Promise<number> {
122
- const { err } = ctx.services.logger
121
+ const { echo, err, verbose } = ctx.services.logger
123
122
  const words = args.filter((arg) => !arg.startsWith("--"))
124
123
  const op = words[0] ?? "check"
125
124
  const tool = words[1] ?? ""
126
125
  for (const arg of args) {
127
- if (arg === "--yes") ctx.assumeYes = true
128
- else if (arg === "--verbose") {
129
- ctx.verbose = true
130
- }
126
+ if (arg === "--verbose") ctx.verbose = true
131
127
  }
132
128
 
133
129
  if (op === "check") {
@@ -135,20 +131,33 @@ export async function modeToolchain(ctx: Ctx, args: ReadonlyArray<string>): Prom
135
131
  return 0
136
132
  }
137
133
  if (op !== "ensure") {
138
- err("Usage: toolchain [check|ensure <tool>] [--yes]")
134
+ err("Usage: toolchain [check|ensure <tool>]")
139
135
  return 2
140
136
  }
141
137
  if (tool === "") {
142
- err("Usage: toolchain ensure <tool> [--yes]")
138
+ err("Usage: toolchain ensure <tool>")
143
139
  return 2
144
140
  }
145
141
  switch (tool) {
146
- case "bun":
147
- return (await bunBootstrap(ctx, ctx.services)).kind === "ready" ? 0 : 1
148
- case "effect-solutions":
149
- return await ensure(ctx, "effect-solutions", effectSolutionsInstall(ctx))
142
+ case "bun": {
143
+ // `bun` is the one managed tool, and its bootstrap is silent when Bun is
144
+ // already installed. Probe first so the no-op confirmation the `--verbose`
145
+ // contract promises is not mistaken for a fresh install.
146
+ const alreadyInstalled = present(ctx, "bun")
147
+ if ((await bunBootstrap(ctx, ctx.services)).kind !== "ready") return 1
148
+ if (alreadyInstalled) {
149
+ // A present tool whose --version cannot be read reports `unknown` in the
150
+ // doctor table; keep the same vocabulary rather than an empty pair of
151
+ // parentheses.
152
+ const probed = await installedVersion(ctx, "bun")
153
+ const installed = probed === "" ? "version unknown" : probed
154
+ if (ctx.dryRun) echo(`[dry-run] bun up to date (${installed})`)
155
+ else verbose(`bun up to date (${installed})`)
156
+ }
157
+ return 0
158
+ }
150
159
  default:
151
- err("toolchain ensure supports managed tools only (bun, effect-solutions)")
160
+ err("toolchain ensure supports managed tools only (bun)")
152
161
  return 2
153
162
  }
154
163
  }
@@ -0,0 +1,62 @@
1
+ import { p } from "../exec"
2
+ import type { HostOs } from "./types"
3
+
4
+ function posixLiteral(value: string): string {
5
+ return `'${value.replaceAll("'", `'"'"'`)}'`
6
+ }
7
+
8
+ export const darwin: HostOs = {
9
+ id: "darwin",
10
+ toolchainOs: "darwin",
11
+ supportsBubblewrap: false,
12
+ directoryLinkKinds: ["symlink"],
13
+ executableSuffixes: [""],
14
+ invoke: (executablePath, args) => ({ command: executablePath, args }),
15
+ bunExecutableName: "bun",
16
+ bunInstaller: (pin, directory) => {
17
+ const scriptPath = p(directory, "install.sh")
18
+ return {
19
+ scriptPath,
20
+ download: {
21
+ command: "curl",
22
+ args: ["-fsSL", "https://bun.sh/install", "-o", scriptPath]
23
+ },
24
+ run: {
25
+ command: "bash",
26
+ args: [scriptPath, `bun-v${pin}`]
27
+ }
28
+ }
29
+ },
30
+ environmentSetting: (name, value) => ({
31
+ kind: "profile",
32
+ candidates: [".zshrc", ".bashrc", ".bash_profile", ".profile", ".zshenv"],
33
+ target: (shell) => {
34
+ const shellPath = shell ?? "bash"
35
+ const shellName = shellPath.slice(shellPath.lastIndexOf("/") + 1)
36
+ return shellName === "zsh" ? ".zshrc" : shellName === "bash" ? ".bashrc" : ".profile"
37
+ },
38
+ line: `export ${name}=${value}`
39
+ }),
40
+ statusLineCommand: (bun, script) => {
41
+ const bunLiteral = posixLiteral(bun)
42
+ const scriptLiteral = posixLiteral(script)
43
+ return `test -x ${bunLiteral} && test -f ${scriptLiteral} && exec ${bunLiteral} ${scriptLiteral} || true`
44
+ },
45
+ failureHookCommand: (command) => command,
46
+ installHint: (tool) => {
47
+ switch (tool) {
48
+ case "git":
49
+ return "brew install git"
50
+ case "jq":
51
+ return "brew install jq"
52
+ case "curl":
53
+ return "brew install curl"
54
+ case "ffplay":
55
+ return "brew install ffmpeg"
56
+ case "claude":
57
+ return "curl -fsSL https://claude.ai/install.sh -o /tmp/claude-install.sh && bash /tmp/claude-install.sh"
58
+ case "codex":
59
+ return 'tmp=$(mktemp) && curl -fsSL https://chatgpt.com/codex/install.sh -o "$tmp" && CODEX_NON_INTERACTIVE=1 sh "$tmp"'
60
+ }
61
+ }
62
+ }
@@ -0,0 +1,42 @@
1
+ import { darwin } from "./darwin"
2
+ import { linux } from "./linux"
3
+ import type { HostOs, PlatformName } from "./types"
4
+ import { windows } from "./windows"
5
+
6
+ export * from "./types"
7
+
8
+ export function rawPlatform(): NodeJS.Platform {
9
+ return process.platform
10
+ }
11
+
12
+ export function platformName(pf: NodeJS.Platform = rawPlatform()): PlatformName {
13
+ switch (pf) {
14
+ case "linux":
15
+ return "linux"
16
+ case "darwin":
17
+ return "darwin"
18
+ case "win32":
19
+ return "windows"
20
+ default:
21
+ return "unknown"
22
+ }
23
+ }
24
+
25
+ // An unrecognized host keeps Linux hint text, filters no toolchain row, and never claims bubblewrap — exactly today's behavior.
26
+ const unknown: HostOs = {
27
+ ...linux,
28
+ id: "unknown",
29
+ toolchainOs: "",
30
+ supportsBubblewrap: false
31
+ }
32
+
33
+ const HOSTS: Readonly<Record<PlatformName, HostOs>> = {
34
+ linux,
35
+ darwin,
36
+ windows,
37
+ unknown
38
+ }
39
+
40
+ export function hostOs(id: PlatformName = platformName()): HostOs {
41
+ return HOSTS[id]
42
+ }
@@ -0,0 +1,62 @@
1
+ import { p } from "../exec"
2
+ import type { HostOs } from "./types"
3
+
4
+ function posixLiteral(value: string): string {
5
+ return `'${value.replaceAll("'", `'"'"'`)}'`
6
+ }
7
+
8
+ export const linux: HostOs = {
9
+ id: "linux",
10
+ toolchainOs: "linux",
11
+ supportsBubblewrap: true,
12
+ directoryLinkKinds: ["symlink"],
13
+ executableSuffixes: [""],
14
+ invoke: (executablePath, args) => ({ command: executablePath, args }),
15
+ bunExecutableName: "bun",
16
+ bunInstaller: (pin, directory) => {
17
+ const scriptPath = p(directory, "install.sh")
18
+ return {
19
+ scriptPath,
20
+ download: {
21
+ command: "curl",
22
+ args: ["-fsSL", "https://bun.sh/install", "-o", scriptPath]
23
+ },
24
+ run: {
25
+ command: "bash",
26
+ args: [scriptPath, `bun-v${pin}`]
27
+ }
28
+ }
29
+ },
30
+ environmentSetting: (name, value) => ({
31
+ kind: "profile",
32
+ candidates: [".zshrc", ".bashrc", ".bash_profile", ".profile", ".zshenv"],
33
+ target: (shell) => {
34
+ const shellPath = shell ?? "bash"
35
+ const shellName = shellPath.slice(shellPath.lastIndexOf("/") + 1)
36
+ return shellName === "zsh" ? ".zshrc" : shellName === "bash" ? ".bashrc" : ".profile"
37
+ },
38
+ line: `export ${name}=${value}`
39
+ }),
40
+ statusLineCommand: (bun, script) => {
41
+ const bunLiteral = posixLiteral(bun)
42
+ const scriptLiteral = posixLiteral(script)
43
+ return `test -x ${bunLiteral} && test -f ${scriptLiteral} && exec ${bunLiteral} ${scriptLiteral} || true`
44
+ },
45
+ failureHookCommand: (command) => command,
46
+ installHint: (tool) => {
47
+ switch (tool) {
48
+ case "git":
49
+ return "sudo apt install -y git (or your distro's package manager)"
50
+ case "jq":
51
+ return "sudo apt install -y jq"
52
+ case "curl":
53
+ return "sudo apt install -y curl"
54
+ case "ffplay":
55
+ return "sudo apt install -y ffmpeg"
56
+ case "claude":
57
+ return "curl -fsSL https://claude.ai/install.sh -o /tmp/claude-install.sh && bash /tmp/claude-install.sh"
58
+ case "codex":
59
+ return 'tmp=$(mktemp) && curl -fsSL https://chatgpt.com/codex/install.sh -o "$tmp" && CODEX_NON_INTERACTIVE=1 sh "$tmp"'
60
+ }
61
+ }
62
+ }
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Host-to-artifact map (Platform seam in DESIGN.md). Every launcher, installer,
3
+ * build script, and release workflow selects a compiled binary from this one
4
+ * table; cli/test/unit/hostTargets.test.ts parses those scripts and fails when
5
+ * any of them drifts from it.
6
+ *
7
+ * Membership here is the support matrix: adding a row is how a host becomes
8
+ * supported, and `requireSupportedHost` in cli/src/engine.ts admits exactly the
9
+ * platform/arch pairs this table names.
10
+ */
11
+ import { platformName, type PlatformName } from "./index"
12
+
13
+ export type TargetId =
14
+ | "linux-x64"
15
+ | "linux-arm64"
16
+ | "darwin-x64"
17
+ | "darwin-arm64"
18
+ | "windows-x64"
19
+ | "windows-arm64"
20
+
21
+ export type TargetArch = "x64" | "arm64"
22
+
23
+ export interface HostTarget {
24
+ readonly id: TargetId
25
+ readonly platform: PlatformName
26
+ readonly arch: TargetArch
27
+ /** `bun build --compile --target=` value. */
28
+ readonly bunTarget: string
29
+ /** Compiled binary file name, including the Windows `.exe` suffix. */
30
+ readonly artifact: string
31
+ /** `uname -s`-`uname -m` keys the Bash launcher matches; empty on Windows. */
32
+ readonly unameKeys: ReadonlyArray<string>
33
+ /**
34
+ * `$env:PROCESSOR_ARCHITECTURE` values the PowerShell launcher matches; empty
35
+ * off Windows. Windows PowerShell 5.1 runs emulated on ARM64 and reports
36
+ * `AMD64` there, so a launcher must read `PROCESSOR_ARCHITEW6432` first.
37
+ */
38
+ readonly processorArchitectures: ReadonlyArray<string>
39
+ }
40
+
41
+ const target = (
42
+ id: TargetId,
43
+ platform: PlatformName,
44
+ arch: TargetArch,
45
+ unameKeys: ReadonlyArray<string>,
46
+ processorArchitectures: ReadonlyArray<string>
47
+ ): HostTarget => ({
48
+ id,
49
+ platform,
50
+ arch,
51
+ bunTarget: `bun-${id}`,
52
+ artifact: platform === "windows" ? `docks-kit-${id}.exe` : `docks-kit-${id}`,
53
+ unameKeys,
54
+ processorArchitectures
55
+ })
56
+
57
+ export const HOST_TARGETS: ReadonlyArray<HostTarget> = [
58
+ target("linux-x64", "linux", "x64", ["Linux-x86_64"], []),
59
+ target("linux-arm64", "linux", "arm64", ["Linux-aarch64"], []),
60
+ target("darwin-x64", "darwin", "x64", ["Darwin-x86_64"], []),
61
+ target("darwin-arm64", "darwin", "arm64", ["Darwin-arm64"], []),
62
+ target("windows-x64", "windows", "x64", [], ["AMD64"]),
63
+ target("windows-arm64", "windows", "arm64", [], ["ARM64"])
64
+ ]
65
+
66
+ export function targetFor(platform: PlatformName, arch: string): HostTarget | undefined {
67
+ return HOST_TARGETS.find((t) => t.platform === platform && t.arch === arch)
68
+ }
69
+
70
+ /** Resolve the artifact for a raw Node platform/arch pair. */
71
+ export function targetForHost(platform: NodeJS.Platform, arch: string): HostTarget | undefined {
72
+ return targetFor(platformName(platform), arch)
73
+ }