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.
- package/AGENTS.md +57 -17
- package/README.md +33 -31
- package/cli/docs/flags.md +0 -1
- package/cli/docs/install.md +28 -13
- package/cli/docs/overview.md +2 -2
- package/cli/docs/platforms.md +5 -2
- package/cli/docs/sync-layers.md +3 -4
- package/cli/docs/toolchain.md +26 -34
- package/cli/src/commands/docs.ts +3 -3
- package/cli/src/commands/model.ts +3 -0
- package/cli/src/commands/models.ts +5 -3
- package/cli/src/commands/status.ts +145 -32
- package/cli/src/commands/sync.ts +4 -5
- package/cli/src/commands/toolchain.ts +4 -7
- package/cli/src/commands/update.ts +177 -32
- package/cli/src/efforts.ts +5 -5
- package/cli/src/engine-native/DESIGN.md +31 -22
- package/cli/src/engine-native/bun.ts +42 -14
- package/cli/src/engine-native/claudeRuntime.ts +17 -9
- package/cli/src/engine-native/claudeSettingsModifiers.ts +29 -11
- package/cli/src/engine-native/claudeSync.ts +91 -55
- package/cli/src/engine-native/codexSync.ts +173 -49
- package/cli/src/engine-native/codexToml.ts +12 -7
- package/cli/src/engine-native/deps.ts +36 -95
- package/cli/src/engine-native/exec.ts +42 -24
- package/cli/src/engine-native/index.ts +17 -6
- package/cli/src/engine-native/models.ts +2 -9
- package/cli/src/engine-native/modes.ts +54 -35
- package/cli/src/engine-native/os/darwin.ts +62 -0
- package/cli/src/engine-native/os/index.ts +42 -0
- package/cli/src/engine-native/os/linux.ts +62 -0
- package/cli/src/engine-native/os/targets.ts +73 -0
- package/cli/src/engine-native/os/types.ts +75 -0
- package/cli/src/engine-native/os/windows.ts +176 -0
- package/cli/src/engine-native/parseArgs.ts +147 -48
- package/cli/src/engine-native/services.ts +1 -11
- package/cli/src/engine-native/settings.ts +3 -2
- package/cli/src/engine-native/skillsSync.ts +141 -89
- package/cli/src/engine-native/toolchain.ts +5 -147
- package/cli/src/engine.ts +41 -11
- package/cli/src/generated/sotPayload.ts +7 -7
- package/cli/src/kitHome.ts +42 -5
- package/cli/src/main.ts +12 -2
- package/cli/src/manifests.ts +28 -11
- package/cli/src/payload.ts +2 -5
- package/docks-kit +4 -4
- package/docks-kit.ps1 +123 -0
- package/package.json +9 -5
- 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
|
|
5
|
-
* snapshot write.
|
|
4
|
+
* the kit-managed snapshot, and the snapshot write.
|
|
6
5
|
*/
|
|
7
|
-
import {
|
|
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 {
|
|
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
|
-
|
|
30
|
-
|
|
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
|
|
38
|
-
|
|
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 =
|
|
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
|
-
|
|
152
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
180
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
232
|
+
return ""
|
|
204
233
|
}
|
|
205
|
-
return existsSync(link)
|
|
206
234
|
}
|
|
207
235
|
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
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
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
):
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
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
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
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
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
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
|
|
285
|
+
return "failed"
|
|
253
286
|
}
|
|
254
287
|
}
|
|
255
288
|
|
|
256
|
-
|
|
257
|
-
const
|
|
258
|
-
if (
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
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<
|
|
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
|
|
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
|
|
3
|
-
*
|
|
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
|
|
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
|
|
20
|
+
return targetForHost(platform, arch) !== undefined
|
|
18
21
|
? Effect.void
|
|
19
22
|
: bail(
|
|
20
|
-
`unsupported host ${platform}/${arch}; docks-kit supports only Linux and
|
|
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.
|
|
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
|
-
|
|
75
|
+
const res = yield* Effect.sync(() =>
|
|
54
76
|
// Child process (raw channel): runEngineNative writes straight to
|
|
55
|
-
// process.stdout, so in-process capture
|
|
56
|
-
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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. */
|