docks-kit 0.1.4 → 0.2.0

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.
@@ -4,13 +4,12 @@
4
4
  * change. Guard order, message strings, and backup behavior are golden-tested.
5
5
  */
6
6
  import { spawnSync } from "node:child_process"
7
- import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"
7
+ import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs"
8
8
 
9
9
  import { syncCodexModel, replaceTopLevelSettingInFile } from "./codexToml"
10
- import { capture, commandExists, p } from "./exec"
10
+ import { p } from "./exec"
11
11
  import type { Ctx } from "./index"
12
12
  import { compareCodepoints, isObject, jqStringify, parseJson, type Json } from "./jq"
13
- import { echo, err, log, warn } from "./output"
14
13
 
15
14
  export function codexSync(ctx: Ctx): void {
16
15
  const codexDir = p(ctx.home, ".codex")
@@ -31,14 +30,15 @@ export function codexSync(ctx: Ctx): void {
31
30
  // ---------------------------------------------------------- bubblewrap ----
32
31
 
33
32
  function ensureBubblewrap(ctx: Ctx): void {
34
- if (!bwrapSupportedOs()) return
33
+ const { change, echo, warn } = ctx.services.logger
34
+ if (!bwrapSupportedOs(ctx)) return
35
35
 
36
36
  if (ctx.dryRun) {
37
37
  echo("[dry-run] verify bubblewrap installed (recommended Codex Linux sandbox runtime)")
38
38
  return
39
39
  }
40
40
 
41
- if (commandExists("bwrap")) return
41
+ if (ctx.services.deps.probe("bwrap").state === "present") return
42
42
 
43
43
  if (ctx.skipRtk) {
44
44
  warn(
@@ -47,7 +47,7 @@ function ensureBubblewrap(ctx: Ctx): void {
47
47
  return
48
48
  }
49
49
 
50
- const pmInstall = bwrapDetectPmInstallCmd()
50
+ const pmInstall = bwrapDetectPmInstallCmd(ctx)
51
51
  if (pmInstall === "") {
52
52
  warn(
53
53
  "bubblewrap not installed and no supported package manager found (apt-get/dnf/pacman/zypper). Codex may use its bundled helper if user namespaces work; install system bubblewrap manually when possible."
@@ -62,14 +62,13 @@ function ensureBubblewrap(ctx: Ctx): void {
62
62
  return
63
63
  }
64
64
 
65
- if (!commandExists("bwrap")) {
65
+ if (ctx.services.deps.probe("bwrap").state === "missing") {
66
66
  warn("Package install reported success but bwrap not on PATH — check installation manually")
67
67
  return
68
68
  }
69
69
 
70
70
  if (spawnSync("unshare", ["-Ur", "true"], { stdio: "ignore" }).status === 0) {
71
- const version = capture("bwrap", ["--version"]).split("\n")[0] ?? ""
72
- log(`bubblewrap installed and functional (${version})`)
71
+ change(`bubblewrap installed and functional (${ctx.services.deps.version("bwrap")})`)
73
72
  } else {
74
73
  warn(
75
74
  "bubblewrap installed but unprivileged user namespaces appear blocked. On Ubuntu 24.04+, prefer loading the AppArmor bwrap-userns-restrict profile; fallback: sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0"
@@ -77,24 +76,27 @@ function ensureBubblewrap(ctx: Ctx): void {
77
76
  }
78
77
  }
79
78
 
80
- function bwrapSupportedOs(): boolean {
81
- if (process.platform === "linux") return true
82
- if (process.platform === "darwin" || process.platform === "win32") return false
79
+ function bwrapSupportedOs(ctx: Ctx): boolean {
80
+ const { warn } = ctx.services.logger
81
+ const pn = ctx.services.platform.name()
82
+ if (pn === "linux") return true
83
+ if (pn === "darwin" || pn === "windows") return false
83
84
  warn("Unknown OS — skipping bubblewrap check; Codex sandbox may not work")
84
85
  return false
85
86
  }
86
87
 
87
- function bwrapDetectPmInstallCmd(): string {
88
- if (commandExists("apt-get")) return "sudo apt-get install -y bubblewrap"
89
- if (commandExists("dnf")) return "sudo dnf install -y bubblewrap"
90
- if (commandExists("pacman")) return "sudo pacman -S --noconfirm bubblewrap"
91
- if (commandExists("zypper")) return "sudo zypper install -y bubblewrap"
88
+ function bwrapDetectPmInstallCmd(ctx: Ctx): string {
89
+ if (ctx.services.deps.probe("apt-get").state === "present") return "sudo apt-get install -y bubblewrap"
90
+ if (ctx.services.deps.probe("dnf").state === "present") return "sudo dnf install -y bubblewrap"
91
+ if (ctx.services.deps.probe("pacman").state === "present") return "sudo pacman -S --noconfirm bubblewrap"
92
+ if (ctx.services.deps.probe("zypper").state === "present") return "sudo zypper install -y bubblewrap"
92
93
  return ""
93
94
  }
94
95
 
95
96
  // --------------------------------------------------------------- config ----
96
97
 
97
98
  function syncConfig(ctx: Ctx, sotConfig: string, userConfig: string): void {
99
+ const { change, echo, verbose } = ctx.services.logger
98
100
  if (!existsSync(sotConfig)) return
99
101
 
100
102
  if (ctx.dryRun) {
@@ -104,17 +106,31 @@ function syncConfig(ctx: Ctx, sotConfig: string, userConfig: string): void {
104
106
 
105
107
  if (!existsSync(userConfig)) {
106
108
  copyFileSync(sotConfig, userConfig)
107
- log("Codex config installed")
109
+ change("Codex config installed")
110
+ ctx.nextStepTriggers.codexRestart = true
108
111
  return
109
112
  }
110
113
 
111
- copyFileSync(userConfig, `${userConfig}.bak`)
114
+ // Merge into a staging copy so `.bak` is written only when the config
115
+ // actually changes — an unconditional early backup lets a later no-op run
116
+ // overwrite the recovery copy with already-merged content.
117
+ const before = readFileSync(userConfig, "utf8")
118
+ const staging = `${userConfig}.merge.tmp`
119
+ writeFileSync(staging, before)
112
120
 
113
- scrubDeprecatedFeatures(userConfig)
114
- mergeTopLevelSettings(sotConfig, userConfig)
115
- mergeTableSettings(sotConfig, userConfig)
121
+ scrubDeprecatedFeatures(ctx, staging)
122
+ mergeTopLevelSettings(sotConfig, staging)
123
+ mergeTableSettings(sotConfig, staging)
116
124
 
117
- log("Codex config merged (backup at config.toml.bak; user-only keys/tables preserved)")
125
+ if (readFileSync(staging, "utf8") === before) {
126
+ rmSync(staging, { force: true })
127
+ verbose("Codex config already in sync")
128
+ } else {
129
+ copyFileSync(userConfig, `${userConfig}.bak`)
130
+ renameSync(staging, userConfig)
131
+ change("Codex config merged (backup at config.toml.bak; user-only keys/tables preserved)")
132
+ ctx.nextStepTriggers.codexRestart = true
133
+ }
118
134
  }
119
135
 
120
136
  /** codex::scrub_deprecated_features — the [features].use_legacy_landlock awk pass. */
@@ -152,14 +168,15 @@ export function scrubDeprecatedFeaturesText(content: string): string {
152
168
  return out
153
169
  }
154
170
 
155
- function scrubDeprecatedFeatures(userConfig: string): void {
171
+ function scrubDeprecatedFeatures(ctx: Ctx, userConfig: string): void {
172
+ const { change } = ctx.services.logger
156
173
  if (!existsSync(userConfig)) return
157
174
  const content = readFileSync(userConfig, "utf8")
158
175
  if (!content.split("\n").some((l) => /^use_legacy_landlock[ \t]*=/.test(l))) return
159
176
 
160
177
  writeFileSync(`${userConfig}.tmp`, scrubDeprecatedFeaturesText(content))
161
178
  renameSync(`${userConfig}.tmp`, userConfig)
162
- log("Codex: scrubbed deprecated [features].use_legacy_landlock")
179
+ change("Codex: scrubbed deprecated [features].use_legacy_landlock")
163
180
  }
164
181
 
165
182
  function mergeTopLevelSettings(sotConfig: string, userConfig: string): void {
@@ -209,6 +226,7 @@ function mergeTableSettings(sotConfig: string, userConfig: string): void {
209
226
  // ------------------------------------------------------- rules + agents ----
210
227
 
211
228
  function syncRules(ctx: Ctx, sotRulesDir: string, userRulesDir: string): void {
229
+ const { change, echo, verbose } = ctx.services.logger
212
230
  if (!existsSync(sotRulesDir)) return
213
231
 
214
232
  if (ctx.dryRun) {
@@ -217,21 +235,29 @@ function syncRules(ctx: Ctx, sotRulesDir: string, userRulesDir: string): void {
217
235
  }
218
236
 
219
237
  mkdirSync(userRulesDir, { recursive: true })
220
- let rulesSynced = false
238
+ let sawRules = false
239
+ let rulesChanged = false
221
240
  const ruleFiles = readdirSync(sotRulesDir, { withFileTypes: true })
222
241
  .filter((e) => e.isFile() && e.name.endsWith(".rules"))
223
242
  .map((e) => p(sotRulesDir, e.name))
224
243
  .sort(compareCodepoints)
225
244
  for (const ruleFile of ruleFiles) {
245
+ sawRules = true
226
246
  const userRuleFile = p(userRulesDir, ruleFile.slice(ruleFile.lastIndexOf("/") + 1))
247
+ const identical = existsSync(userRuleFile) && readFileSync(userRuleFile).equals(readFileSync(ruleFile))
248
+ if (identical) continue
227
249
  if (existsSync(userRuleFile)) copyFileSync(userRuleFile, `${userRuleFile}.bak`)
228
250
  copyFileSync(ruleFile, userRuleFile)
229
- rulesSynced = true
251
+ rulesChanged = true
230
252
  }
231
- if (rulesSynced) log("Codex rules synced")
253
+ if (rulesChanged) {
254
+ change("Codex rules synced")
255
+ ctx.nextStepTriggers.codexRestart = true
256
+ } else if (sawRules) verbose("Codex rules already in sync")
232
257
  }
233
258
 
234
259
  function syncAgentsMd(ctx: Ctx, sotAgentsMd: string, userAgentsMd: string): void {
260
+ const { change, echo, verbose } = ctx.services.logger
235
261
  if (!existsSync(sotAgentsMd)) return
236
262
 
237
263
  if (ctx.dryRun) {
@@ -239,14 +265,20 @@ function syncAgentsMd(ctx: Ctx, sotAgentsMd: string, userAgentsMd: string): void
239
265
  return
240
266
  }
241
267
 
268
+ if (existsSync(userAgentsMd) && readFileSync(userAgentsMd).equals(readFileSync(sotAgentsMd))) {
269
+ verbose("Codex AGENTS.md already in sync")
270
+ return
271
+ }
242
272
  if (existsSync(userAgentsMd)) copyFileSync(userAgentsMd, `${userAgentsMd}.bak`)
243
273
  copyFileSync(sotAgentsMd, userAgentsMd)
244
- log("Codex AGENTS.md synced")
274
+ change("Codex AGENTS.md synced")
275
+ ctx.nextStepTriggers.codexRestart = true
245
276
  }
246
277
 
247
278
  // ---------------------------------------------------------- marketplace ----
248
279
 
249
280
  function syncMarketplace(ctx: Ctx, sotMarketplace: string, userMarketplace: string): void {
281
+ const { change, echo, err, verbose } = ctx.services.logger
250
282
  if (!existsSync(sotMarketplace)) return
251
283
 
252
284
  if (ctx.dryRun) {
@@ -264,13 +296,20 @@ function syncMarketplace(ctx: Ctx, sotMarketplace: string, userMarketplace: stri
264
296
  err(`Skipping marketplace sync: ${userMarketplace} is not valid JSON. Fix or delete it.`)
265
297
  return
266
298
  }
299
+ const out = jqStringify(mergeMarketplace(repo, user))
300
+ if (out === readFileSync(userMarketplace, "utf8")) {
301
+ verbose("Codex marketplace already in sync")
302
+ return
303
+ }
267
304
  copyFileSync(userMarketplace, `${userMarketplace}.bak`)
268
- writeFileSync(`${userMarketplace}.tmp`, jqStringify(mergeMarketplace(repo, user)))
305
+ writeFileSync(`${userMarketplace}.tmp`, out)
269
306
  renameSync(`${userMarketplace}.tmp`, userMarketplace)
270
- log("Codex marketplace merged (backup at marketplace.json.bak)")
307
+ change("Codex marketplace merged (backup at marketplace.json.bak)")
308
+ ctx.nextStepTriggers.codexRestart = true
271
309
  } else {
272
310
  copyFileSync(sotMarketplace, userMarketplace)
273
- log("Codex marketplace installed")
311
+ change("Codex marketplace installed")
312
+ ctx.nextStepTriggers.codexRestart = true
274
313
  }
275
314
  }
276
315
 
@@ -326,28 +365,27 @@ export function marketplaceSource(marketplace: string, configFile: string): stri
326
365
  }
327
366
 
328
367
  function removeLegacyDocksMarketplace(ctx: Ctx, userConfig: string): void {
368
+ const { change, echo, warn } = ctx.services.logger
329
369
  if (ctx.dryRun) {
330
370
  echo("[dry-run] remove legacy configured Codex Docks marketplace when personal marketplace is deployed")
331
371
  return
332
372
  }
333
373
 
334
- if (!commandExists("codex")) return
374
+ if (ctx.services.deps.probe("codex").state === "missing") return
335
375
 
336
376
  const source = marketplaceSource("docks", userConfig)
337
377
  if (source !== "https://github.com/DocksDocks/docks.git" && source !== "DocksDocks/docks") return
338
378
  const res = spawnSync("codex", ["plugin", "marketplace", "remove", "docks"], { stdio: "ignore" })
339
379
  if (res.error === undefined && res.status === 0) {
340
- log("Removed legacy configured Codex Docks marketplace; using personal marketplace file")
380
+ change("Removed legacy configured Codex Docks marketplace; using personal marketplace file")
381
+ ctx.nextStepTriggers.codexRestart = true
341
382
  } else {
342
383
  warn("Failed to remove legacy configured Codex Docks marketplace")
343
384
  }
344
385
  }
345
386
 
346
387
  /** codex::_standalone_install_command — per-OS official standalone installer. */
347
- const standaloneInstallCommand = (): string =>
348
- process.platform === "win32"
349
- ? `powershell -ExecutionPolicy ByPass -c "irm https://chatgpt.com/codex/install.ps1 | iex"`
350
- : 'tmp=$(mktemp) && curl -fsSL https://chatgpt.com/codex/install.sh -o "$tmp" && CODEX_NON_INTERACTIVE=1 sh "$tmp"'
388
+ const standaloneInstallCommand = (ctx: Ctx): string => ctx.services.deps.spec("codex").installHint()
351
389
 
352
390
  /** codex::_enabled_plugin_ids — [plugins."<id>"] tables with enabled = true. */
353
391
  export function enabledPluginIds(configFile: string): Array<string> {
@@ -386,14 +424,23 @@ function manualPluginRefreshCommand(sotConfig: string): string {
386
424
  }
387
425
 
388
426
  function syncPlugins(ctx: Ctx, sotConfig: string): void {
427
+ const { change, echo, warn } = ctx.services.logger
389
428
  if (ctx.dryRun) {
390
429
  echo("[dry-run] add enabled Codex plugins from SoT")
391
430
  return
392
431
  }
393
432
 
394
- if (!commandExists("codex")) {
433
+ if (ctx.services.deps.probe("codex").state === "missing") {
395
434
  warn(
396
- `codex CLI not in PATH - deployed config/marketplace only; install Codex with: ${standaloneInstallCommand()} | docs: https://developers.openai.com/codex/cli; then run: ${manualPluginRefreshCommand(sotConfig)}`
435
+ `codex CLI not in PATH - deployed config/marketplace only; install Codex with: ${standaloneInstallCommand(ctx)} | docs: https://developers.openai.com/codex/cli; then run: ${manualPluginRefreshCommand(sotConfig)}`
436
+ )
437
+ return
438
+ }
439
+ if (ctx.services.deps.probe("git").state === "missing") {
440
+ ctx.services.deps.warnMissing(
441
+ "git",
442
+ ctx.services.logger,
443
+ "plugin marketplaces are git repos — Codex plugin refresh skipped; re-run sync after installing"
397
444
  )
398
445
  return
399
446
  }
@@ -407,7 +454,7 @@ function syncPlugins(ctx: Ctx, sotConfig: string): void {
407
454
  refreshed++
408
455
  } else if (addOut.includes("could not find a Codex CLI binary")) {
409
456
  warn(
410
- `Codex plugin refresh hit a stale launcher/wrapper on PATH - install current standalone Codex with: ${standaloneInstallCommand()}`
457
+ `Codex plugin refresh hit a stale launcher/wrapper on PATH - install current standalone Codex with: ${standaloneInstallCommand(ctx)}`
411
458
  )
412
459
  failed++
413
460
  } else {
@@ -419,13 +466,17 @@ function syncPlugins(ctx: Ctx, sotConfig: string): void {
419
466
  }
420
467
  }
421
468
 
422
- if (refreshed > 0) log(`Codex plugins synced (plugins: ~${refreshed})`)
469
+ if (refreshed > 0) {
470
+ change(`Codex plugins synced (plugins: ~${refreshed})`)
471
+ ctx.nextStepTriggers.codexRestart = true
472
+ }
423
473
  if (failed > 0) warn(`${failed} Codex plugin operation(s) failed — re-run sync or install manually`)
424
474
  }
425
475
 
426
476
  // -------------------------------------------------------------- summary ----
427
477
 
428
478
  export function codexSummary(ctx: Ctx): void {
479
+ const { echo } = ctx.services.logger
429
480
  const codexDir = p(ctx.home, ".codex")
430
481
  echo(`Codex: ${codexDir}`)
431
482
  if (!ctx.dryRun) {
@@ -434,6 +485,8 @@ export function codexSummary(ctx: Ctx): void {
434
485
  }
435
486
  }
436
487
 
437
- export function codexNextSteps(): void {
438
- echo("Restart Codex to load any refreshed plugins, skills, or tools.")
488
+ export function codexNextSteps(ctx: Ctx): Array<string> {
489
+ return ctx.verbose || ctx.nextStepTriggers.codexRestart
490
+ ? ["Restart Codex to load any refreshed plugins, skills, or tools."]
491
+ : []
439
492
  }
@@ -8,7 +8,6 @@ import { p } from "./exec"
8
8
  import { readFileSync, renameSync, writeFileSync } from "node:fs"
9
9
 
10
10
  import type { Ctx } from "./index"
11
- import { echo, log, warn } from "./output"
12
11
 
13
12
  export function replaceTopLevelSetting(content: string, key: string, replacement: string): string {
14
13
  const lines = content.split("\n")
@@ -40,13 +39,17 @@ export function replaceTopLevelSetting(content: string, key: string, replacement
40
39
  return `${out.join("\n")}\n`
41
40
  }
42
41
 
43
- export function replaceTopLevelSettingInFile(file: string, key: string, replacement: string): void {
44
- const next = replaceTopLevelSetting(readFileSync(file, "utf8"), key, replacement)
42
+ export function replaceTopLevelSettingInFile(file: string, key: string, replacement: string): boolean {
43
+ const before = readFileSync(file, "utf8")
44
+ const next = replaceTopLevelSetting(before, key, replacement)
45
+ if (next === before) return false
45
46
  writeFileSync(`${file}.tmp`, next)
46
47
  renameSync(`${file}.tmp`, file)
48
+ return true
47
49
  }
48
50
 
49
51
  export function syncCodexModel(ctx: Ctx, model: string): void {
52
+ const { change, echo, verbose, warn } = ctx.services.logger
50
53
  const userCodexSettings = p(ctx.home, ".codex", "config.toml")
51
54
 
52
55
  if (model === "") return
@@ -62,6 +65,10 @@ export function syncCodexModel(ctx: Ctx, model: string): void {
62
65
  warn(`(--codex-model) ${userCodexSettings} missing — skipped`)
63
66
  return
64
67
  }
65
- replaceTopLevelSettingInFile(userCodexSettings, "model", `model = "${model}"`)
66
- log(`Model: deployed Codex model set to ${model} (SoT unchanged; flag-less sync reverts)`)
68
+ if (replaceTopLevelSettingInFile(userCodexSettings, "model", `model = "${model}"`)) {
69
+ change(`Model: deployed Codex model set to ${model} (SoT unchanged; flag-less sync reverts)`)
70
+ ctx.nextStepTriggers.codexRestart = true
71
+ } else {
72
+ verbose(`Model: deployed Codex model already ${model}`)
73
+ }
67
74
  }