docks-kit 0.9.0 → 0.10.1

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.
@@ -112,34 +112,29 @@ const home = (): string => {
112
112
  return envHome !== undefined && envHome !== "" ? envHome : homedir()
113
113
  }
114
114
 
115
- const absoluteWindowsExe = (path: string): boolean =>
116
- /\.exe$/i.test(path) && (/^[A-Za-z]:[\\/]/.test(path) || /^\\\\/.test(path))
117
115
 
118
116
  // The resolved path gets persisted into global direct-exec hooks, so a
119
117
  // relative `which` hit (relative PATH entry, relative BUN_INSTALL) would
120
118
  // break outside the sync working directory.
121
- const absoluteExecutable = (path: string, platform: NodeJS.Platform): boolean =>
122
- platform === "win32" ? absoluteWindowsExe(path) : isAbsolute(path)
123
119
 
124
- const findBun = (exec: ProbeExecutor, platform: NodeJS.Platform = rawPlatform()): { command: string; path: string } | undefined => {
120
+ const findBun = (exec: ProbeExecutor): { command: string; path: string } | undefined => {
125
121
  const onPath = exec.which("bun")
126
- if (onPath !== "" && absoluteExecutable(onPath, platform)) {
127
- return { command: platform === "win32" ? onPath : "bun", path: onPath }
122
+ if (onPath !== "" && isAbsolute(onPath)) {
123
+ return { command: "bun", path: onPath }
128
124
  }
129
125
  const root =
130
126
  process.env["BUN_INSTALL"] !== undefined && process.env["BUN_INSTALL"] !== ""
131
127
  ? process.env["BUN_INSTALL"]!
132
128
  : p(home(), ".bun")
133
- const name = platform === "win32" ? "bun.exe" : "bun"
134
- 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")]) {
135
130
  const found = exec.which(candidate)
136
- if (found !== "" && absoluteExecutable(found, platform)) return { command: found, path: found }
131
+ if (found !== "" && isAbsolute(found)) return { command: found, path: found }
137
132
  }
138
133
  return undefined
139
134
  }
140
135
 
141
- const resolveBun = (exec: ProbeExecutor, platform: NodeJS.Platform): ProbeResult => {
142
- const bun = findBun(exec, platform)
136
+ const resolveBun = (exec: ProbeExecutor): ProbeResult => {
137
+ const bun = findBun(exec)
143
138
  return bun === undefined
144
139
  ? { state: "missing" }
145
140
  : { state: "present", path: bun.path }
@@ -161,28 +156,23 @@ const versionEffectSolutions = (exec: ProbeExecutor): string => {
161
156
  return match?.[1] ?? ""
162
157
  }
163
158
 
164
- const locateEffectSolutions = (exec: ProbeExecutor, platform: NodeJS.Platform): DependencyLocation => {
165
- const strictBun = findBun(exec, platform)
159
+ const locateEffectSolutions = (exec: ProbeExecutor): DependencyLocation => {
160
+ const strictBun = findBun(exec)
166
161
  const pathBun = exec.which("bun")
167
162
  const bun = strictBun ?? (pathBun !== "" ? { command: "bun", path: pathBun } : undefined)
168
163
  if (bun === undefined) return { path: "", binDir: "" }
169
164
  const globalBin = exec.capture(bun.command, ["pm", "-g", "bin"])
170
- const names =
171
- platform === "win32"
172
- ? ["effect-solutions.exe", "effect-solutions.cmd", "effect-solutions.bunx"]
173
- : ["effect-solutions"]
174
- const path = names.map((name) => p(globalBin, name)).find((candidate) => globalBin !== "" && exec.which(candidate) !== "")
175
- 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 }
176
168
  }
177
169
 
178
170
  const resolveChrome = (exec: ProbeExecutor, platform: NodeJS.Platform): ProbeResult => {
179
171
  const root = p(home(), ".agent-browser", "browsers")
180
172
  const relative =
181
- platform === "win32"
182
- ? "chrome.exe"
183
- : platform === "darwin"
184
- ? "Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing"
185
- : "chrome"
173
+ platform === "darwin"
174
+ ? "Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing"
175
+ : "chrome"
186
176
  if (existsSync(root)) {
187
177
  for (const directory of readdirSync(root).filter((name) => name.startsWith("chrome-")).sort().reverse()) {
188
178
  const path = exec.which(p(root, directory, relative))
@@ -215,23 +205,19 @@ export const DEPENDENCIES: Record<ToolId, DependencySpec> = {
215
205
  "git",
216
206
  "optional",
217
207
  (pf = rawPlatform()) =>
218
- pf === "win32"
219
- ? "winget install Git.Git (then open a new terminal)"
220
- : pf === "darwin"
221
- ? "brew install git"
222
- : "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)",
223
211
  { version: versionProbe("git") }
224
212
  ),
225
213
  jq: spec("jq", "optional", (pf = rawPlatform()) =>
226
- pf === "win32"
227
- ? "winget install jqlang.jq (then open a new terminal)"
228
- : pf === "darwin"
229
- ? "brew install jq"
230
- : "sudo apt install -y jq",
214
+ pf === "darwin"
215
+ ? "brew install jq"
216
+ : "sudo apt install -y jq",
231
217
  { version: versionProbe("jq") }
232
218
  ),
233
219
  curl: spec("curl", "optional", (pf = rawPlatform()) =>
234
- 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",
235
221
  { version: versionProbe("curl") }
236
222
  ),
237
223
  node: spec("node", "optional", () => "install Node.js via https://nodejs.org (or your package manager)", {
@@ -242,22 +228,16 @@ export const DEPENDENCIES: Record<ToolId, DependencySpec> = {
242
228
  claude: spec(
243
229
  "claude",
244
230
  "optional",
245
- (pf = rawPlatform()) =>
246
- pf === "win32"
247
- ? "winget install Anthropic.ClaudeCode"
248
- : "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",
249
232
  { version: versionProbe("claude") }
250
233
  ),
251
234
  codex: spec(
252
235
  "codex",
253
236
  "optional",
254
- (pf = rawPlatform()) =>
255
- pf === "win32"
256
- ? `powershell -ExecutionPolicy ByPass -c "irm https://chatgpt.com/codex/install.ps1 | iex"`
257
- : '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"',
258
238
  { version: versionProbe("codex") }
259
239
  ),
260
- 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)", {
261
241
  version: versionProbe("rtk"),
262
242
  latest: latestRtk
263
243
  }),
@@ -269,12 +249,11 @@ export const DEPENDENCIES: Record<ToolId, DependencySpec> = {
269
249
  bun: spec(
270
250
  "bun",
271
251
  "optional",
272
- (pf = rawPlatform()) =>
273
- 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",
274
253
  {
275
254
  resolve: resolveBun,
276
255
  version: (exec) => exec.capture(versionBunCommand(exec), ["--version"]),
277
- locate: (exec, platform) => ({ path: findBun(exec, platform)?.path ?? "", binDir: "" })
256
+ locate: (exec) => ({ path: findBun(exec)?.path ?? "", binDir: "" })
278
257
  }
279
258
  ),
280
259
  bwrap: spec("bwrap", "optional", () => "sudo apt install -y bubblewrap (or dnf/pacman/zypper equivalent)"),
@@ -298,7 +277,7 @@ export const DEPENDENCIES: Record<ToolId, DependencySpec> = {
298
277
  "ffplay",
299
278
  "optional",
300
279
  (pf = rawPlatform()) =>
301
- 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",
302
281
  { versionArgs: ["-version"], resolve: pathProbe("ffplay") }
303
282
  ),
304
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
@@ -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. */
@@ -9,7 +9,7 @@ import {
9
9
  rmSync,
10
10
  statSync
11
11
  } from "node:fs"
12
- import { basename, dirname, join } from "node:path"
12
+ import { dirname, join } from "node:path"
13
13
 
14
14
  import { isObject, parseJson, type Json } from "./jq"
15
15
  import type { Ctx } from "./index"
@@ -5,12 +5,12 @@
5
5
  * and the snapshot write.
6
6
  */
7
7
  import { spawnSync } from "node:child_process"
8
- import { cpSync, existsSync, lstatSync, mkdirSync, readFileSync, readlinkSync, realpathSync, rmdirSync, rmSync, statSync, symlinkSync, unlinkSync, writeFileSync } from "node:fs"
8
+ import { cpSync, existsSync, lstatSync, mkdirSync, readFileSync, readlinkSync, rmSync, statSync, symlinkSync } from "node:fs"
9
9
  import { p, writeFileIfChanged } from "./exec"
10
10
  import { bunBootstrap } from "./bun"
11
11
  import type { Ctx } from "./index"
12
12
  import { compareCodepoints } from "./jq"
13
- import type { EngineServices, Platform } from "./services"
13
+ import type { EngineServices } from "./services"
14
14
  import { ensure, field } from "./toolchain"
15
15
  import { payloadText } from "../payload"
16
16
 
@@ -139,9 +139,6 @@ function healClaudeSymlink(ctx: Ctx, skillsDir: string, base: string): boolean {
139
139
  if (linkStat?.isSymbolicLink() === true) {
140
140
  const current = safeReadlink(claudeLink)
141
141
  if (current === relTarget) return false
142
- // win32: `npx skills add` creates absolute symlinks/junctions — any link
143
- // that RESOLVES to the canonical dir is healthy, not stale.
144
- if (ctx.services.platform.isWindows() && realpathEquals(claudeLink, canonical)) return false
145
142
  if (ctx.dryRun) {
146
143
  echo(`[dry-run] would replace stale Claude symlink: ~/.claude/skills/${base} -> ${current} (correct: ${relTarget})`)
147
144
  return true
@@ -178,36 +175,22 @@ function safeReadlink(path: string): string {
178
175
  }
179
176
  }
180
177
 
181
- function realpathEquals(a: string, b: string): boolean {
182
- try {
183
- return realpathSync(a) === realpathSync(b)
184
- } catch {
185
- return false
186
- }
187
- }
188
178
 
189
- /**
190
- * Remove a symlink/junction without crashing: Bun's rmSync throws EFAULT on
191
- * win32 directory symlinks. unlink handles file symlinks, rmdir handles
192
- * dir symlinks/junctions, rmSync is the POSIX catch-all.
193
- */
179
+ /** Remove a symlink without touching a real directory. */
194
180
  function removeLink(path: string): boolean {
195
- for (const rm of [() => unlinkSync(path), () => rmdirSync(path), () => rmSync(path, { force: true })]) {
196
- try {
197
- rm()
198
- return true
199
- } catch {
200
- // try the next removal shape
201
- }
181
+ try {
182
+ rmSync(path, { force: true })
183
+ return true
184
+ } catch {
185
+ return lstat(path) === undefined
202
186
  }
203
- return lstat(path) === undefined
204
187
  }
205
188
 
206
- /** skills::_link_or_copy — real symlink preferred, copy fallback (Windows). */
207
- export function linkOrCopy(target: string, link: string, platform: Platform): boolean {
189
+ /** skills::_link_or_copy — real symlink preferred, copy fallback. */
190
+ export function linkOrCopy(target: string, link: string): boolean {
208
191
  removeLink(link)
209
192
  try {
210
- symlinkSync(target, link, platform.isWindows() ? "dir" : undefined)
193
+ symlinkSync(target, link)
211
194
  } catch {
212
195
  // fall through to the copy fallback below
213
196
  }
@@ -223,11 +206,11 @@ export function linkOrCopy(target: string, link: string, platform: Platform): bo
223
206
  }
224
207
 
225
208
  function linkOrCopyWithWarnings(target: string, link: string, services: EngineServices): boolean {
226
- const linked = linkOrCopy(target, link, services.platform)
209
+ const linked = linkOrCopy(target, link)
227
210
  if (!linked) {
228
211
  services.logger.warn(`could not create ${link} (symlink and copy both failed)`)
229
212
  } else if (lstat(link)?.isSymbolicLink() !== true) {
230
- services.logger.warn(`symlinks unsupported here — ${link} is a copy (refreshed on sync; enable Windows Developer Mode for real links)`)
213
+ services.logger.warn(`symlinks unsupported here — ${link} is a copy refreshed on sync`)
231
214
  }
232
215
  return linked
233
216
  }
@@ -298,14 +281,6 @@ export function effectSolutionsInstall(
298
281
 
299
282
  const location = services.deps.location("effect-solutions")
300
283
  const gbin = location.binDir
301
- // win32: bun writes an .exe shim (not the bare Unix name), and the
302
- // ~/.local/bin link step below is Unix-only plumbing (non-interactive
303
- // agent PATH) — bun's global bin is already the Windows PATH entry.
304
- if (services.platform.isWindows()) {
305
- if (location.path !== "") change(`effect-solutions CLI ready (${gbin})`)
306
- else warn(`effect-solutions installed but no shim found under '${gbin !== "" ? gbin : "<unknown>"}' — check bun pm -g bin`)
307
- return 0
308
- }
309
284
  if (location.path !== "") {
310
285
  mkdirSync(p(ctx.home, ".local", "bin"), { recursive: true })
311
286
  linkOrCopyWithWarnings(bun, p(ctx.home, ".local", "bin", "bun"), services)
package/cli/src/engine.ts CHANGED
@@ -5,28 +5,32 @@ import { makeEngineServices } from "./engine-native/services"
5
5
  import { kitHome } from "./kitHome"
6
6
  import { DependencyManagerService, LoggerService, PlatformService } from "./services"
7
7
 
8
- // Same factory as the Effect rim's live layers — this path runs outside the
9
- // runtime (child-spawn capture), so it takes the services directly.
10
- const services = makeEngineServices()
11
-
12
8
  /**
13
9
  * The single seam between the typed CLI and EngineNative. Engine execution
14
10
  * stays in-process after @effect/cli has parsed pickers and flag spellings.
15
11
  */
16
12
  const bashRemovedMessage = "bash engine removed — recover at tag bash-engine-final"
17
13
  const bashEngineRequested = (): boolean => process.env["DOCKS_KIT_ENGINE"] === "bash"
14
+ const requireSupportedHost = () => {
15
+ const platform = process.platform
16
+ const arch = process.arch
17
+ return (platform === "linux" || platform === "darwin") && (arch === "x64" || arch === "arm64")
18
+ ? Effect.void
19
+ : bail(
20
+ `unsupported host ${platform}/${arch}; docks-kit supports only Linux and macOS on x64 or arm64`,
21
+ 2
22
+ )
23
+ }
18
24
 
19
- // bun build --compile runs the embedded entry from a virtual path
20
- // ("/$bunfs/root/…" on POSIX, "B:\~BUN\root\…" on Windows). There
21
- // process.execPath IS the CLI, so a re-spawn must not pass main.ts.
22
- // The Windows check is anchored to the drive-rooted "~BUN" segment so a
23
- // real checkout under a ~BUN-named directory can't false-positive.
25
+ // bun build --compile runs the embedded entry from a virtual POSIX path
26
+ // ("/$bunfs/root/…"). There process.execPath IS the CLI, so a re-spawn must
27
+ // not pass main.ts.
24
28
  export const compiled =
25
- process.argv[1] !== undefined &&
26
- (process.argv[1].startsWith("/$bunfs/") || /^[A-Za-z]:[\\/]~BUN[\\/]/i.test(process.argv[1]))
29
+ process.argv[1] !== undefined && process.argv[1].startsWith("/$bunfs/")
27
30
 
28
31
  export const engine = (args: ReadonlyArray<string>) =>
29
32
  Effect.gen(function* () {
33
+ yield* requireSupportedHost()
30
34
  if (bashEngineRequested()) {
31
35
  yield* bail(bashRemovedMessage, 2)
32
36
  }
@@ -41,9 +45,12 @@ export const engine = (args: ReadonlyArray<string>) =>
41
45
 
42
46
  /** Run the engine capturing stdout (engine logs/warns go to stderr and pass through). */
43
47
  export const engineCapture = (args: ReadonlyArray<string>) =>
44
- bashEngineRequested()
45
- ? bail(bashRemovedMessage, 2)
46
- : Effect.sync(() => {
48
+ Effect.gen(function* () {
49
+ yield* requireSupportedHost()
50
+ if (bashEngineRequested()) {
51
+ return yield* bail(bashRemovedMessage, 2)
52
+ }
53
+ return yield* Effect.sync(() => {
47
54
  // Child process (raw channel): runEngineNative writes straight to
48
55
  // process.stdout, so in-process capture isn't possible.
49
56
  const res = spawnSync(process.execPath, compiled ? [...args] : [`${kitHome()}/cli/src/main.ts`, ...args], {
@@ -52,10 +59,11 @@ export const engineCapture = (args: ReadonlyArray<string>) =>
52
59
  stdio: ["ignore", "pipe", "inherit"]
53
60
  })
54
61
  if (res.error !== undefined || res.status !== 0) {
55
- services.logger.warn(`engine capture failed (${args.join(" ")} exited ${res.status ?? "spawn-error"})`)
62
+ makeEngineServices().logger.warn(`engine capture failed (${args.join(" ")} exited ${res.status ?? "spawn-error"})`)
56
63
  }
57
64
  return res.stdout ?? ""
58
65
  })
66
+ })
59
67
 
60
68
  /** Print a message to stderr and exit — for CLI-side validation failures. */
61
69
  export const bail = (message: string, code = 2) =>