docks-kit 0.15.1 → 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 (49) hide show
  1. package/AGENTS.md +57 -17
  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/model.ts +3 -0
  11. package/cli/src/commands/models.ts +5 -3
  12. package/cli/src/commands/status.ts +145 -32
  13. package/cli/src/commands/sync.ts +4 -5
  14. package/cli/src/commands/toolchain.ts +4 -7
  15. package/cli/src/commands/update.ts +177 -32
  16. package/cli/src/efforts.ts +5 -5
  17. package/cli/src/engine-native/DESIGN.md +31 -22
  18. package/cli/src/engine-native/bun.ts +42 -14
  19. package/cli/src/engine-native/claudeRuntime.ts +17 -9
  20. package/cli/src/engine-native/claudeSettingsModifiers.ts +29 -11
  21. package/cli/src/engine-native/claudeSync.ts +91 -55
  22. package/cli/src/engine-native/codexSync.ts +173 -49
  23. package/cli/src/engine-native/codexToml.ts +12 -7
  24. package/cli/src/engine-native/deps.ts +36 -95
  25. package/cli/src/engine-native/exec.ts +42 -24
  26. package/cli/src/engine-native/index.ts +17 -6
  27. package/cli/src/engine-native/models.ts +2 -9
  28. package/cli/src/engine-native/modes.ts +54 -35
  29. package/cli/src/engine-native/os/darwin.ts +62 -0
  30. package/cli/src/engine-native/os/index.ts +42 -0
  31. package/cli/src/engine-native/os/linux.ts +62 -0
  32. package/cli/src/engine-native/os/targets.ts +73 -0
  33. package/cli/src/engine-native/os/types.ts +75 -0
  34. package/cli/src/engine-native/os/windows.ts +176 -0
  35. package/cli/src/engine-native/parseArgs.ts +147 -48
  36. package/cli/src/engine-native/services.ts +1 -11
  37. package/cli/src/engine-native/settings.ts +3 -2
  38. package/cli/src/engine-native/skillsSync.ts +141 -89
  39. package/cli/src/engine-native/toolchain.ts +5 -147
  40. package/cli/src/engine.ts +41 -11
  41. package/cli/src/generated/sotPayload.ts +7 -7
  42. package/cli/src/kitHome.ts +42 -5
  43. package/cli/src/main.ts +12 -2
  44. package/cli/src/manifests.ts +28 -11
  45. package/cli/src/payload.ts +2 -5
  46. package/docks-kit +4 -4
  47. package/docks-kit.ps1 +123 -0
  48. package/package.json +9 -5
  49. package/cli/src/engine-native/os.ts +0 -24
@@ -1,22 +1,39 @@
1
1
  /**
2
2
  * EngineNative `sync agents` pipeline: universal-skill bootstrap
3
3
  * (`npx skills@<pin> add`), Claude symlink healing, --prune reconcile against
4
- * the kit-managed snapshot, the effect-solutions toolchain callback, and the
5
- * snapshot write.
4
+ * the kit-managed snapshot, and the snapshot write.
6
5
  */
7
- import { cpSync, existsSync, lstatSync, mkdirSync, readFileSync, readlinkSync, rmSync, statSync, symlinkSync } from "node:fs"
6
+ import {
7
+ cpSync,
8
+ existsSync,
9
+ lstatSync,
10
+ mkdirSync,
11
+ readFileSync,
12
+ readlinkSync,
13
+ rmSync,
14
+ statSync,
15
+ symlinkSync,
16
+ writeFileSync
17
+ } from "node:fs"
18
+ import { dirname, relative, resolve } from "node:path"
19
+ import { payloadText } from "../payload"
8
20
  import { p, spawnProcess, writeFileIfChanged } from "./exec"
9
- import { bunBootstrap } from "./bun"
10
21
  import type { Ctx } from "./index"
11
22
  import { compareCodepoints } from "./jq"
23
+ import { hostOs, type DirectoryLinkKind } from "./os"
24
+ import { ExitError } from "./parseArgs"
12
25
  import type { EngineServices } from "./services"
13
- import { ensure, field } from "./toolchain"
14
- import { payloadText } from "../payload"
26
+ import { field } from "./toolchain"
15
27
 
16
28
  export interface SkillsState {
17
29
  present: number
18
30
  }
19
31
 
32
+ export type LinkOutcome = "symlink" | "junction" | "copy" | "failed"
33
+
34
+ /** Lets heal and prune distinguish a kit-owned copy from a user's real directory. */
35
+ export const COPY_MARKER = ".docks-kit-copied-skill"
36
+
20
37
  export async function skillsSync(ctx: Ctx): Promise<SkillsState> {
21
38
  const state: SkillsState = { present: 0 }
22
39
  const skillsDir = p(ctx.agentsDir, "skills")
@@ -26,16 +43,17 @@ export async function skillsSync(ctx: Ctx): Promise<SkillsState> {
26
43
  if (!ctx.dryRun) mkdirSync(skillsDir, { recursive: true })
27
44
 
28
45
  await syncUniversal(ctx, state, skillsDir, manifest)
29
- if (ctx.prune) await reconcileRemovals(ctx, manifest, snapshot)
30
- await syncEffectSolutionsCli(ctx)
31
- updateSnapshot(ctx, manifest, snapshot)
46
+ const failedRemovals = ctx.prune ? await reconcileRemovals(ctx, manifest, snapshot) : []
47
+ updateSnapshot(ctx, manifest, snapshot, failedRemovals)
32
48
  return state
33
49
  }
34
50
 
35
51
  /** skills::_skills_cli — the pinned npx package spec. */
36
52
  function skillsCli(ctx: Ctx): string {
37
- const v = field(ctx, "skills-cli", "verified")
38
- return v !== "" ? `skills@${v}` : "skills"
53
+ const version = field(ctx, "skills-cli", "verified")
54
+ if (version !== "") return `skills@${version}`
55
+ ctx.services.logger.err("Universal skills sync aborted because SoT/toolchain.json has no verified skills-cli pin")
56
+ throw new ExitError(1)
39
57
  }
40
58
 
41
59
  /** skills::_normalize_manifest — cleaned slugs, one per line. */
@@ -131,7 +149,7 @@ function healClaudeSymlink(ctx: Ctx, skillsDir: string, base: string): boolean {
131
149
  const canonical = p(skillsDir, base)
132
150
  const claudeSkillsDir = p(ctx.home, ".claude", "skills")
133
151
  const claudeLink = p(claudeSkillsDir, base)
134
- const relTarget = `../../.agents/skills/${base}`
152
+ const relTarget = relative(dirname(claudeLink), canonical)
135
153
 
136
154
  if (!isDir(canonical)) return false
137
155
 
@@ -148,15 +166,25 @@ function healClaudeSymlink(ctx: Ctx, skillsDir: string, base: string): boolean {
148
166
  return false
149
167
  }
150
168
  } else if (linkStat !== undefined) {
151
- warn(`~/.claude/skills/${base} exists as a real path (not a symlink) — leaving alone; remove manually if it's stale`)
152
- return false
169
+ if (!isKitOwnedCopy(claudeLink)) {
170
+ warn(`~/.claude/skills/${base} exists as a real path (not a symlink) — leaving alone; remove manually if it's stale`)
171
+ return false
172
+ }
173
+ if (ctx.dryRun) {
174
+ echo(`[dry-run] would replace kit-created Claude copy: ~/.claude/skills/${base} -> ${relTarget}`)
175
+ return true
176
+ }
177
+ if (!removeKitOwnedCopy(claudeLink)) {
178
+ warn(`could not remove kit-created copy ~/.claude/skills/${base} — remove it manually, then re-run sync`)
179
+ return false
180
+ }
153
181
  } else if (ctx.dryRun) {
154
182
  echo(`[dry-run] would create missing Claude symlink: ~/.claude/skills/${base} -> ${relTarget}`)
155
183
  return true
156
184
  }
157
185
 
158
186
  mkdirSync(claudeSkillsDir, { recursive: true })
159
- return linkOrCopyWithWarnings(relTarget, claudeLink, ctx.services)
187
+ return linkOrCopyWithWarnings(relTarget, claudeLink, ctx.services) !== "failed"
160
188
  }
161
189
 
162
190
  function lstat(path: string): ReturnType<typeof lstatSync> | undefined {
@@ -167,107 +195,110 @@ function lstat(path: string): ReturnType<typeof lstatSync> | undefined {
167
195
  }
168
196
  }
169
197
 
170
- function safeReadlink(path: string): string {
198
+ /**
199
+ * A link only counts when it RESOLVES to the skill directory. Windows picks a
200
+ * symlink's file-or-directory type by autodetecting the target against the
201
+ * process working directory, not the link's own directory, so a relative
202
+ * target can yield a symlink that exists but resolves to nothing. Checking
203
+ * resolution is what makes the next mechanism in the chain reachable.
204
+ */
205
+ function linksToDirectory(path: string): boolean {
206
+ if (lstat(path)?.isSymbolicLink() !== true) return false
171
207
  try {
172
- return readlinkSync(path)
208
+ return statSync(path).isDirectory()
173
209
  } catch {
174
- return ""
210
+ return false
175
211
  }
176
212
  }
177
213
 
214
+ function isKitOwnedCopy(path: string): boolean {
215
+ return lstat(path)?.isDirectory() === true && existsSync(p(path, COPY_MARKER))
216
+ }
178
217
 
179
- /** Remove a symlink without touching a real directory. */
180
- function removeLink(path: string): boolean {
218
+ function removeKitOwnedCopy(path: string): boolean {
219
+ if (!isKitOwnedCopy(path)) return false
181
220
  try {
182
- rmSync(path, { force: true })
221
+ rmSync(path, { recursive: true, force: true })
183
222
  return true
184
223
  } catch {
185
224
  return lstat(path) === undefined
186
225
  }
187
226
  }
188
227
 
189
- /** skills::_link_or_copy real symlink preferred, copy fallback. */
190
- export function linkOrCopy(target: string, link: string): boolean {
191
- removeLink(link)
192
- try {
193
- symlinkSync(target, link)
194
- } catch {
195
- // fall through to the copy fallback below
196
- }
197
- if (lstat(link)?.isSymbolicLink() === true) return true
228
+ function safeReadlink(path: string): string {
198
229
  try {
199
- // Resolve a relative target against the link's parent, like ln does.
200
- const resolved = target.startsWith("/") ? target : p(link.slice(0, link.lastIndexOf("/")), target)
201
- cpSync(resolved, link, { recursive: true })
230
+ return readlinkSync(path)
202
231
  } catch {
203
- // fall through to the existence check below
232
+ return ""
204
233
  }
205
- return existsSync(link)
206
234
  }
207
235
 
208
- function linkOrCopyWithWarnings(target: string, link: string, services: EngineServices): boolean {
209
- const linked = linkOrCopy(target, link)
210
- if (!linked) {
211
- services.logger.warn(`could not create ${link} (symlink and copy both failed)`)
212
- } else if (lstat(link)?.isSymbolicLink() !== true) {
213
- services.logger.warn(`symlinks unsupported here — ${link} is a copy refreshed on sync`)
236
+
237
+ /** Remove a symlink without touching a real directory. */
238
+ function removeLink(path: string): boolean {
239
+ try {
240
+ rmSync(path, { force: true })
241
+ return true
242
+ } catch {
243
+ return lstat(path) === undefined
214
244
  }
215
- return linked
216
245
  }
217
246
 
218
- // ------------------------------------------------- toolchain callbacks ----
219
-
220
- /** skills::_effect_solutions_install. */
221
- export function effectSolutionsInstall(
222
- ctx: Ctx
223
- ): (mode: "install" | "upgrade", version: string, services: EngineServices) => Promise<number> {
224
- return async (mode, version, services) => {
225
- const { change, clearProgress, progress, verbose, warn } = services.logger
226
- const verb = mode === "upgrade" ? "Upgrading" : "Installing"
227
- const pkg = `effect-solutions@${version !== "" ? version : "latest"}`
228
-
229
- const bunState = await bunBootstrap(ctx, services)
230
- if (bunState.kind === "deferred") return 1
231
- const bun = bunState.executable
247
+ /** skills::_link_or_copy try directory links in host order, then a marked copy. */
248
+ export function linkOrCopy(
249
+ target: string,
250
+ link: string,
251
+ kinds: ReadonlyArray<DirectoryLinkKind> = hostOs().directoryLinkKinds
252
+ ): LinkOutcome {
253
+ const resolvedLink = resolve(link)
254
+ const absoluteTarget = resolve(dirname(resolvedLink), target)
255
+ if (absoluteTarget === resolvedLink) return "symlink"
256
+ removeLink(link)
232
257
 
233
- verbose(`${verb} effect-solutions CLI via bun${version !== "" ? ` (pinned ${version})` : ""}...`)
234
- progress(`${verb} effect-solutions CLI...`)
235
- const installResult = await spawnProcess(bun, ["add", "-g", pkg], { stdio: "ignore" })
236
- clearProgress()
237
- if (installResult.exitCode !== 0) {
238
- warn(`bun add -g ${pkg} failed. Try manually: bun add -g ${pkg}`)
239
- return 1
258
+ for (const kind of kinds) {
259
+ try {
260
+ if (kind === "symlink") {
261
+ symlinkSync(target, link)
262
+ } else {
263
+ symlinkSync(absoluteTarget, link, "junction")
264
+ }
265
+ if (linksToDirectory(link)) return kind
266
+ } catch {
267
+ // The runtime decides whether each mechanism works; try the next one.
240
268
  }
269
+ removeLink(link)
270
+ }
241
271
 
242
- const location = await services.deps.location("effect-solutions")
243
- const gbin = location.binDir
244
- if (location.path !== "") {
245
- mkdirSync(p(ctx.home, ".local", "bin"), { recursive: true })
246
- linkOrCopyWithWarnings(bun, p(ctx.home, ".local", "bin", "bun"), services)
247
- linkOrCopyWithWarnings(location.path, p(ctx.home, ".local", "bin", "effect-solutions"), services)
248
- change("effect-solutions CLI ready (linked bun + effect-solutions into ~/.local/bin)")
249
- } else {
250
- warn(`effect-solutions installed but binary not found under '${gbin !== "" ? gbin : "<unknown>"}' — link it onto PATH manually`)
272
+ const copyDestinationWasAbsent = lstat(link) === undefined
273
+ try {
274
+ cpSync(absoluteTarget, link, { recursive: true })
275
+ writeFileSync(p(link, COPY_MARKER), "")
276
+ return "copy"
277
+ } catch {
278
+ if (copyDestinationWasAbsent) {
279
+ try {
280
+ rmSync(link, { recursive: true, force: true })
281
+ } catch {
282
+ // The outcome remains failed; a later sync can retry the destination.
283
+ }
251
284
  }
252
- return 0
285
+ return "failed"
253
286
  }
254
287
  }
255
288
 
256
- async function syncEffectSolutionsCli(ctx: Ctx): Promise<void> {
257
- const { clearProgress, progress, warn } = ctx.services.logger
258
- if (!/"effect-kit@docks"[ \t]*:[ \t]*true/.test(payloadText("SoT/.claude/settings.json"))) return
259
-
260
- progress("Checking effect-solutions CLI...")
261
- const result = await ensure(ctx, "effect-solutions", effectSolutionsInstall(ctx))
262
- clearProgress()
263
- if (result !== 0) {
264
- warn("effect-solutions bootstrap failed — continuing sync")
289
+ function linkOrCopyWithWarnings(target: string, link: string, services: EngineServices): LinkOutcome {
290
+ const outcome = linkOrCopy(target, link)
291
+ if (outcome === "copy") {
292
+ services.logger.warn(`created copy fallback ${link} because directory linking is unavailable — a later sync will restore a real link once linking works`)
293
+ } else if (outcome === "failed") {
294
+ services.logger.warn(`could not create symlink ${link}`)
265
295
  }
296
+ return outcome
266
297
  }
267
298
 
268
299
  // ----------------------------------------------------- prune + snapshot ----
269
300
 
270
- async function reconcileRemovals(ctx: Ctx, manifest: string, snapshot: string): Promise<void> {
301
+ async function reconcileRemovals(ctx: Ctx, manifest: string, snapshot: string): Promise<Array<string>> {
271
302
  const { change, clearProgress, echo, progress, warn } = ctx.services.logger
272
303
  if (!existsSync(snapshot)) {
273
304
  if (ctx.dryRun) {
@@ -275,17 +306,25 @@ async function reconcileRemovals(ctx: Ctx, manifest: string, snapshot: string):
275
306
  `[dry-run] (--prune) no kit-managed-skills snapshot yet; first real sync writes ${snapshot}, then future --prune runs reconcile against it`
276
307
  )
277
308
  }
278
- return
309
+ return []
279
310
  }
280
311
 
281
312
  const current = normalizeManifest(manifest)
313
+ const currentBases = new Set(current.map((slug) => slug.slice(slug.lastIndexOf("/") + 1)))
282
314
  let removed = 0
283
315
  let failed = 0
316
+ const failedSlugs: Array<string> = []
284
317
  for (const slug of readSlugs(snapshot)) {
285
318
  if (current.includes(slug)) continue
286
319
  const base = slug.slice(slug.lastIndexOf("/") + 1)
320
+ if (currentBases.has(base)) continue
321
+ const claudeEntry = p(ctx.home, ".claude", "skills", base)
322
+ const managedClaudeEntry = lstat(claudeEntry)?.isSymbolicLink() === true || isKitOwnedCopy(claudeEntry)
287
323
  if (ctx.dryRun) {
288
324
  echo(`[dry-run] kit-managed skill no longer in SoT — would remove: ${base}`)
325
+ if (managedClaudeEntry) {
326
+ echo(`[dry-run] kit-managed Claude skill entry — would remove: ~/.claude/skills/${base}`)
327
+ }
289
328
  continue
290
329
  }
291
330
  progress(`Removing universal skill ${base}...`)
@@ -293,12 +332,24 @@ async function reconcileRemovals(ctx: Ctx, manifest: string, snapshot: string):
293
332
  stdio: "ignore"
294
333
  })
295
334
  clearProgress()
296
- if (res.error === undefined && res.exitCode === 0) {
297
- removed++
298
- } else {
335
+ if (res.error !== undefined || res.exitCode !== 0) {
299
336
  warn(`Failed to remove kit-managed skill: ${base}`)
300
337
  failed++
338
+ failedSlugs.push(slug)
339
+ continue
340
+ }
341
+ if (managedClaudeEntry) {
342
+ const entryStat = lstat(claudeEntry)
343
+ const removedClaudeEntry = entryStat === undefined
344
+ || (entryStat.isSymbolicLink() ? removeLink(claudeEntry) : removeKitOwnedCopy(claudeEntry))
345
+ if (!removedClaudeEntry) {
346
+ warn(`Failed to remove kit-managed Claude skill entry: ${base}`)
347
+ failed++
348
+ failedSlugs.push(slug)
349
+ continue
350
+ }
301
351
  }
352
+ removed++
302
353
  }
303
354
 
304
355
  if (removed > 0) {
@@ -306,13 +357,14 @@ async function reconcileRemovals(ctx: Ctx, manifest: string, snapshot: string):
306
357
  ctx.nextStepTriggers.skillsRestart = true
307
358
  }
308
359
  if (failed > 0) warn(`${failed} skill remove(s) failed — re-run with --prune or run: npx skills remove --global <name> -y`)
360
+ return failedSlugs
309
361
  }
310
362
 
311
- function updateSnapshot(ctx: Ctx, manifest: string, snapshot: string): void {
363
+ function updateSnapshot(ctx: Ctx, manifest: string, snapshot: string, failedRemovals: ReadonlyArray<string>): void {
312
364
  if (ctx.dryRun) return
313
365
 
314
366
  mkdirSync(ctx.agentsDir, { recursive: true })
315
- const sorted = [...new Set(normalizeManifest(manifest))].sort(compareCodepoints)
367
+ const sorted = [...new Set([...normalizeManifest(manifest), ...failedRemovals])].sort(compareCodepoints)
316
368
  writeFileIfChanged(snapshot, sorted.length > 0 ? `${sorted.join("\n")}\n` : "")
317
369
  }
318
370
 
@@ -1,20 +1,12 @@
1
1
  /**
2
- * Verified-version-floor layer over SoT/toolchain.json. Probe/install commands
3
- * spawn deterministic argv arrays and are covered by golden regression cases.
2
+ * Verified-version-floor layer over SoT/toolchain.json. Probe commands spawn
3
+ * deterministic argv arrays and are covered by golden regression cases.
4
4
  */
5
- import { readSync } from "node:fs"
6
-
7
5
  import type { ToolId } from "./deps"
8
6
  import type { Ctx } from "./index"
9
7
  import { compareCodepoints, isObject, parseJson, type Json } from "./jq"
10
- import type { EngineServices } from "./services"
11
8
  import { payloadText } from "../payload"
12
-
13
- type InstallFn = (
14
- mode: "install" | "upgrade",
15
- version: string,
16
- services: EngineServices
17
- ) => number | Promise<number>
9
+ import { hostOs } from "./os"
18
10
 
19
11
  function manifest(): { [k: string]: Json } {
20
12
  const doc = parseJson(payloadText("SoT/toolchain.json"))
@@ -68,7 +60,6 @@ export async function installedVersion(ctx: Ctx, tool: ToolId): Promise<string>
68
60
  case "tsc":
69
61
  return firstLineField(await version(), 1)
70
62
  case "bun":
71
- case "effect-solutions":
72
63
  case "npm":
73
64
  return await version()
74
65
  case "bwrap":
@@ -83,139 +74,6 @@ export async function installedVersion(ctx: Ctx, tool: ToolId): Promise<string>
83
74
  }
84
75
  }
85
76
 
86
- export async function latestVersion(ctx: Ctx, tool: ToolId): Promise<string> {
87
- return await ctx.services.deps.latest(tool)
88
- }
89
-
90
- /** Blocking TTY prompt matching bash `read -r -p` (prompt on stderr). */
91
- export function promptLine(
92
- prompt: string,
93
- write: (chunk: string) => void = (chunk) => void process.stderr.write(chunk),
94
- readByte: (buffer: Buffer) => number = (buffer) => readSync(0, buffer, 0, 1, null)
95
- ): string {
96
- write(prompt)
97
- const buf = Buffer.alloc(1)
98
- let line = ""
99
- for (;;) {
100
- let n: number
101
- try {
102
- n = readByte(buf)
103
- } catch {
104
- break
105
- }
106
- if (n === 0) break
107
- const ch = buf.toString("utf8")
108
- if (ch === "\n") break
109
- line += ch
110
- }
111
- return line.replace(/\r$/, "")
112
- }
113
-
114
- /** toolchain::_gate — { proceed, target } ("" target = latest). */
115
- async function gate(
116
- ctx: Ctx,
117
- tool: string,
118
- mode: "install" | "upgrade",
119
- latest: string
120
- ): Promise<{ proceed: boolean; target: string }> {
121
- const { warn } = ctx.services.logger
122
- const verified = field(ctx, tool, "verified")
123
- const pinnable = field(ctx, tool, "pinnable")
124
-
125
- if (verified === "" || !isNewer(latest, verified)) return { proceed: true, target: "" }
126
-
127
- if (ctx.assumeYes) {
128
- warn(`${tool} ${latest} is newer than kit-verified ${verified} — proceeding (--yes)`)
129
- return { proceed: true, target: "" }
130
- }
131
-
132
- if (process.stdin.isTTY === true) {
133
- ctx.services.logger.warn(`${tool} ${latest} is not kit-verified (verified: ${verified}).`)
134
- const question = `Install ${tool} ${latest} anyway? [y/N] `
135
- const answer =
136
- ctx.terminalLease === undefined
137
- ? promptLine(question)
138
- : await ctx.terminalLease.withExclusive(() => promptLine(question))
139
- if (/^[yY]/.test(answer)) return { proceed: true, target: "" }
140
- }
141
-
142
- if (mode === "install" && pinnable === "true") {
143
- warn(`installing kit-verified ${tool} ${verified} instead of ${latest}`)
144
- return { proceed: true, target: verified }
145
- }
146
- warn(
147
- `skipping ${tool} ${mode} (latest ${latest} is above kit-verified ${verified}; pass --yes to accept, or update SoT/toolchain.json after testing)`
148
- )
149
- return { proceed: false, target: "" }
150
- }
151
-
152
- export async function ensure(ctx: Ctx, tool: ToolId, installFn: InstallFn): Promise<number> {
153
- const { echo, verbose, warn } = ctx.services.logger
154
- const policy = field(ctx, tool, "policy")
155
-
156
- if (!present(ctx, tool)) {
157
- const latest = await latestVersion(ctx, tool)
158
- if (ctx.dryRun) {
159
- echo(`[dry-run] would install ${tool} (${latest !== "" ? latest : "latest"}, gated by toolchain.json verified pin)`)
160
- return 0
161
- }
162
- let target: string
163
- if (latest === "") {
164
- target = field(ctx, tool, "verified")
165
- if (target !== "" && field(ctx, tool, "pinnable") === "true") {
166
- warn(`${tool} latest version unknown (offline?) — installing kit-verified ${target} instead`)
167
- } else {
168
- target = ""
169
- warn(`${tool} latest version unknown (offline?) and not pinnable — installing latest unverified`)
170
- }
171
- } else {
172
- const g = await gate(ctx, tool, "install", latest)
173
- if (!g.proceed) return 0
174
- target = g.target
175
- }
176
- return await installFn("install", target !== "" ? target : latest, ctx.services)
177
- }
178
-
179
- const installed = await installedVersion(ctx, tool)
180
- const installedLabel = installed !== "" ? installed : "version unknown"
181
-
182
- if (policy !== "track") {
183
- if (ctx.dryRun) {
184
- echo(`[dry-run] ${tool} present (${installedLabel})`)
185
- return 0
186
- }
187
- verbose(`${tool} present (${installedLabel})`)
188
- return 0
189
- }
190
-
191
- const latest = await latestVersion(ctx, tool)
192
- if (latest === "") {
193
- if (ctx.dryRun) {
194
- echo(`[dry-run] ${tool} present (${installedLabel}); latest unknown (offline?) — no action`)
195
- return 0
196
- }
197
- verbose(`${tool} present (${installedLabel}; latest unknown — no action)`)
198
- return 0
199
- }
200
-
201
- if (installed === "" || isNewer(latest, installed)) {
202
- if (ctx.dryRun) {
203
- echo(`[dry-run] would upgrade ${tool} (${installed !== "" ? installed : "unknown"} -> ${latest}, gated by toolchain.json verified pin)`)
204
- return 0
205
- }
206
- const g = await gate(ctx, tool, "upgrade", latest)
207
- if (!g.proceed) return 0
208
- return await installFn("upgrade", g.target !== "" ? g.target : latest, ctx.services)
209
- }
210
-
211
- if (ctx.dryRun) {
212
- echo(`[dry-run] ${tool} up to date (${installed})`)
213
- return 0
214
- }
215
- verbose(`${tool} up to date (${installed})`)
216
- return 0
217
- }
218
-
219
77
  function row(cells: [string, string, string, string, string, string]): string {
220
78
  const widths = [28, 9, 14, 9, 9]
221
79
  return cells.map((c, i) => (i < widths.length ? c.padEnd(widths[i]!) : c)).join(" ")
@@ -225,7 +83,7 @@ export async function report(ctx: Ctx): Promise<void> {
225
83
  const { echo } = ctx.services.logger
226
84
  echo(row(["TOOL", "KIND", "INSTALLED", "FLOOR", "VERIFIED", "STATUS"]))
227
85
  const pn = ctx.services.platform.name()
228
- const platformOs = pn === "unknown" ? "" : pn
86
+ const platformOs = hostOs(pn).toolchainOs
229
87
  for (const tool of Object.keys(manifest()).sort(compareCodepoints)) {
230
88
  const os = field(ctx, tool, "os")
231
89
  if (os !== "" && platformOs !== "" && os !== platformOs) continue
@@ -242,7 +100,7 @@ export async function report(ctx: Ctx): Promise<void> {
242
100
  const toolId = tool as ToolId
243
101
  if (present(ctx, toolId)) {
244
102
  installed = await installedVersion(ctx, toolId)
245
- status = "ok"
103
+ status = installed === "" ? "unknown" : "ok"
246
104
  if (floor !== "" && installed !== "" && isNewer(floor, installed)) {
247
105
  status = "below-floor"
248
106
  } else if (verified !== "" && installed !== "" && isNewer(installed, verified)) {
package/cli/src/engine.ts CHANGED
@@ -1,7 +1,10 @@
1
+ import { CliError } from "effect/unstable/cli"
1
2
  import { Console, Effect } from "effect"
2
3
  import { spawnSync } from "node:child_process"
3
4
  import { runEngineNative } from "./engine-native"
5
+ import { ExitError } from "./engine-native/parseArgs"
4
6
  import { makeEngineServices } from "./engine-native/services"
7
+ import { targetForHost } from "./engine-native/os/targets"
5
8
  import { kitHome } from "./kitHome"
6
9
  import { DependencyManagerService, LoggerService, PlatformService } from "./services"
7
10
 
@@ -14,10 +17,10 @@ const bashEngineRequested = (): boolean => process.env["DOCKS_KIT_ENGINE"] === "
14
17
  const requireSupportedHost = () => {
15
18
  const platform = process.platform
16
19
  const arch = process.arch
17
- return (platform === "linux" || platform === "darwin") && (arch === "x64" || arch === "arm64")
20
+ return targetForHost(platform, arch) !== undefined
18
21
  ? Effect.void
19
22
  : bail(
20
- `unsupported host ${platform}/${arch}; docks-kit supports only Linux and macOS on x64 or arm64`,
23
+ `unsupported host ${platform}/${arch}; docks-kit supports only Linux, macOS, and Windows on x64 or arm64`,
21
24
  2
22
25
  )
23
26
  }
@@ -28,6 +31,19 @@ const requireSupportedHost = () => {
28
31
  export const compiled =
29
32
  process.argv[1] !== undefined && process.argv[1].startsWith("/$bunfs/")
30
33
 
34
+ export class EngineCaptureError extends ExitError {
35
+ constructor(readonly diagnostic: string, code: number) {
36
+ super(code)
37
+ this.name = "EngineCaptureError"
38
+ this.message = diagnostic
39
+ }
40
+ }
41
+
42
+ const failureMessage = (error: unknown): string => {
43
+ if (error instanceof Error && error.message !== "") return error.message
44
+ return typeof error === "string" && error !== "" ? error : "unknown error"
45
+ }
46
+
31
47
  export const engine = (args: ReadonlyArray<string>) =>
32
48
  Effect.gen(function* () {
33
49
  yield* requireSupportedHost()
@@ -37,7 +53,13 @@ export const engine = (args: ReadonlyArray<string>) =>
37
53
  const logger = yield* LoggerService
38
54
  const deps = yield* DependencyManagerService
39
55
  const platform = yield* PlatformService
40
- const code = yield* Effect.promise(() => runEngineNative(args, { logger, deps, platform }))
56
+ const code = yield* Effect.tryPromise({
57
+ try: () => runEngineNative(args, { logger, deps, platform }),
58
+ catch: (error) => new CliError.UserError({
59
+ cause: error,
60
+ userMessage: `engine operation '${args.join(" ") || "default"}' failed: ${failureMessage(error)}`
61
+ })
62
+ })
41
63
  if (code !== 0) {
42
64
  yield* Effect.sync(() => process.exit(code))
43
65
  }
@@ -50,19 +72,27 @@ export const engineCapture = (args: ReadonlyArray<string>) =>
50
72
  if (bashEngineRequested()) {
51
73
  return yield* bail(bashRemovedMessage, 2)
52
74
  }
53
- return yield* Effect.sync(() => {
75
+ const res = yield* Effect.sync(() =>
54
76
  // Child process (raw channel): runEngineNative writes straight to
55
- // process.stdout, so in-process capture isn't possible.
56
- const res = spawnSync(process.execPath, compiled ? [...args] : [`${kitHome()}/cli/src/main.ts`, ...args], {
77
+ // process.stdout, so in-process capture is not possible.
78
+ spawnSync(process.execPath, compiled ? [...args] : [`${kitHome()}/cli/src/main.ts`, ...args], {
57
79
  env: { ...process.env, DOCKS_KIT_ENGINE: "native-raw" },
58
80
  encoding: "utf8",
59
81
  stdio: ["ignore", "pipe", "inherit"]
60
82
  })
61
- if (res.error !== undefined || res.status !== 0) {
62
- makeEngineServices().logger.warn(`engine capture failed (${args.join(" ")} exited ${res.status ?? "spawn-error"})`)
63
- }
64
- return res.stdout ?? ""
65
- })
83
+ )
84
+ if (res.error !== undefined || res.status !== 0) {
85
+ const reasons = new Array<string>()
86
+ if (typeof res.status === "number") reasons.push(`exit ${res.status}`)
87
+ if (res.signal !== null) reasons.push(`signal ${res.signal}`)
88
+ if (res.error !== undefined) reasons.push(`spawn error: ${res.error.message}`)
89
+ if (reasons.length === 0) reasons.push("no child status")
90
+ const diagnostic = `engine capture failed for '${args.join(" ") || "default"}': ${reasons.join("; ")}`
91
+ makeEngineServices().logger.err(diagnostic)
92
+ const code = typeof res.status === "number" && res.status !== 0 ? res.status : 1
93
+ return yield* Effect.fail(new EngineCaptureError(diagnostic, code))
94
+ }
95
+ return res.stdout ?? ""
66
96
  })
67
97
 
68
98
  /** Print a message to stderr and exit — for CLI-side validation failures. */