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
|
@@ -9,6 +9,7 @@ import { syncCodexEffort, syncCodexModel, replaceTopLevelSettingInFile } from ".
|
|
|
9
9
|
import { p, spawnProcess } from "./exec"
|
|
10
10
|
import type { Ctx } from "./index"
|
|
11
11
|
import { compareCodepoints, isObject, jqStringify, parseJson, type Json } from "./jq"
|
|
12
|
+
import { hostOs } from "./os"
|
|
12
13
|
import { payloadBytes, payloadDisplayPath, payloadPaths, payloadText, type PayloadPath } from "../payload"
|
|
13
14
|
|
|
14
15
|
export async function codexSync(ctx: Ctx): Promise<void> {
|
|
@@ -70,7 +71,10 @@ async function ensureBubblewrap(ctx: Ctx): Promise<void> {
|
|
|
70
71
|
return
|
|
71
72
|
}
|
|
72
73
|
|
|
73
|
-
|
|
74
|
+
const namespaceProbe = await spawnProcess("unshare", ["-Ur", "true"], { stdio: "ignore" })
|
|
75
|
+
if (namespaceProbe.error !== undefined) {
|
|
76
|
+
warn(`Could not run unshare to check user namespaces: ${namespaceProbe.error.message}`)
|
|
77
|
+
} else if (namespaceProbe.exitCode === 0) {
|
|
74
78
|
change(`bubblewrap installed and functional (${await ctx.services.deps.version("bwrap")})`)
|
|
75
79
|
} else {
|
|
76
80
|
warn(
|
|
@@ -81,10 +85,11 @@ async function ensureBubblewrap(ctx: Ctx): Promise<void> {
|
|
|
81
85
|
|
|
82
86
|
function bwrapSupportedOs(ctx: Ctx): boolean {
|
|
83
87
|
const { warn } = ctx.services.logger
|
|
84
|
-
const
|
|
85
|
-
if (
|
|
86
|
-
if (
|
|
87
|
-
|
|
88
|
+
const os = hostOs(ctx.services.platform.name())
|
|
89
|
+
if (os.supportsBubblewrap) return true
|
|
90
|
+
if (os.id === "unknown") {
|
|
91
|
+
warn("Unknown OS — skipping bubblewrap check; Codex sandbox may not work")
|
|
92
|
+
}
|
|
88
93
|
return false
|
|
89
94
|
}
|
|
90
95
|
|
|
@@ -100,10 +105,14 @@ function bwrapDetectPmInstallCmd(ctx: Ctx): string {
|
|
|
100
105
|
|
|
101
106
|
function syncConfig(ctx: Ctx, sotConfigText: string, userConfig: string): void {
|
|
102
107
|
const { change, echo, verbose } = ctx.services.logger
|
|
103
|
-
const sotConfig = payloadDisplayPath("SoT/.codex/config.toml"
|
|
108
|
+
const sotConfig = payloadDisplayPath("SoT/.codex/config.toml")
|
|
104
109
|
|
|
105
110
|
if (ctx.dryRun) {
|
|
106
|
-
|
|
111
|
+
if (existsSync(userConfig)) {
|
|
112
|
+
echo(`[dry-run] merge ${sotConfig} -> ${userConfig}`)
|
|
113
|
+
} else {
|
|
114
|
+
echo(`[dry-run] install ${sotConfig} -> ${userConfig}`)
|
|
115
|
+
}
|
|
107
116
|
return
|
|
108
117
|
}
|
|
109
118
|
|
|
@@ -119,7 +128,8 @@ function syncConfig(ctx: Ctx, sotConfigText: string, userConfig: string): void {
|
|
|
119
128
|
// overwrite the recovery copy with already-merged content.
|
|
120
129
|
const before = readFileSync(userConfig, "utf8")
|
|
121
130
|
const staging = `${userConfig}.merge.tmp`
|
|
122
|
-
|
|
131
|
+
// Normalize once before record transforms because CR bytes change table-header identity.
|
|
132
|
+
writeFileSync(staging, before.replace(/\r\n/g, "\n"))
|
|
123
133
|
|
|
124
134
|
scrubDeprecatedFeatures(ctx, staging)
|
|
125
135
|
removeRetiredPluginTables(ctx, staging)
|
|
@@ -146,15 +156,20 @@ export function scrubDeprecatedFeaturesText(content: string): string {
|
|
|
146
156
|
let header = ""
|
|
147
157
|
let body = ""
|
|
148
158
|
let keep = false
|
|
159
|
+
let changed = false
|
|
149
160
|
for (const line of lines) {
|
|
150
161
|
if (inFeatures) {
|
|
151
162
|
if (line.startsWith("[")) {
|
|
152
163
|
inFeatures = false
|
|
153
164
|
if (keep) out += `${header}\n${body}`
|
|
165
|
+
else changed = true
|
|
154
166
|
out += `${line}\n`
|
|
155
167
|
continue
|
|
156
168
|
}
|
|
157
|
-
if (/^use_legacy_landlock[ \t]*=/.test(line))
|
|
169
|
+
if (/^use_legacy_landlock[ \t]*=/.test(line)) {
|
|
170
|
+
changed = true
|
|
171
|
+
continue
|
|
172
|
+
}
|
|
158
173
|
body += `${line}\n`
|
|
159
174
|
if (/[^ \t\f\v\r]/.test(line)) keep = true
|
|
160
175
|
continue
|
|
@@ -168,24 +183,31 @@ export function scrubDeprecatedFeaturesText(content: string): string {
|
|
|
168
183
|
}
|
|
169
184
|
out += `${line}\n`
|
|
170
185
|
}
|
|
171
|
-
if (inFeatures
|
|
172
|
-
|
|
186
|
+
if (inFeatures) {
|
|
187
|
+
if (keep) out += `${header}\n${body}`
|
|
188
|
+
else changed = true
|
|
189
|
+
}
|
|
190
|
+
return changed ? out : content
|
|
173
191
|
}
|
|
174
192
|
|
|
175
193
|
function scrubDeprecatedFeatures(ctx: Ctx, userConfig: string): void {
|
|
176
194
|
const { change } = ctx.services.logger
|
|
177
195
|
if (!existsSync(userConfig)) return
|
|
178
196
|
const content = readFileSync(userConfig, "utf8")
|
|
179
|
-
|
|
197
|
+
const next = scrubDeprecatedFeaturesText(content)
|
|
198
|
+
if (next === content) return
|
|
180
199
|
|
|
181
|
-
writeFileSync(`${userConfig}.tmp`,
|
|
200
|
+
writeFileSync(`${userConfig}.tmp`, next)
|
|
182
201
|
renameSync(`${userConfig}.tmp`, userConfig)
|
|
183
202
|
change("Codex: scrubbed deprecated [features].use_legacy_landlock")
|
|
184
203
|
}
|
|
185
204
|
|
|
186
205
|
const PLUGIN_TABLE_HEADER = /^\[plugins\."([^"]+)"\][ \t]*$/
|
|
187
206
|
/** Plugin ids the kit retired; their deployed tables are stripped on every sync. */
|
|
188
|
-
const RETIRED_PLUGIN_IDS: Readonly<Record<string, true>> = {
|
|
207
|
+
const RETIRED_PLUGIN_IDS: Readonly<Record<string, true>> = {
|
|
208
|
+
"effect-kit@docks": true,
|
|
209
|
+
"session-relay@docks": true
|
|
210
|
+
}
|
|
189
211
|
|
|
190
212
|
/** codex::remove_retired_plugin_tables — drop [plugins."<id>"] blocks for retired ids. */
|
|
191
213
|
export function removeRetiredPluginTablesText(content: string): string {
|
|
@@ -236,38 +258,134 @@ function mergeTopLevelSettings(sotConfigText: string, userConfig: string): void
|
|
|
236
258
|
}
|
|
237
259
|
}
|
|
238
260
|
|
|
239
|
-
|
|
261
|
+
interface TomlTableHeader {
|
|
262
|
+
readonly path: string
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const TOML_BASIC_ESCAPES: Readonly<Record<string, string>> = {
|
|
266
|
+
b: "\b",
|
|
267
|
+
t: "\t",
|
|
268
|
+
n: "\n",
|
|
269
|
+
f: "\f",
|
|
270
|
+
r: "\r",
|
|
271
|
+
'"': '"',
|
|
272
|
+
"\\": "\\"
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function tomlBasicEscape(line: string, offset: number): { readonly next: number; readonly value: string } | undefined {
|
|
276
|
+
const escaped = line[offset]
|
|
277
|
+
const simple = escaped === undefined ? undefined : TOML_BASIC_ESCAPES[escaped]
|
|
278
|
+
if (simple !== undefined) return { next: offset + 1, value: simple }
|
|
279
|
+
const digits = escaped === "u" ? 4 : escaped === "U" ? 8 : 0
|
|
280
|
+
if (digits === 0) return undefined
|
|
281
|
+
const hex = line.slice(offset + 1, offset + 1 + digits)
|
|
282
|
+
if (hex.length !== digits || !/^[0-9A-Fa-f]+$/.test(hex)) return undefined
|
|
283
|
+
const codePoint = Number.parseInt(hex, 16)
|
|
284
|
+
if (codePoint > 0x10ffff || (codePoint >= 0xd800 && codePoint <= 0xdfff)) return undefined
|
|
285
|
+
return { next: offset + 1 + digits, value: String.fromCodePoint(codePoint) }
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** Decode a table header to the TOML path that determines managed ownership. */
|
|
289
|
+
function tomlTableHeader(line: string): TomlTableHeader | undefined {
|
|
290
|
+
let offset = 0
|
|
291
|
+
const skipWhitespace = (): void => {
|
|
292
|
+
while (line[offset] === " " || line[offset] === "\t") offset++
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
skipWhitespace()
|
|
296
|
+
if (line[offset] !== "[") return undefined
|
|
297
|
+
offset++
|
|
298
|
+
const array = line[offset] === "["
|
|
299
|
+
if (array) offset++
|
|
300
|
+
|
|
301
|
+
const keys: Array<string> = []
|
|
302
|
+
while (true) {
|
|
303
|
+
skipWhitespace()
|
|
304
|
+
const quote = line[offset]
|
|
305
|
+
let key = ""
|
|
306
|
+
if (quote === '"' || quote === "'") {
|
|
307
|
+
offset++
|
|
308
|
+
let closed = false
|
|
309
|
+
while (offset < line.length) {
|
|
310
|
+
const char = line[offset]!
|
|
311
|
+
if (char === quote) {
|
|
312
|
+
offset++
|
|
313
|
+
closed = true
|
|
314
|
+
break
|
|
315
|
+
}
|
|
316
|
+
if (quote === '"' && char === "\\") {
|
|
317
|
+
const escape = tomlBasicEscape(line, offset + 1)
|
|
318
|
+
if (escape === undefined) return undefined
|
|
319
|
+
key += escape.value
|
|
320
|
+
offset = escape.next
|
|
321
|
+
continue
|
|
322
|
+
}
|
|
323
|
+
if (char === "\n" || char === "\r") return undefined
|
|
324
|
+
key += char
|
|
325
|
+
offset++
|
|
326
|
+
}
|
|
327
|
+
if (!closed) return undefined
|
|
328
|
+
} else {
|
|
329
|
+
const start = offset
|
|
330
|
+
while (offset < line.length && /[A-Za-z0-9_-]/.test(line[offset]!)) offset++
|
|
331
|
+
if (offset === start) return undefined
|
|
332
|
+
key = line.slice(start, offset)
|
|
333
|
+
}
|
|
334
|
+
keys.push(key)
|
|
335
|
+
|
|
336
|
+
skipWhitespace()
|
|
337
|
+
if (line[offset] === ".") {
|
|
338
|
+
offset++
|
|
339
|
+
continue
|
|
340
|
+
}
|
|
341
|
+
if (line[offset] !== "]") return undefined
|
|
342
|
+
offset++
|
|
343
|
+
if (array) {
|
|
344
|
+
if (line[offset] !== "]") return undefined
|
|
345
|
+
offset++
|
|
346
|
+
}
|
|
347
|
+
skipWhitespace()
|
|
348
|
+
if (offset < line.length && line[offset] !== "#") return undefined
|
|
349
|
+
return { path: JSON.stringify(keys) }
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function mergeTableSettingsText(sotConfigText: string, userConfigText: string): string {
|
|
240
354
|
const sotLines = sotConfigText.split("\n")
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
355
|
+
let merged = userConfigText
|
|
356
|
+
for (let tableOffset = 0; tableOffset < sotLines.length; tableOffset++) {
|
|
357
|
+
const managedHeader = tomlTableHeader(sotLines[tableOffset]!)
|
|
358
|
+
if (managedHeader === undefined) continue
|
|
359
|
+
|
|
245
360
|
const block: Array<string> = []
|
|
246
|
-
for (
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
361
|
+
for (let blockOffset = tableOffset; blockOffset < sotLines.length; blockOffset++) {
|
|
362
|
+
const line = sotLines[blockOffset]!
|
|
363
|
+
if (blockOffset !== tableOffset && tomlTableHeader(line) !== undefined) break
|
|
364
|
+
block.push(line)
|
|
250
365
|
}
|
|
251
366
|
const tableBlock = block.join("\n").replace(/\n+$/, "")
|
|
252
367
|
|
|
253
|
-
|
|
254
|
-
const userLines = readFileSync(userConfig, "utf8").split("\n")
|
|
368
|
+
const userLines = merged.split("\n")
|
|
255
369
|
if (userLines[userLines.length - 1] === "") userLines.pop()
|
|
256
370
|
let skip = false
|
|
257
371
|
const kept: Array<string> = []
|
|
258
372
|
for (const line of userLines) {
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
373
|
+
const header = tomlTableHeader(line)
|
|
374
|
+
if (header !== undefined) {
|
|
375
|
+
skip = header.path === managedHeader.path
|
|
376
|
+
if (skip) continue
|
|
262
377
|
}
|
|
263
|
-
if (skip && line.startsWith("[")) skip = false
|
|
264
378
|
if (!skip) kept.push(line)
|
|
265
379
|
}
|
|
266
|
-
|
|
267
|
-
const next = `${kept.join("\n")}\n\n${tableBlock}\n`
|
|
268
|
-
writeFileSync(`${userConfig}.tmp`, next)
|
|
269
|
-
renameSync(`${userConfig}.tmp`, userConfig)
|
|
380
|
+
merged = `${kept.join("\n")}\n\n${tableBlock}\n`
|
|
270
381
|
}
|
|
382
|
+
return merged
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function mergeTableSettings(sotConfigText: string, userConfig: string): void {
|
|
386
|
+
const next = mergeTableSettingsText(sotConfigText, readFileSync(userConfig, "utf8"))
|
|
387
|
+
writeFileSync(`${userConfig}.tmp`, next)
|
|
388
|
+
renameSync(`${userConfig}.tmp`, userConfig)
|
|
271
389
|
}
|
|
272
390
|
|
|
273
391
|
// ------------------------------------------------------- rules + agents ----
|
|
@@ -276,7 +394,7 @@ function syncRules(ctx: Ctx, sotRules: ReadonlyArray<PayloadPath>, userRulesDir:
|
|
|
276
394
|
const { change, echo, verbose } = ctx.services.logger
|
|
277
395
|
const firstRule = sotRules[0]
|
|
278
396
|
if (firstRule === undefined) return
|
|
279
|
-
const firstDisplay = payloadDisplayPath(firstRule
|
|
397
|
+
const firstDisplay = payloadDisplayPath(firstRule)
|
|
280
398
|
const sotRulesDir = firstDisplay.slice(0, firstDisplay.lastIndexOf("/"))
|
|
281
399
|
|
|
282
400
|
if (ctx.dryRun) {
|
|
@@ -306,7 +424,7 @@ function syncRules(ctx: Ctx, sotRules: ReadonlyArray<PayloadPath>, userRulesDir:
|
|
|
306
424
|
|
|
307
425
|
function syncAgentsMd(ctx: Ctx, sotAgentsMdText: string, userAgentsMd: string): void {
|
|
308
426
|
const { change, echo, verbose } = ctx.services.logger
|
|
309
|
-
const sotAgentsMd = payloadDisplayPath("SoT/.codex/AGENTS.md"
|
|
427
|
+
const sotAgentsMd = payloadDisplayPath("SoT/.codex/AGENTS.md")
|
|
310
428
|
|
|
311
429
|
if (ctx.dryRun) {
|
|
312
430
|
echo(`[dry-run] cp ${sotAgentsMd} -> ${userAgentsMd}`)
|
|
@@ -326,26 +444,32 @@ function syncAgentsMd(ctx: Ctx, sotAgentsMdText: string, userAgentsMd: string):
|
|
|
326
444
|
// ---------------------------------------------------------- marketplace ----
|
|
327
445
|
|
|
328
446
|
function syncMarketplace(ctx: Ctx, sotMarketplaceText: string, userMarketplace: string): void {
|
|
329
|
-
const { change, echo,
|
|
330
|
-
const sotMarketplace = payloadDisplayPath("SoT/.codex/plugins/marketplace.json"
|
|
447
|
+
const { change, echo, verbose } = ctx.services.logger
|
|
448
|
+
const sotMarketplace = payloadDisplayPath("SoT/.codex/plugins/marketplace.json")
|
|
449
|
+
const repo = parseJson(sotMarketplaceText)
|
|
450
|
+
if (repo === undefined) throw new Error(`invalid SoT marketplace JSON: ${sotMarketplace}`)
|
|
451
|
+
|
|
452
|
+
const userText = existsSync(userMarketplace) ? readFileSync(userMarketplace, "utf8") : undefined
|
|
453
|
+
const user = userText === undefined ? undefined : parseJson(userText)
|
|
454
|
+
if (userText !== undefined && user === undefined) {
|
|
455
|
+
throw new Error(`invalid deployed Codex marketplace JSON: ${userMarketplace}. Fix or delete it.`)
|
|
456
|
+
}
|
|
457
|
+
const out = user === undefined ? undefined : jqStringify(mergeMarketplace(repo, user))
|
|
331
458
|
|
|
332
459
|
if (ctx.dryRun) {
|
|
333
|
-
|
|
460
|
+
if (userText === undefined) {
|
|
461
|
+
echo(`[dry-run] cp ${sotMarketplace} -> ${userMarketplace}`)
|
|
462
|
+
} else if (out === userText) {
|
|
463
|
+
verbose("Codex marketplace already in sync")
|
|
464
|
+
} else {
|
|
465
|
+
echo(`[dry-run] merge ${sotMarketplace} -> ${userMarketplace} (backup at ${userMarketplace}.bak)`)
|
|
466
|
+
}
|
|
334
467
|
return
|
|
335
468
|
}
|
|
336
469
|
|
|
337
470
|
mkdirSync(p(ctx.agentsDir, "plugins"), { recursive: true })
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
if (existsSync(userMarketplace)) {
|
|
342
|
-
const user = parseJson(readFileSync(userMarketplace, "utf8"))
|
|
343
|
-
if (user === undefined) {
|
|
344
|
-
err(`Skipping marketplace sync: ${userMarketplace} is not valid JSON. Fix or delete it.`)
|
|
345
|
-
return
|
|
346
|
-
}
|
|
347
|
-
const out = jqStringify(mergeMarketplace(repo, user))
|
|
348
|
-
if (out === readFileSync(userMarketplace, "utf8")) {
|
|
471
|
+
if (userText !== undefined && out !== undefined) {
|
|
472
|
+
if (out === userText) {
|
|
349
473
|
verbose("Codex marketplace already in sync")
|
|
350
474
|
return
|
|
351
475
|
}
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* historical record-oriented behavior.
|
|
6
6
|
*/
|
|
7
7
|
import { p } from "./exec"
|
|
8
|
-
import { readFileSync, renameSync, writeFileSync } from "node:fs"
|
|
8
|
+
import { existsSync, readFileSync, renameSync, writeFileSync } from "node:fs"
|
|
9
9
|
|
|
10
10
|
import { resolveEffort } from "../efforts"
|
|
11
11
|
import type { Ctx } from "./index"
|
|
@@ -61,17 +61,22 @@ function syncCodexSetting(ctx: Ctx, edit: CodexSettingEdit): void {
|
|
|
61
61
|
const { change, echo, verbose, warn } = ctx.services.logger
|
|
62
62
|
const userCodexSettings = p(ctx.home, ".codex", "config.toml")
|
|
63
63
|
|
|
64
|
-
if (
|
|
65
|
-
|
|
64
|
+
if (!existsSync(userCodexSettings)) {
|
|
65
|
+
// A dry-run sync already previewed installing the file, so the edit is not skipped.
|
|
66
|
+
// The `docks-kit model codex <m>` command creates no file, so the skip stands.
|
|
67
|
+
if (ctx.dryRun && ctx.syncCodex) {
|
|
68
|
+
echo(`[dry-run] (${edit.tag}) set ${edit.key} = "${edit.value}" in ${userCodexSettings}`)
|
|
69
|
+
return
|
|
70
|
+
}
|
|
71
|
+
warn(`(${edit.tag}) ${userCodexSettings} missing — skipped`)
|
|
66
72
|
return
|
|
67
73
|
}
|
|
68
74
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
} catch {
|
|
72
|
-
warn(`(${edit.tag}) ${userCodexSettings} missing — skipped`)
|
|
75
|
+
if (ctx.dryRun) {
|
|
76
|
+
echo(`[dry-run] (${edit.tag}) set ${edit.key} = "${edit.value}" in ${userCodexSettings}`)
|
|
73
77
|
return
|
|
74
78
|
}
|
|
79
|
+
|
|
75
80
|
if (replaceTopLevelSettingInFile(userCodexSettings, edit.key, `${edit.key} = "${edit.value}"`)) {
|
|
76
81
|
change(edit.changed)
|
|
77
82
|
ctx.nextStepTriggers.codexRestart = true
|
|
@@ -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
|
-
*
|
|
7
|
-
* WHICH external tools exist, whether they are
|
|
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
|
-
|
|
96
|
-
|
|
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,
|
|
@@ -107,11 +98,11 @@ const home = (): string => {
|
|
|
107
98
|
return envHome !== undefined && envHome !== "" ? envHome : homedir()
|
|
108
99
|
}
|
|
109
100
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
101
|
+
/**
|
|
102
|
+
* The resolved path gets persisted into global direct-exec hooks, so a
|
|
103
|
+
* relative `which` hit (relative PATH entry, relative BUN_INSTALL) would
|
|
104
|
+
* break outside the sync working directory.
|
|
105
|
+
*/
|
|
115
106
|
const findBun = (exec: ProbeExecutor): { command: string; path: string } | undefined => {
|
|
116
107
|
const onPath = exec.which("bun")
|
|
117
108
|
if (onPath !== "" && isAbsolute(onPath)) {
|
|
@@ -135,33 +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 versionBunCommand = (exec: ProbeExecutor): string =>
|
|
145
|
-
exec.commandExists("bun") ? "bun" : p(home(), ".bun", "bin", "bun")
|
|
146
|
-
|
|
147
|
-
const versionEffectSolutions = async (exec: ProbeExecutor): Promise<string> => {
|
|
148
|
-
const bun = versionBunCommand(exec)
|
|
149
|
-
if (bun !== "bun" && exec.which(bun) === "") return ""
|
|
150
|
-
const match = /effect-solutions@([0-9][0-9.]*)/.exec(await exec.capture(bun, ["pm", "-g", "ls"]))
|
|
151
|
-
return match?.[1] ?? ""
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
const locateEffectSolutions = async (exec: ProbeExecutor): Promise<DependencyLocation> => {
|
|
155
|
-
const strictBun = findBun(exec)
|
|
156
|
-
const pathBun = exec.which("bun")
|
|
157
|
-
const bun = strictBun ?? (pathBun !== "" ? { command: "bun", path: pathBun } : undefined)
|
|
158
|
-
if (bun === undefined) return { path: "", binDir: "" }
|
|
159
|
-
const globalBin = await exec.capture(bun.command, ["pm", "-g", "bin"])
|
|
160
|
-
const path = globalBin !== "" ? p(globalBin, "effect-solutions") : ""
|
|
161
|
-
const resolved = path !== "" && exec.which(path) !== "" ? path : ""
|
|
162
|
-
return { path: resolved, binDir: globalBin }
|
|
163
|
-
}
|
|
164
|
-
|
|
165
129
|
const npmGlobalCache = new WeakMap<ProbeExecutor, Promise<{ [k: string]: string }>>()
|
|
166
130
|
|
|
167
131
|
const npmGlobalVersions = (exec: ProbeExecutor): Promise<{ [k: string]: string }> => {
|
|
@@ -185,31 +149,21 @@ const npmGlobalVersions = (exec: ProbeExecutor): Promise<{ [k: string]: string }
|
|
|
185
149
|
const versionNpmGlobal = (pkg: string) => async (exec: ProbeExecutor): Promise<string> =>
|
|
186
150
|
(await npmGlobalVersions(exec))[pkg] ?? ""
|
|
187
151
|
|
|
188
|
-
const latestNpm = (id: "effect-solutions") => async (exec: ProbeExecutor): Promise<string> =>
|
|
189
|
-
exec.commandExists("npm") ? await exec.capture("npm", ["view", id, "version"]) : ""
|
|
190
|
-
|
|
191
152
|
export const defaultProbeExecutor: ProbeExecutor = { commandExists, capture, which }
|
|
192
153
|
|
|
193
154
|
export const DEPENDENCIES: Record<ToolId, DependencySpec> = {
|
|
194
155
|
git: spec(
|
|
195
156
|
"git",
|
|
196
157
|
"optional",
|
|
197
|
-
(pf = rawPlatform()) =>
|
|
198
|
-
pf === "darwin"
|
|
199
|
-
? "brew install git"
|
|
200
|
-
: "sudo apt install -y git (or your distro's package manager)",
|
|
158
|
+
(pf = rawPlatform()) => hostOs(platformName(pf)).installHint("git"),
|
|
201
159
|
{ version: versionProbe("git") }
|
|
202
160
|
),
|
|
203
|
-
jq: spec("jq", "optional", (pf = rawPlatform()) =>
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
),
|
|
209
|
-
curl: spec("curl", "optional", (pf = rawPlatform()) =>
|
|
210
|
-
pf === "darwin" ? "brew install curl" : "sudo apt install -y curl",
|
|
211
|
-
{ version: versionProbe("curl") }
|
|
212
|
-
),
|
|
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
|
+
}),
|
|
213
167
|
node: spec("node", "optional", () => "install Node.js via https://nodejs.org (or your package manager)", {
|
|
214
168
|
version: versionProbe("node")
|
|
215
169
|
}),
|
|
@@ -218,13 +172,13 @@ export const DEPENDENCIES: Record<ToolId, DependencySpec> = {
|
|
|
218
172
|
claude: spec(
|
|
219
173
|
"claude",
|
|
220
174
|
"optional",
|
|
221
|
-
() => "
|
|
175
|
+
(pf = rawPlatform()) => hostOs(platformName(pf)).installHint("claude"),
|
|
222
176
|
{ version: versionProbe("claude") }
|
|
223
177
|
),
|
|
224
178
|
codex: spec(
|
|
225
179
|
"codex",
|
|
226
180
|
"optional",
|
|
227
|
-
() =>
|
|
181
|
+
(pf = rawPlatform()) => hostOs(platformName(pf)).installHint("codex"),
|
|
228
182
|
{ version: versionProbe("codex") }
|
|
229
183
|
),
|
|
230
184
|
bun: spec(
|
|
@@ -233,24 +187,20 @@ export const DEPENDENCIES: Record<ToolId, DependencySpec> = {
|
|
|
233
187
|
() => "curl -fsSL https://bun.sh/install | bash",
|
|
234
188
|
{
|
|
235
189
|
resolve: resolveBun,
|
|
236
|
-
version: (exec) =>
|
|
237
|
-
|
|
190
|
+
version: async (exec) => {
|
|
191
|
+
const bun = findBun(exec)
|
|
192
|
+
if (bun === undefined) return ""
|
|
193
|
+
return await exec.capture(bun.command, ["--version"])
|
|
194
|
+
}
|
|
238
195
|
}
|
|
239
196
|
),
|
|
240
197
|
bwrap: spec("bwrap", "optional", () => "sudo apt install -y bubblewrap (or dnf/pacman/zypper equivalent)", {
|
|
241
198
|
version: versionProbe("bwrap")
|
|
242
199
|
}),
|
|
243
|
-
"effect-solutions": spec("effect-solutions", "optional", () => "bun add -g effect-solutions", {
|
|
244
|
-
resolve: resolveEffectSolutions,
|
|
245
|
-
version: versionEffectSolutions,
|
|
246
|
-
locate: locateEffectSolutions,
|
|
247
|
-
latest: latestNpm("effect-solutions")
|
|
248
|
-
}),
|
|
249
200
|
ffplay: spec(
|
|
250
201
|
"ffplay",
|
|
251
202
|
"optional",
|
|
252
|
-
(pf = rawPlatform()) =>
|
|
253
|
-
pf === "darwin" ? "brew install ffmpeg" : "sudo apt install -y ffmpeg",
|
|
203
|
+
(pf = rawPlatform()) => hostOs(platformName(pf)).installHint("ffplay"),
|
|
254
204
|
{ versionArgs: ["-version"], version: versionProbe("ffplay", ["-version"]), resolve: pathProbe("ffplay") }
|
|
255
205
|
),
|
|
256
206
|
intelephense: spec("intelephense", "optional", () => "npm install -g intelephense", {
|
|
@@ -286,20 +236,11 @@ export async function resolveVersion(specification: DependencySpec, exec: ProbeE
|
|
|
286
236
|
return await (specification.version ?? versionProbe(specification.id, specification.versionArgs))(exec)
|
|
287
237
|
}
|
|
288
238
|
|
|
289
|
-
export async function resolveLocation(
|
|
290
|
-
specification: DependencySpec,
|
|
291
|
-
exec: ProbeExecutor,
|
|
292
|
-
platform: NodeJS.Platform = rawPlatform()
|
|
293
|
-
): Promise<DependencyLocation> {
|
|
294
|
-
if (specification.locate !== undefined) return await specification.locate(exec, platform)
|
|
295
|
-
const result = resolveDependency(specification, exec, platform)
|
|
296
|
-
return { path: result.state === "present" ? (result.path ?? exec.which(specification.id)) : "", binDir: "" }
|
|
297
|
-
}
|
|
298
|
-
|
|
299
239
|
export async function resolvePath(
|
|
300
240
|
specification: DependencySpec,
|
|
301
241
|
exec: ProbeExecutor,
|
|
302
|
-
platform
|
|
242
|
+
platform: NodeJS.Platform = rawPlatform()
|
|
303
243
|
): Promise<string> {
|
|
304
|
-
|
|
244
|
+
const result = resolveDependency(specification, exec, platform)
|
|
245
|
+
return result.state === "present" ? (result.path ?? exec.which(specification.id)) : ""
|
|
305
246
|
}
|