docks-kit 0.14.3 → 0.14.4

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/README.md CHANGED
@@ -128,7 +128,7 @@ Details: `docks-kit docs platforms`.
128
128
  Tagging `cli-v*` builds four standalone binaries (Linux x64/arm64 and macOS
129
129
  x64/arm64) plus `SHA256SUMS` and attaches them to the GitHub release; npm
130
130
  publishes the exact package tarball through trusted publishing with OIDC provenance.
131
- Package `docks-kit` 0.14.3 bundles the CLI + generated payload, so npm releases
131
+ Package `docks-kit` 0.14.4 bundles the CLI + generated payload, so npm releases
132
132
  are versioned config snapshots without shipping the authoring `SoT/` tree.
133
133
 
134
134
  ## Deeper docs
package/cli/docs/flags.md CHANGED
@@ -23,6 +23,12 @@ docks-kit sync claude agents # two
23
23
  | `--yes` | Auto-accept toolchain above-verified prompts (containers/CI) |
24
24
  | `--verbose` / `-v` | Also print no-op confirmations (already in sync, up to date, left as-is); accepted on `sync`, `model`, and `toolchain` |
25
25
 
26
+ ## Environment overrides
27
+
28
+ | Variable | Effect |
29
+ |----------|--------|
30
+ | `DOCKS_KIT_SYNC_CONCURRENCY=1\|2\|3` | Maximum selected sync pipelines running at once. Default `3`; use `1` for serial golden/debug execution. Invalid values fail `sync` with exit 2 but do not affect `model` or `toolchain`. |
31
+
26
32
  ## Per-tool flags
27
33
 
28
34
  | Flag | Effect |
@@ -31,6 +31,11 @@ explicit removed-engine diagnostic and exits 2 with the recovery tag message.
31
31
  materialized settings, writes runtime assets, commits settings, then performs
32
32
  readiness-gated legacy cleanup. Modifiers run after the base commit, removals
33
33
  before plugins, and LSP checks after plugin state.
34
+ - **Pipeline concurrency is bounded.** Selected Claude, Codex, and skills
35
+ pipelines overlap through one input-ordered pool. The production default is
36
+ 3; `DOCKS_KIT_SYNC_CONCURRENCY=1` restores serial execution for golden tests
37
+ and debugging (`2` allows two-way overlap). Each individual pipeline remains
38
+ serial, and summaries retain canonical Claude, Codex, skills order.
34
39
  - **External CLIs stay external.** `claude`, `codex`, `npx`, `npm`, `bun`,
35
40
  `curl`, and platform package managers are spawned with argv arrays,
36
41
  not shell command strings except where the external installer contract is a
@@ -69,10 +74,16 @@ warnings, and the summary. Status-quo confirmations exist but are opt-in.
69
74
  stderr sink exists and `process.stderr.isTTY` is `true`. It disables progress
70
75
  in every other case. Injected golden sinks therefore keep their existing
71
76
  bytes unless the harness supplies a progress sink.
72
- - Each progress write replaces one terminal line. `clearProgress()` erases a
73
- pending line and does nothing when no line is pending. Every durable
74
- `change`, `verbose`, `warn`, `err`, or `echo` write erases the pending line
75
- before it writes durable output.
77
+ - Each progress write replaces one terminal line. Every durable `change`,
78
+ `verbose`, `warn`, `err`, or `echo` write erases a visible transient before
79
+ its own write.
80
+ - During sync, the coordinator holds one run-scoped terminal lease and owns
81
+ the transient line. Terminal-exclusive sections serialize input ownership
82
+ by acquisition order and suspend progress redraw while an inherited-stdio
83
+ installer or blocking prompt holds the lease.
84
+ - Automated tests cannot exercise real input contention: golden children use
85
+ ignored stdin, so `process.stdin.isTTY` is false. The simultaneous-prompt
86
+ path requires hand verification in a real TTY.
76
87
 
77
88
  ### Change detection
78
89
 
@@ -1,8 +1,7 @@
1
- import { spawnSync } from "node:child_process"
2
1
  import { rmSync } from "node:fs"
3
2
  import { tmpdir } from "node:os"
4
3
 
5
- import { p } from "./exec"
4
+ import { p, spawnProcess } from "./exec"
6
5
  import type { Ctx } from "./index"
7
6
  import type { EngineServices } from "./services"
8
7
  import { field } from "./toolchain"
@@ -11,10 +10,6 @@ export type BunRuntimeState =
11
10
  | { readonly kind: "ready"; readonly executable: string }
12
11
  | { readonly kind: "deferred"; readonly reason: "missing-curl" | "install-failed" }
13
12
 
14
- function remember(ctx: Ctx, state: BunRuntimeState): BunRuntimeState {
15
- ctx.bunRuntime = state
16
- return state
17
- }
18
13
 
19
14
  function predictedExecutable(ctx: Ctx): string {
20
15
  const root = process.env["BUN_INSTALL"] !== undefined && process.env["BUN_INSTALL"] !== ""
@@ -23,47 +18,53 @@ function predictedExecutable(ctx: Ctx): string {
23
18
  return p(root, "bin", "bun")
24
19
  }
25
20
 
26
- function installBun(pin: string, installer: string): void {
27
- const download = spawnSync("curl", ["-fsSL", "https://bun.sh/install", "-o", installer], { stdio: "ignore" })
28
- if (download.error === undefined && download.status === 0) {
29
- spawnSync("bash", [installer, `bun-v${pin}`], { stdio: "ignore" })
21
+ async function installBun(pin: string, installer: string): Promise<void> {
22
+ const download = await spawnProcess("curl", ["-fsSL", "https://bun.sh/install", "-o", installer], { stdio: "ignore" })
23
+ if (download.error === undefined && download.exitCode === 0) {
24
+ await spawnProcess("bash", [installer, `bun-v${pin}`], { stdio: "ignore" })
30
25
  }
31
26
  }
32
27
 
33
- export function bunBootstrap(ctx: Ctx, services: EngineServices): BunRuntimeState {
28
+ export function bunBootstrap(ctx: Ctx, services: EngineServices): Promise<BunRuntimeState> {
34
29
  if (ctx.bunRuntime !== undefined) return ctx.bunRuntime
30
+ const pending = runBunBootstrap(ctx, services)
31
+ ctx.bunRuntime = pending
32
+ return pending
33
+ }
34
+
35
+ async function runBunBootstrap(ctx: Ctx, services: EngineServices): Promise<BunRuntimeState> {
35
36
 
36
- const existing = services.deps.path("bun")
37
- if (existing !== "") return remember(ctx, { kind: "ready", executable: existing })
37
+ const existing = await services.deps.path("bun")
38
+ if (existing !== "") return { kind: "ready", executable: existing }
38
39
 
39
40
  const pin = field(ctx, "bun", "verified")
40
41
  if (pin === "") {
41
42
  services.logger.warn("Bun bootstrap aborted — SoT/toolchain.json has no verified Bun pin")
42
- return remember(ctx, { kind: "deferred", reason: "install-failed" })
43
+ return { kind: "deferred", reason: "install-failed" }
43
44
  }
44
45
  if (services.deps.probe("curl").state === "missing") {
45
46
  services.deps.warnMissing("curl", services.logger, "cannot bootstrap Bun; install Bun manually, then re-run sync")
46
- return remember(ctx, { kind: "deferred", reason: "missing-curl" })
47
+ return { kind: "deferred", reason: "missing-curl" }
47
48
  }
48
49
  if (ctx.dryRun) {
49
50
  const executable = predictedExecutable(ctx)
50
51
  services.logger.echo(`[dry-run] install Bun ${pin} (kit-verified) -> ${executable}`)
51
- return remember(ctx, { kind: "ready", executable })
52
+ return { kind: "ready", executable }
52
53
  }
53
54
  services.logger.warn(`Bun not found — installing Bun ${pin} (kit-verified)...`)
54
55
  const installer = p(tmpdir(), `bun-install-${process.pid}.sh`)
55
56
  try {
56
- installBun(pin, installer)
57
+ await installBun(pin, installer)
57
58
  } finally {
58
59
  rmSync(installer, { force: true })
59
60
  }
60
61
 
61
- const installed = services.deps.path("bun")
62
+ const installed = await services.deps.path("bun")
62
63
  if (installed === "") {
63
64
  services.logger.warn("Bun install failed. Install manually from https://bun.sh/docs/installation, then re-run sync.")
64
- return remember(ctx, { kind: "deferred", reason: "install-failed" })
65
+ return { kind: "deferred", reason: "install-failed" }
65
66
  }
66
- const version = services.deps.version("bun")
67
+ const version = await services.deps.version("bun")
67
68
  services.logger.change(`Bun installed (${version !== "" ? version : "version unknown"})`)
68
- return remember(ctx, { kind: "ready", executable: installed })
69
+ return { kind: "ready", executable: installed }
69
70
  }
@@ -4,7 +4,6 @@
4
4
  * plugins. Message strings, guard order, JSON semantics, and spawned argv are
5
5
  * golden-tested.
6
6
  */
7
- import { spawnSync } from "node:child_process"
8
7
  import {
9
8
  appendFileSync,
10
9
  copyFileSync,
@@ -23,7 +22,7 @@ import {
23
22
  syncClaudeModel
24
23
  } from "./claudeSettingsModifiers"
25
24
  import { claudeRuntimePaths, materializeClaudeSettings, type ClaudeRuntimePaths } from "./claudeRuntime"
26
- import { p, writeBytesIfChanged, writeTextIfChanged } from "./exec"
25
+ import { p, spawnProcess, writeBytesIfChanged, writeTextIfChanged } from "./exec"
27
26
  import type { Ctx } from "./index"
28
27
  import { compareCodepoints, deepMerge, isObject, jqStringify, parseJson, type Json } from "./jq"
29
28
  import { ExitError } from "./parseArgs"
@@ -35,7 +34,7 @@ export type ClaudeRuntimeState =
35
34
  | { readonly kind: "ready"; readonly paths: ClaudeRuntimePaths }
36
35
  | { readonly kind: "deferred"; readonly reason: "bun-unavailable" }
37
36
 
38
- export function claudeSync(ctx: Ctx): ClaudeRuntimeState {
37
+ export async function claudeSync(ctx: Ctx): Promise<ClaudeRuntimeState> {
39
38
  const { err, warn } = ctx.services.logger
40
39
  const claudeDir = p(ctx.home, ".claude")
41
40
 
@@ -47,7 +46,7 @@ export function claudeSync(ctx: Ctx): ClaudeRuntimeState {
47
46
  )
48
47
  }
49
48
 
50
- const bun = bunBootstrap(ctx, ctx.services)
49
+ const bun = await bunBootstrap(ctx, ctx.services)
51
50
  const runtime: ClaudeRuntimeState = bun.kind === "ready"
52
51
  ? { kind: "ready", paths: claudeRuntimePaths(claudeDir, bun.executable) }
53
52
  : { kind: "deferred", reason: "bun-unavailable" }
@@ -78,9 +77,9 @@ export function claudeSync(ctx: Ctx): ClaudeRuntimeState {
78
77
  syncClaudeAdvisor(ctx, ctx.claudeAdvisor)
79
78
  syncClaudeJson(ctx)
80
79
  syncConnectorEnv(ctx)
81
- syncPlugins(ctx, claudeDir)
82
- syncOptionalPlugins(ctx, claudeDir)
83
- syncLspServers(ctx)
80
+ await syncPlugins(ctx, claudeDir)
81
+ await syncOptionalPlugins(ctx, claudeDir)
82
+ await syncLspServers(ctx)
84
83
  return runtime
85
84
  }
86
85
 
@@ -523,9 +522,9 @@ function syncRemovals(ctx: Ctx, claudeDir: string, runtime: ClaudeRuntimeState):
523
522
 
524
523
  // -------------------------------------------------------------- plugins ----
525
524
 
526
- function cli(args: Array<string>): { ok: boolean; out: string } {
527
- const res = spawnSync("claude", args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] })
528
- return { ok: res.error === undefined && res.status === 0, out: `${res.stdout ?? ""}${res.stderr ?? ""}` }
525
+ async function cli(args: Array<string>): Promise<{ ok: boolean; out: string }> {
526
+ const res = await spawnProcess("claude", args, { stdio: ["ignore", "pipe", "pipe"] })
527
+ return { ok: res.error === undefined && res.exitCode === 0, out: `${res.stdout}${res.stderr}` }
529
528
  }
530
529
 
531
530
  function readJsonFile(file: string): Json | undefined {
@@ -564,7 +563,7 @@ function nonUserScopeMarketplaces(installedDoc: Json | undefined): Set<string> {
564
563
  return marketplaces
565
564
  }
566
565
 
567
- function syncPlugins(ctx: Ctx, claudeDir: string): void {
566
+ async function syncPlugins(ctx: Ctx, claudeDir: string): Promise<void> {
568
567
  const { change, clearProgress, echo, progress, verbose, warn } = ctx.services.logger
569
568
  const knownMarketplaces = p(claudeDir, "plugins", "known_marketplaces.json")
570
569
  const installedPlugins = p(claudeDir, "plugins", "installed_plugins.json")
@@ -607,7 +606,7 @@ function syncPlugins(ctx: Ctx, claudeDir: string): void {
607
606
  if (known !== undefined && isObject(known) && known[mpName] !== undefined && known[mpName] !== null && known[mpName] !== false) continue
608
607
  const repo = isObject(mpValue) && isObject(mpValue["source"]) ? String((mpValue["source"] as { [k: string]: Json })["repo"] ?? "") : ""
609
608
  progress(`Adding marketplace ${mpName}...`)
610
- const marketplaceResult = cli(["plugin", "marketplace", "add", repo])
609
+ const marketplaceResult = await cli(["plugin", "marketplace", "add", repo])
611
610
  clearProgress()
612
611
  if (marketplaceResult.ok) {
613
612
  addedMp++
@@ -628,12 +627,12 @@ function syncPlugins(ctx: Ctx, claudeDir: string): void {
628
627
  const mpName = separator > 0 ? pluginId.slice(separator + 1) : ""
629
628
  if (mpName !== "" && !refreshedMarketplaces.has(mpName)) {
630
629
  progress(`Refreshing marketplace ${mpName}...`)
631
- cli(["plugin", "marketplace", "update", mpName])
630
+ await cli(["plugin", "marketplace", "update", mpName])
632
631
  clearProgress()
633
632
  refreshedMarketplaces.add(mpName)
634
633
  }
635
634
  progress(`Installing plugin ${pluginId}...`)
636
- const installResult = cli(["plugin", "install", pluginId])
635
+ const installResult = await cli(["plugin", "install", pluginId])
637
636
  clearProgress()
638
637
  if (installResult.ok) {
639
638
  addedPl++
@@ -664,13 +663,13 @@ function syncPlugins(ctx: Ctx, claudeDir: string): void {
664
663
  if (!ctx.skipPluginRefresh) {
665
664
  for (const mpName of [...kitMarketplaces].sort(compareCodepoints)) {
666
665
  progress(`Refreshing marketplace ${mpName}...`)
667
- cli(["plugin", "marketplace", "update", mpName])
666
+ await cli(["plugin", "marketplace", "update", mpName])
668
667
  clearProgress()
669
668
  }
670
669
  for (const pluginId of [...kitPluginIds].sort(compareCodepoints)) {
671
670
  if (!pluginUserScopeInstalled(installedPlugins, pluginId)) continue
672
671
  progress(`Updating plugin ${pluginId}...`)
673
- const updateResult = cli(["plugin", "update", pluginId, "--scope", "user"])
672
+ const updateResult = await cli(["plugin", "update", pluginId, "--scope", "user"])
674
673
  clearProgress()
675
674
  if (updateResult.out.includes("Successfully updated")) updatedPl++
676
675
  }
@@ -686,7 +685,7 @@ function syncPlugins(ctx: Ctx, claudeDir: string): void {
686
685
  if (isObject(sotPlugins) && Object.prototype.hasOwnProperty.call(sotPlugins, pluginId)) continue
687
686
  if (!pluginUserScopeInstalled(installedPlugins, pluginId)) continue
688
687
  progress(`Uninstalling plugin ${pluginId}...`)
689
- const uninstallResult = cli(["plugin", "uninstall", "-y", "--scope", "user", pluginId])
688
+ const uninstallResult = await cli(["plugin", "uninstall", "-y", "--scope", "user", pluginId])
690
689
  clearProgress()
691
690
  if (uninstallResult.ok) {
692
691
  removedPl++
@@ -702,7 +701,7 @@ function syncPlugins(ctx: Ctx, claudeDir: string): void {
702
701
  const declared = isObject(sotMarketplaces) ? sotMarketplaces[mpName] : undefined
703
702
  if (declared !== undefined && declared !== null && declared !== false) continue
704
703
  progress(`Removing marketplace ${mpName}...`)
705
- const removeResult = cli(["plugin", "marketplace", "remove", mpName])
704
+ const removeResult = await cli(["plugin", "marketplace", "remove", mpName])
706
705
  clearProgress()
707
706
  if (removeResult.ok) {
708
707
  removedMp++
@@ -714,7 +713,7 @@ function syncPlugins(ctx: Ctx, claudeDir: string): void {
714
713
  }
715
714
 
716
715
  // Pass 6 — re-assert SoT enabled-state in the user settings.
717
- if (reassertEnabledState(ctx, repoObj, p(claudeDir, "settings.json"))) {
716
+ if (await reassertEnabledState(ctx, repoObj, p(claudeDir, "settings.json"))) {
718
717
  change("Plugin enable-state re-asserted from SoT in settings.json")
719
718
  ctx.nextStepTriggers.claudePlugins = true
720
719
  }
@@ -731,7 +730,7 @@ function syncPlugins(ctx: Ctx, claudeDir: string): void {
731
730
  }
732
731
  }
733
732
 
734
- function reassertEnabledState(ctx: Ctx, repoObj: { [k: string]: Json }, userSettingsFile: string): boolean {
733
+ async function reassertEnabledState(ctx: Ctx, repoObj: { [k: string]: Json }, userSettingsFile: string): Promise<boolean> {
735
734
  const { warn } = ctx.services.logger
736
735
  if (!existsSync(userSettingsFile)) return false
737
736
  const sotPlugins = isObject(repoObj["enabledPlugins"]) ? repoObj["enabledPlugins"] : {}
@@ -742,7 +741,7 @@ function reassertEnabledState(ctx: Ctx, repoObj: { [k: string]: Json }, userSett
742
741
  const user = readJsonFile(userSettingsFile)
743
742
  const enabled = user !== undefined && isObject(user) && isObject(user["enabledPlugins"]) ? (user["enabledPlugins"] as { [k: string]: Json })[pluginId] : undefined
744
743
  if (enabled !== true) continue
745
- if (cli(["plugin", "disable", pluginId]).ok) {
744
+ if ((await cli(["plugin", "disable", pluginId])).ok) {
746
745
  cliDisabled = true
747
746
  } else {
748
747
  warn(`Failed to disable SoT-false plugin: ${pluginId} (will retry next sync)`)
@@ -765,7 +764,7 @@ function reassertEnabledState(ctx: Ctx, repoObj: { [k: string]: Json }, userSett
765
764
 
766
765
  // ------------------------------------------------------ optional plugins ----
767
766
 
768
- function enableOptionalPlugin(ctx: Ctx, claudeDir: string, pluginId: string, marketplaceRepo: string): boolean {
767
+ async function enableOptionalPlugin(ctx: Ctx, claudeDir: string, pluginId: string, marketplaceRepo: string): Promise<boolean> {
769
768
  const { change, clearProgress, progress, verbose, warn } = ctx.services.logger
770
769
  const installedPlugins = p(claudeDir, "plugins", "installed_plugins.json")
771
770
  const knownMarketplaces = p(claudeDir, "plugins", "known_marketplaces.json")
@@ -776,7 +775,7 @@ function enableOptionalPlugin(ctx: Ctx, claudeDir: string, pluginId: string, mar
776
775
  const known = readJsonFile(knownMarketplaces)
777
776
  const has = known !== undefined && isObject(known) && known[mpName] !== undefined && known[mpName] !== null && known[mpName] !== false
778
777
  if (!has) {
779
- if (!cli(["plugin", "marketplace", "add", marketplaceRepo]).ok) {
778
+ if (!(await cli(["plugin", "marketplace", "add", marketplaceRepo])).ok) {
780
779
  warn(`Failed to add marketplace ${marketplaceRepo} for ${pluginId}`)
781
780
  return false
782
781
  }
@@ -787,7 +786,7 @@ function enableOptionalPlugin(ctx: Ctx, claudeDir: string, pluginId: string, mar
787
786
  const wasInstalled = pluginUserScopeInstalled(installedPlugins, pluginId)
788
787
  if (!wasInstalled) {
789
788
  progress(`Installing plugin ${pluginId}...`)
790
- const installResult = cli(["plugin", "install", pluginId])
789
+ const installResult = await cli(["plugin", "install", pluginId])
791
790
  clearProgress()
792
791
  if (!installResult.ok) {
793
792
  if (marketplaceAdded) change(`Optional plugin ${pluginId}: marketplace added (install failed — will retry next sync)`)
@@ -802,7 +801,7 @@ function enableOptionalPlugin(ctx: Ctx, claudeDir: string, pluginId: string, mar
802
801
  ? (settingsDoc["enabledPlugins"] as { [k: string]: Json })[pluginId] === true
803
802
  : false
804
803
 
805
- if (!cli(["plugin", "enable", pluginId]).ok) {
804
+ if (!(await cli(["plugin", "enable", pluginId])).ok) {
806
805
  if (marketplaceAdded || !wasInstalled) change(`Optional plugin ${pluginId}: installed (enable failed — will retry next sync)`)
807
806
  warn(`Failed to enable optional plugin ${pluginId}`)
808
807
  return marketplaceAdded || !wasInstalled
@@ -813,7 +812,7 @@ function enableOptionalPlugin(ctx: Ctx, claudeDir: string, pluginId: string, mar
813
812
  return changed
814
813
  }
815
814
 
816
- function syncOptionalPlugins(ctx: Ctx, claudeDir: string): void {
815
+ async function syncOptionalPlugins(ctx: Ctx, claudeDir: string): Promise<void> {
817
816
  const { echo, warn } = ctx.services.logger
818
817
  if (ctx.claudePlugins.length === 0) return
819
818
 
@@ -833,10 +832,10 @@ function syncOptionalPlugins(ctx: Ctx, claudeDir: string): void {
833
832
  }
834
833
 
835
834
  if (ctx.claudePlugins.includes("supabase")) {
836
- if (enableOptionalPlugin(ctx, claudeDir, "supabase@claude-plugins-official", "")) ctx.nextStepTriggers.claudePlugins = true
835
+ if (await enableOptionalPlugin(ctx, claudeDir, "supabase@claude-plugins-official", "")) ctx.nextStepTriggers.claudePlugins = true
837
836
  }
838
837
  if (ctx.claudePlugins.includes("n8n")) {
839
- if (enableOptionalPlugin(ctx, claudeDir, "n8n-mcp-skills@n8n-mcp-skills", "czlonkowski/n8n-skills")) ctx.nextStepTriggers.claudePlugins = true
838
+ if (await enableOptionalPlugin(ctx, claudeDir, "n8n-mcp-skills@n8n-mcp-skills", "czlonkowski/n8n-skills")) ctx.nextStepTriggers.claudePlugins = true
840
839
  }
841
840
  }
842
841
 
@@ -847,7 +846,7 @@ function lspPkg(ctx: Ctx, tool: string, pkg: string): string {
847
846
  return v !== "" ? `${pkg}@${v}` : pkg
848
847
  }
849
848
 
850
- function syncLspServers(ctx: Ctx): void {
849
+ async function syncLspServers(ctx: Ctx): Promise<void> {
851
850
  const { change, clearProgress, echo, progress, verbose, warn } = ctx.services.logger
852
851
  const sot = parseJson(payloadText("SoT/.claude/settings.json"))
853
852
  const enabled = sot !== undefined && isObject(sot) && isObject(sot["enabledPlugins"]) ? sot["enabledPlugins"] : undefined
@@ -889,9 +888,9 @@ function syncLspServers(ctx: Ctx): void {
889
888
 
890
889
  verbose(`Installing LSP servers via npm: ${specs}...`)
891
890
  progress(`Installing LSP servers via npm: ${specs}...`)
892
- const installResult = spawnSync("npm", ["install", "-g", ...missing], { stdio: "ignore" })
891
+ const installResult = await spawnProcess("npm", ["install", "-g", ...missing], { stdio: "ignore" })
893
892
  clearProgress()
894
- if (installResult.status === 0) {
893
+ if (installResult.exitCode === 0) {
895
894
  change(`LSP servers installed (${specs})`)
896
895
  ctx.nextStepTriggers.claudeRestart = true
897
896
  } else {
@@ -3,21 +3,20 @@
3
3
  * avoid a TOML library because reformatting user configs would be a behavior
4
4
  * change. Guard order, message strings, and backup behavior are golden-tested.
5
5
  */
6
- import { spawnSync } from "node:child_process"
7
6
  import { copyFileSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs"
8
7
 
9
8
  import { syncCodexEffort, syncCodexModel, replaceTopLevelSettingInFile } from "./codexToml"
10
- import { p } from "./exec"
9
+ import { p, spawnProcess } from "./exec"
11
10
  import type { Ctx } from "./index"
12
11
  import { compareCodepoints, isObject, jqStringify, parseJson, type Json } from "./jq"
13
12
  import { payloadBytes, payloadDisplayPath, payloadPaths, payloadText, type PayloadPath } from "../payload"
14
13
 
15
- export function codexSync(ctx: Ctx): void {
14
+ export async function codexSync(ctx: Ctx): Promise<void> {
16
15
  const codexDir = p(ctx.home, ".codex")
17
16
  const sotConfig = payloadText("SoT/.codex/config.toml")
18
17
  const userConfig = p(codexDir, "config.toml")
19
18
 
20
- ensureBubblewrap(ctx)
19
+ await ensureBubblewrap(ctx)
21
20
  if (!ctx.dryRun) mkdirSync(codexDir, { recursive: true })
22
21
  syncConfig(ctx, sotConfig, userConfig)
23
22
  syncCodexModel(ctx, ctx.codexModel)
@@ -25,13 +24,13 @@ export function codexSync(ctx: Ctx): void {
25
24
  syncRules(ctx, payloadPaths("SoT/.codex/rules/"), p(codexDir, "rules"))
26
25
  syncAgentsMd(ctx, payloadText("SoT/.codex/AGENTS.md"), p(codexDir, "AGENTS.md"))
27
26
  syncMarketplace(ctx, payloadText("SoT/.codex/plugins/marketplace.json"), p(ctx.agentsDir, "plugins", "marketplace.json"))
28
- removeLegacyDocksMarketplace(ctx, userConfig)
29
- syncPlugins(ctx, sotConfig)
27
+ await removeLegacyDocksMarketplace(ctx, userConfig)
28
+ await syncPlugins(ctx, sotConfig)
30
29
  }
31
30
 
32
31
  // ---------------------------------------------------------- bubblewrap ----
33
32
 
34
- function ensureBubblewrap(ctx: Ctx): void {
33
+ async function ensureBubblewrap(ctx: Ctx): Promise<void> {
35
34
  const { change, echo, warn } = ctx.services.logger
36
35
  if (!bwrapSupportedOs(ctx)) return
37
36
 
@@ -58,8 +57,10 @@ function ensureBubblewrap(ctx: Ctx): void {
58
57
  }
59
58
 
60
59
  warn(`bubblewrap not installed - recommended for Codex Linux sandbox. Running: ${pmInstall} (sudo prompt may appear)`)
61
- const res = spawnSync("bash", ["-c", pmInstall], { stdio: ["inherit", "inherit", "inherit"] })
62
- if (res.status !== 0) {
60
+ const runInstaller = () =>
61
+ spawnProcess("bash", ["-c", pmInstall], { stdio: ["inherit", "inherit", "inherit"] })
62
+ const res = await (ctx.terminalLease?.withExclusive(runInstaller) ?? runInstaller())
63
+ if (res.exitCode !== 0) {
63
64
  warn(`Failed to auto-install bubblewrap. Install manually: ${pmInstall}`)
64
65
  return
65
66
  }
@@ -69,8 +70,8 @@ function ensureBubblewrap(ctx: Ctx): void {
69
70
  return
70
71
  }
71
72
 
72
- if (spawnSync("unshare", ["-Ur", "true"], { stdio: "ignore" }).status === 0) {
73
- change(`bubblewrap installed and functional (${ctx.services.deps.version("bwrap")})`)
73
+ if ((await spawnProcess("unshare", ["-Ur", "true"], { stdio: "ignore" })).exitCode === 0) {
74
+ change(`bubblewrap installed and functional (${await ctx.services.deps.version("bwrap")})`)
74
75
  } else {
75
76
  warn(
76
77
  "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"
@@ -411,7 +412,7 @@ export function marketplaceSource(marketplace: string, configFile: string): stri
411
412
  return ""
412
413
  }
413
414
 
414
- function removeLegacyDocksMarketplace(ctx: Ctx, userConfig: string): void {
415
+ async function removeLegacyDocksMarketplace(ctx: Ctx, userConfig: string): Promise<void> {
415
416
  const { change, echo, warn } = ctx.services.logger
416
417
  if (ctx.dryRun) {
417
418
  echo("[dry-run] remove legacy configured Codex Docks marketplace when personal marketplace is deployed")
@@ -422,8 +423,8 @@ function removeLegacyDocksMarketplace(ctx: Ctx, userConfig: string): void {
422
423
 
423
424
  const source = marketplaceSource("docks", userConfig)
424
425
  if (source !== "https://github.com/DocksDocks/docks.git" && source !== "DocksDocks/docks") return
425
- const res = spawnSync("codex", ["plugin", "marketplace", "remove", "docks"], { stdio: "ignore" })
426
- if (res.error === undefined && res.status === 0) {
426
+ const res = await spawnProcess("codex", ["plugin", "marketplace", "remove", "docks"], { stdio: "ignore" })
427
+ if (res.error === undefined && res.exitCode === 0) {
427
428
  change("Removed legacy configured Codex Docks marketplace; using personal marketplace file")
428
429
  ctx.nextStepTriggers.codexRestart = true
429
430
  } else {
@@ -474,13 +475,12 @@ function manualPluginRefreshCommand(sotConfigText: string): string {
474
475
  return first !== undefined ? `codex plugin add ${first}` : "codex plugin add <plugin@marketplace>"
475
476
  }
476
477
 
477
- function installedPluginIdsFromCli(): Set<string> | undefined {
478
- const result = spawnSync("codex", ["plugin", "list", "--json"], {
479
- encoding: "utf8",
478
+ async function installedPluginIdsFromCli(): Promise<Set<string> | undefined> {
479
+ const result = await spawnProcess("codex", ["plugin", "list", "--json"], {
480
480
  stdio: ["ignore", "pipe", "ignore"]
481
481
  })
482
- if (result.error !== undefined || result.status !== 0) return undefined
483
- const value = parseJson(result.stdout ?? "")
482
+ if (result.error !== undefined || result.exitCode !== 0) return undefined
483
+ const value = parseJson(result.stdout)
484
484
  if (value === undefined || !isObject(value) || !Array.isArray(value["installed"])) return undefined
485
485
  const ids = new Set<string>()
486
486
  for (const row of value["installed"]) {
@@ -490,7 +490,7 @@ function installedPluginIdsFromCli(): Set<string> | undefined {
490
490
  return ids
491
491
  }
492
492
 
493
- function syncPlugins(ctx: Ctx, sotConfigText: string): void {
493
+ async function syncPlugins(ctx: Ctx, sotConfigText: string): Promise<void> {
494
494
  const { change, clearProgress, echo, progress, verbose, warn } = ctx.services.logger
495
495
  if (ctx.dryRun) {
496
496
  echo(
@@ -520,7 +520,7 @@ function syncPlugins(ctx: Ctx, sotConfigText: string): void {
520
520
  let pluginIds = desiredPluginIds
521
521
  if (ctx.skipPluginRefresh) {
522
522
  progress("Checking installed Codex plugins...")
523
- const installedPluginIds = installedPluginIdsFromCli()
523
+ const installedPluginIds = await installedPluginIdsFromCli()
524
524
  clearProgress()
525
525
  if (installedPluginIds === undefined) {
526
526
  warn("Codex plugin inventory unavailable — falling back to the full refresh path")
@@ -533,10 +533,10 @@ function syncPlugins(ctx: Ctx, sotConfigText: string): void {
533
533
  let failed = 0
534
534
  for (const pluginId of pluginIds) {
535
535
  progress(`Updating Codex plugin ${pluginId}...`)
536
- const res = spawnSync("codex", ["plugin", "add", pluginId], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] })
536
+ const res = await spawnProcess("codex", ["plugin", "add", pluginId], { stdio: ["ignore", "pipe", "pipe"] })
537
537
  clearProgress()
538
- const addOut = `${res.stdout ?? ""}${res.stderr ?? ""}`
539
- if (res.error === undefined && res.status === 0) {
538
+ const addOut = `${res.stdout}${res.stderr}`
539
+ if (res.error === undefined && res.exitCode === 0) {
540
540
  refreshed++
541
541
  } else if (addOut.includes("could not find a Codex CLI binary")) {
542
542
  warn(