infra-kit 0.1.114 → 0.1.115

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (202) hide show
  1. package/.eslintcache +1 -1
  2. package/.omc/state/agent-replay-1fcfe0ec-7567-41c3-9bfd-501e99e86819.jsonl +19 -0
  3. package/.omc/state/agent-replay-38382db5-3088-4b5a-aff7-2561a60827c7.jsonl +17 -0
  4. package/.omc/state/agent-replay-50c36be9-a3a2-42b9-9c85-5a06d3e22f6a.jsonl +5 -0
  5. package/.omc/state/agent-replay-8198b3e5-fad6-4ee0-9c59-ef773ad89af4.jsonl +1 -0
  6. package/.omc/state/agent-replay-8cd7652a-b8b5-49cf-9b1f-bee8e5972458.jsonl +3 -0
  7. package/.omc/state/agent-replay-9bea518f-a9ee-4d14-bb53-a936ebaf647c.jsonl +7 -0
  8. package/.omc/state/agent-replay-b52d89fb-0e24-4901-bc66-27e3c1697126.jsonl +2 -0
  9. package/.omc/state/agent-replay-f581a1ae-0ee4-48a3-96f3-ce31a0633bb0.jsonl +1 -0
  10. package/.omc/state/agent-replay-fe1057b4-9a25-4e8e-8c3f-3e11652ee697.jsonl +5 -0
  11. package/.omc/state/idle-notif-cooldown.json +1 -1
  12. package/.omc/state/sessions/1fcfe0ec-7567-41c3-9bfd-501e99e86819/last-tool-error-state.json +7 -0
  13. package/.omc/state/sessions/1fcfe0ec-7567-41c3-9bfd-501e99e86819/mission-state.json +109 -0
  14. package/.omc/state/sessions/1fcfe0ec-7567-41c3-9bfd-501e99e86819/pre-tool-advisory-throttle.json +42 -0
  15. package/.omc/state/sessions/1fcfe0ec-7567-41c3-9bfd-501e99e86819/ralph-state.json +13 -0
  16. package/.omc/state/sessions/1fcfe0ec-7567-41c3-9bfd-501e99e86819/skill-active-state.json +15 -0
  17. package/.omc/state/sessions/1fcfe0ec-7567-41c3-9bfd-501e99e86819/subagent-tracking-state.json +62 -0
  18. package/.omc/state/sessions/1fcfe0ec-7567-41c3-9bfd-501e99e86819/ultrawork-state.json +11 -0
  19. package/.omc/state/sessions/23d0ff82-17e5-428b-8743-38f7979bb6f3/last-tool-error-state.json +7 -0
  20. package/.omc/state/sessions/23d0ff82-17e5-428b-8743-38f7979bb6f3/pre-tool-advisory-throttle.json +18 -0
  21. package/.omc/state/sessions/38382db5-3088-4b5a-aff7-2561a60827c7/last-tool-error-state.json +7 -0
  22. package/.omc/state/sessions/38382db5-3088-4b5a-aff7-2561a60827c7/mission-state.json +145 -0
  23. package/.omc/state/sessions/38382db5-3088-4b5a-aff7-2561a60827c7/pre-tool-advisory-throttle.json +38 -0
  24. package/.omc/state/sessions/38382db5-3088-4b5a-aff7-2561a60827c7/ralplan-state.json +11 -0
  25. package/.omc/state/sessions/38382db5-3088-4b5a-aff7-2561a60827c7/subagent-tracking-state.json +98 -0
  26. package/.omc/state/sessions/41336850-028c-476b-be1a-2164e1e46cac/last-tool-error-state.json +7 -0
  27. package/.omc/state/sessions/41336850-028c-476b-be1a-2164e1e46cac/pre-tool-advisory-throttle.json +18 -0
  28. package/.omc/state/sessions/50c36be9-a3a2-42b9-9c85-5a06d3e22f6a/mission-state.json +61 -0
  29. package/.omc/state/sessions/50c36be9-a3a2-42b9-9c85-5a06d3e22f6a/pre-tool-advisory-throttle.json +22 -0
  30. package/.omc/state/sessions/50c36be9-a3a2-42b9-9c85-5a06d3e22f6a/skill-active-state.json +15 -0
  31. package/.omc/state/sessions/50c36be9-a3a2-42b9-9c85-5a06d3e22f6a/subagent-tracking-state.json +26 -0
  32. package/.omc/state/sessions/8198b3e5-fad6-4ee0-9c59-ef773ad89af4/pre-tool-advisory-throttle.json +14 -0
  33. package/.omc/state/sessions/8198b3e5-fad6-4ee0-9c59-ef773ad89af4/subagent-tracking-state.json +17 -0
  34. package/.omc/state/sessions/8cd7652a-b8b5-49cf-9b1f-bee8e5972458/pre-tool-advisory-throttle.json +18 -0
  35. package/.omc/state/sessions/8cd7652a-b8b5-49cf-9b1f-bee8e5972458/ralplan-state.json +11 -0
  36. package/.omc/state/sessions/8cd7652a-b8b5-49cf-9b1f-bee8e5972458/subagent-tracking-state.json +35 -0
  37. package/.omc/state/sessions/9bea518f-a9ee-4d14-bb53-a936ebaf647c/last-tool-error-state.json +7 -0
  38. package/.omc/state/sessions/9bea518f-a9ee-4d14-bb53-a936ebaf647c/mission-state.json +79 -0
  39. package/.omc/state/sessions/9bea518f-a9ee-4d14-bb53-a936ebaf647c/pre-tool-advisory-throttle.json +22 -0
  40. package/.omc/state/sessions/9bea518f-a9ee-4d14-bb53-a936ebaf647c/progress.txt +7 -0
  41. package/.omc/state/sessions/9bea518f-a9ee-4d14-bb53-a936ebaf647c/skill-active-state.json +15 -0
  42. package/.omc/state/sessions/9bea518f-a9ee-4d14-bb53-a936ebaf647c/subagent-tracking-state.json +26 -0
  43. package/.omc/state/sessions/b52d89fb-0e24-4901-bc66-27e3c1697126/pre-tool-advisory-throttle.json +14 -0
  44. package/.omc/state/sessions/b52d89fb-0e24-4901-bc66-27e3c1697126/subagent-tracking-state.json +17 -0
  45. package/.omc/state/sessions/c513ef38-cc3e-4a29-9c4c-1e0137245dd5/pre-tool-advisory-throttle.json +18 -0
  46. package/.omc/state/sessions/ed3e2cfb-a666-4bb1-bcc1-569d5d0b837a/pre-tool-advisory-throttle.json +18 -0
  47. package/.omc/state/sessions/f581a1ae-0ee4-48a3-96f3-ce31a0633bb0/pre-tool-advisory-throttle.json +18 -0
  48. package/.omc/state/sessions/f581a1ae-0ee4-48a3-96f3-ce31a0633bb0/subagent-tracking-state.json +17 -0
  49. package/.omc/state/sessions/fe1057b4-9a25-4e8e-8c3f-3e11652ee697/last-tool-error-state.json +7 -0
  50. package/.omc/state/sessions/fe1057b4-9a25-4e8e-8c3f-3e11652ee697/mission-state.json +61 -0
  51. package/.omc/state/sessions/fe1057b4-9a25-4e8e-8c3f-3e11652ee697/prd.json +22 -0
  52. package/.omc/state/sessions/fe1057b4-9a25-4e8e-8c3f-3e11652ee697/pre-tool-advisory-throttle.json +22 -0
  53. package/.omc/state/sessions/fe1057b4-9a25-4e8e-8c3f-3e11652ee697/progress.txt +22 -0
  54. package/.omc/state/sessions/fe1057b4-9a25-4e8e-8c3f-3e11652ee697/skill-active-state.json +15 -0
  55. package/.omc/state/sessions/fe1057b4-9a25-4e8e-8c3f-3e11652ee697/subagent-tracking-state.json +26 -0
  56. package/.turbo/turbo-build.log +3 -3
  57. package/.turbo/turbo-test.log +92 -44
  58. package/dist/boot-K4QUAFSP.js +2 -0
  59. package/dist/boot-K4QUAFSP.js.map +7 -0
  60. package/dist/chunk-RTSP2FCA.js +2 -0
  61. package/dist/chunk-RTSP2FCA.js.map +7 -0
  62. package/dist/chunk-VPBZR2PY.js +166 -0
  63. package/dist/chunk-VPBZR2PY.js.map +7 -0
  64. package/dist/cli.js +11 -107
  65. package/dist/cli.js.map +4 -4
  66. package/dist/entry/index.d.ts +2 -0
  67. package/dist/index.js +1 -1
  68. package/dist/index.js.map +4 -4
  69. package/dist/lib/vendor/config-schema.d.ts +58 -0
  70. package/dist/mcp.js +1 -54
  71. package/dist/mcp.js.map +4 -4
  72. package/eslint.config.js +33 -1
  73. package/package.json +5 -1
  74. package/scripts/build.js +11 -1
  75. package/src/.omc/state/agent-replay-c513ef38-cc3e-4a29-9c4c-1e0137245dd5.jsonl +4 -0
  76. package/src/.omc/state/agent-replay-ed3e2cfb-a666-4bb1-bcc1-569d5d0b837a.jsonl +2 -0
  77. package/src/.omc/state/idle-notif-cooldown.json +1 -1
  78. package/src/.omc/state/sessions/c513ef38-cc3e-4a29-9c4c-1e0137245dd5/mission-state.json +79 -0
  79. package/src/.omc/state/sessions/c513ef38-cc3e-4a29-9c4c-1e0137245dd5/pre-tool-advisory-throttle.json +22 -0
  80. package/src/.omc/state/sessions/c513ef38-cc3e-4a29-9c4c-1e0137245dd5/subagent-tracking-state.json +26 -0
  81. package/src/.omc/state/sessions/ed3e2cfb-a666-4bb1-bcc1-569d5d0b837a/pre-tool-advisory-throttle.json +18 -0
  82. package/src/.omc/state/sessions/ed3e2cfb-a666-4bb1-bcc1-569d5d0b837a/ralph-state.json +13 -0
  83. package/src/.omc/state/sessions/ed3e2cfb-a666-4bb1-bcc1-569d5d0b837a/ultrawork-state.json +11 -0
  84. package/src/commands/config/.omc/state/sessions/41336850-028c-476b-be1a-2164e1e46cac/last-tool-error-state.json +7 -0
  85. package/src/commands/config/.omc/state/sessions/41336850-028c-476b-be1a-2164e1e46cac/pre-tool-advisory-throttle.json +18 -0
  86. package/src/commands/config/config.ts +7 -37
  87. package/src/commands/doctor/__tests__/agent-files.test.ts +8 -30
  88. package/src/commands/doctor/__tests__/check-legacy-user-global-config.test.ts +110 -0
  89. package/src/commands/doctor/doctor.ts +104 -44
  90. package/src/commands/env-autoload/env-autoload.ts +12 -0
  91. package/src/commands/env-autoload/index.ts +1 -0
  92. package/src/commands/env-clear/__tests__/env-clear.test.ts +27 -0
  93. package/src/commands/env-clear/env-clear.ts +22 -8
  94. package/src/commands/env-load/__tests__/env-load.test.ts +36 -1
  95. package/src/commands/env-load/env-load.ts +152 -35
  96. package/src/commands/env-load/index.ts +2 -1
  97. package/src/commands/env-status/__tests__/env-status.test.ts +78 -0
  98. package/src/commands/env-status/env-status.ts +19 -7
  99. package/src/commands/gh-release-deploy-all/gh-release-deploy-all.ts +45 -1
  100. package/src/commands/gh-release-deploy-selected/gh-release-deploy-selected.ts +46 -1
  101. package/src/commands/init/__tests__/agent-files.test.ts +145 -27
  102. package/src/commands/init/__tests__/migrate-config.test.ts +136 -5
  103. package/src/commands/init/__tests__/migrate-factory-config.test.ts +93 -0
  104. package/src/commands/init/__tests__/seed-user-global-config.test.ts +64 -0
  105. package/src/commands/init/__tests__/shell-body.test.ts +30 -0
  106. package/src/commands/init/agent-files.ts +65 -45
  107. package/src/commands/init/init.ts +140 -31
  108. package/src/commands/init/migrate-config.ts +160 -32
  109. package/src/commands/vendor-check/__tests__/runtime-isolation.test.ts +71 -0
  110. package/src/commands/vendor-check/__tests__/vendor-check.test.ts +87 -0
  111. package/src/commands/vendor-check/index.ts +1 -0
  112. package/src/commands/vendor-check/vendor-check.ts +127 -0
  113. package/src/commands/vendor-config/__tests__/vendor-config.test.ts +103 -0
  114. package/src/commands/vendor-config/index.ts +1 -0
  115. package/src/commands/vendor-config/vendor-config.ts +162 -0
  116. package/src/commands/vendor-diff/__tests__/vendor-diff.test.ts +74 -0
  117. package/src/commands/vendor-diff/index.ts +1 -0
  118. package/src/commands/vendor-diff/vendor-diff.ts +77 -0
  119. package/src/commands/vendor-manifest/index.ts +1 -0
  120. package/src/commands/vendor-manifest/vendor-manifest.ts +60 -0
  121. package/src/commands/vendor-sync/__tests__/vendor-write.test.ts +86 -0
  122. package/src/commands/vendor-sync/index.ts +1 -0
  123. package/src/commands/vendor-sync/vendor-sync.ts +90 -0
  124. package/src/commands/worktrees-add/worktrees-add.ts +1 -1
  125. package/src/commands/worktrees-reload/worktrees-reload.ts +6 -5
  126. package/src/commands/worktrees-remove/worktrees-remove.ts +10 -1
  127. package/src/commands/worktrees-sync/worktrees-sync.ts +9 -1
  128. package/src/entry/cli.ts +417 -210
  129. package/src/entry/index.ts +7 -1
  130. package/src/integrations/cmux/__tests__/open-workspace-with-layout.test.ts +127 -0
  131. package/src/integrations/cmux/close-workspace-by-title.ts +2 -2
  132. package/src/integrations/cmux/list-workspace-titles.ts +1 -1
  133. package/src/integrations/cmux/open-workspace-with-layout.ts +15 -4
  134. package/src/integrations/cursor/open-cursor-workspace.ts +6 -11
  135. package/src/integrations/doppler/__tests__/doppler-errors.test.ts +76 -0
  136. package/src/integrations/doppler/doppler-cli-auth.ts +12 -4
  137. package/src/integrations/doppler/doppler-errors.ts +88 -0
  138. package/src/integrations/doppler/doppler-project.ts +80 -0
  139. package/src/integrations/doppler/index.ts +3 -1
  140. package/src/integrations/gh/gh-cli-auth/gh-cli-auth.ts +9 -5
  141. package/src/integrations/gh/gh-release-prs/gh-release-prs.ts +16 -8
  142. package/src/integrations/ide/__tests__/ide-facade.test.ts +49 -10
  143. package/src/integrations/ide/open-ide-workspace.ts +6 -6
  144. package/src/integrations/ide/remove-ide-worktree-folders.ts +95 -30
  145. package/src/integrations/ide/types.ts +10 -1
  146. package/src/integrations/zed/__tests__/open-zed-workspace.test.ts +1 -17
  147. package/src/integrations/zed/__tests__/reuse-zed-workspace.test.ts +86 -0
  148. package/src/integrations/zed/index.ts +1 -0
  149. package/src/integrations/zed/open-zed-workspace.ts +4 -10
  150. package/src/integrations/zed/reuse-zed-workspace.ts +57 -0
  151. package/src/lib/command-catalog/__tests__/__snapshots__/command-catalog.test.ts.snap +212 -0
  152. package/src/lib/command-catalog/__tests__/command-catalog.test.ts +193 -0
  153. package/src/lib/command-catalog/command-catalog.ts +141 -0
  154. package/src/lib/command-catalog/index.ts +2 -0
  155. package/src/lib/command-echo/__tests__/command-echo.test.ts +127 -0
  156. package/src/lib/constants/constants.ts +13 -0
  157. package/src/lib/constants/index.ts +2 -0
  158. package/src/lib/env-autoload/__tests__/env-autoload.test.ts +288 -0
  159. package/src/lib/env-autoload/env-autoload.ts +294 -0
  160. package/src/lib/env-autoload/index.ts +9 -0
  161. package/src/lib/errors/operation-error.ts +1 -1
  162. package/src/lib/infra-kit-config/__tests__/env-autoload-schema.test.ts +71 -0
  163. package/src/lib/infra-kit-config/__tests__/infra-kit-config.test.ts +77 -6
  164. package/src/lib/infra-kit-config/index.ts +9 -1
  165. package/src/lib/infra-kit-config/infra-kit-config.ts +71 -11
  166. package/src/lib/json-output/__tests__/json-output.test.ts +139 -0
  167. package/src/lib/json-output/index.ts +1 -0
  168. package/src/lib/json-output/json-output.ts +47 -0
  169. package/src/lib/managed-block/managed-block.ts +4 -4
  170. package/src/lib/path-display/index.ts +1 -0
  171. package/src/lib/path-display/path-display.ts +42 -0
  172. package/src/lib/render/__tests__/render.test.ts +48 -0
  173. package/src/lib/render/index.ts +1 -0
  174. package/src/lib/render/render.ts +24 -0
  175. package/src/lib/vendor/__tests__/config-schema.test.ts +31 -0
  176. package/src/lib/vendor/__tests__/config.test.ts +63 -0
  177. package/src/lib/vendor/__tests__/factory-config.test.ts +92 -0
  178. package/src/lib/vendor/__tests__/vendor-lib.test.ts +158 -0
  179. package/src/lib/vendor/__tests__/zx-mock.ts +32 -0
  180. package/src/lib/vendor/config-schema.ts +78 -0
  181. package/src/lib/vendor/config.ts +58 -0
  182. package/src/lib/vendor/factory-config-schema.ts +35 -0
  183. package/src/lib/vendor/factory-config.ts +91 -0
  184. package/src/lib/vendor/hash.ts +16 -0
  185. package/src/lib/vendor/index.ts +23 -0
  186. package/src/lib/vendor/manifest.ts +151 -0
  187. package/src/lib/vendor/skip-sets.ts +48 -0
  188. package/src/lib/vendor/sync-ops.ts +290 -0
  189. package/src/lib/vendor/walk.ts +56 -0
  190. package/src/mcp/tools/index.ts +5 -42
  191. package/src/sanity.test.tsx +1 -0
  192. package/src/tui/__tests__/no-react-boundary.test.ts +51 -0
  193. package/src/tui/boot.tsx +45 -0
  194. package/src/tui/screens/__tests__/command-palette.test.tsx +136 -0
  195. package/src/tui/screens/command-palette.tsx +159 -0
  196. package/src/tui/types.ts +8 -0
  197. package/tsconfig.json +1 -0
  198. package/tsconfig.tsbuildinfo +1 -1
  199. package/vitest.config.ts +8 -0
  200. package/src/commands/worktrees-open/__tests__/open-cmux.test.ts +0 -96
  201. package/src/commands/worktrees-open/index.ts +0 -1
  202. package/src/commands/worktrees-open/worktrees-open.ts +0 -195
@@ -6,7 +6,12 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
6
6
  // Import AFTER the mock is declared so the module picks up the mocked dep.
7
7
  import { getProjectRoot, getRepoName } from 'src/lib/git-utils'
8
8
 
9
- import { getInfraKitConfig, resetInfraKitConfigCache, resolveConfiguredIdes } from '../infra-kit-config'
9
+ import {
10
+ getInfraKitConfig,
11
+ resetInfraKitConfigCache,
12
+ resolveCmuxLayout,
13
+ resolveConfiguredIdes,
14
+ } from '../infra-kit-config'
10
15
  import type { InfraKitConfig } from '../infra-kit-config'
11
16
 
12
17
  vi.mock('src/lib/git-utils', () => {
@@ -32,7 +37,7 @@ const withTmpRepo = async (fn: (tmp: string) => Promise<void>): Promise<void> =>
32
37
  vi.mocked(getProjectRoot).mockResolvedValue(tmp)
33
38
  vi.mocked(getRepoName).mockResolvedValue(path.basename(tmp))
34
39
  // Point os.homedir() at the tmp dir so user-scope override layers
35
- // (~/.infra-kit/config.json, ~/.infra-kit/projects/<repo>/infra-kit.json)
40
+ // (~/.infra-kit/infra-kit.json, ~/.infra-kit/projects/<repo>/infra-kit.json)
36
41
  // can't leak the developer's real config into the test.
37
42
  const homedirSpy = vi.spyOn(os, 'homedir').mockReturnValue(tmp)
38
43
 
@@ -240,7 +245,7 @@ describe('getInfraKitConfig', () => {
240
245
 
241
246
  fs.mkdirSync(userGlobalDir, { recursive: true })
242
247
  fs.writeFileSync(
243
- path.join(userGlobalDir, 'config.json'),
248
+ path.join(userGlobalDir, 'infra-kit.json'),
244
249
  JSON.stringify({ worktrees: { openInGithubDesktop: false, openInCmux: true } }),
245
250
  )
246
251
 
@@ -258,7 +263,7 @@ describe('getInfraKitConfig', () => {
258
263
  const userGlobalDir = path.join(tmp, '.infra-kit')
259
264
 
260
265
  fs.mkdirSync(userGlobalDir, { recursive: true })
261
- fs.writeFileSync(path.join(userGlobalDir, 'config.json'), ' \n')
266
+ fs.writeFileSync(path.join(userGlobalDir, 'infra-kit.json'), ' \n')
262
267
 
263
268
  const cfg = await getInfraKitConfig()
264
269
 
@@ -320,7 +325,7 @@ describe('getInfraKitConfig', () => {
320
325
  })
321
326
  })
322
327
 
323
- it('ignores a non-loaded config.example.jsonc in the user-global layer', async () => {
328
+ it('ignores a non-loaded infra-kit.example.jsonc in the user-global layer', async () => {
324
329
  await withTmpRepo(async (tmp) => {
325
330
  fs.writeFileSync(path.join(tmp, 'infra-kit.json'), VALID_JSON)
326
331
 
@@ -328,7 +333,10 @@ describe('getInfraKitConfig', () => {
328
333
 
329
334
  fs.mkdirSync(userGlobalDir, { recursive: true })
330
335
  // Schema-failing content that must never be merged.
331
- fs.writeFileSync(path.join(userGlobalDir, 'config.example.jsonc'), '{\n // comment\n "environments": []\n}\n')
336
+ fs.writeFileSync(
337
+ path.join(userGlobalDir, 'infra-kit.example.jsonc'),
338
+ '{\n // comment\n "environments": []\n}\n',
339
+ )
332
340
 
333
341
  const cfg = await getInfraKitConfig()
334
342
 
@@ -431,3 +439,66 @@ describe('resolveConfiguredIdes', () => {
431
439
  expect(resolveConfiguredIdes(base)).toEqual([])
432
440
  })
433
441
  })
442
+
443
+ describe('resolveCmuxLayout', () => {
444
+ const base = {
445
+ environments: ['dev'],
446
+ envManagement: { provider: 'doppler', config: { name: 'p' } },
447
+ } as InfraKitConfig
448
+
449
+ it('defaults to two-columns when worktrees.cmux is unset', () => {
450
+ expect(resolveCmuxLayout(base)).toBe('two-columns')
451
+ })
452
+
453
+ it('defaults to two-columns when worktrees is set but cmux.layout is unset', () => {
454
+ const cfg = { ...base, worktrees: { openInCmux: true } } as InfraKitConfig
455
+
456
+ expect(resolveCmuxLayout(cfg)).toBe('two-columns')
457
+ })
458
+
459
+ it('returns the explicit two-columns layout', () => {
460
+ const cfg = { ...base, worktrees: { cmux: { layout: 'two-columns' } } } as InfraKitConfig
461
+
462
+ expect(resolveCmuxLayout(cfg)).toBe('two-columns')
463
+ })
464
+
465
+ it('returns the explicit three-pane layout', () => {
466
+ const cfg = { ...base, worktrees: { cmux: { layout: 'three-pane' } } } as InfraKitConfig
467
+
468
+ expect(resolveCmuxLayout(cfg)).toBe('three-pane')
469
+ })
470
+ })
471
+
472
+ describe('worktrees.cmux schema validation', () => {
473
+ it('accepts a valid cmux.layout and round-trips it through getInfraKitConfig', async () => {
474
+ await withTmpRepo(async (tmp) => {
475
+ fs.writeFileSync(
476
+ path.join(tmp, 'infra-kit.json'),
477
+ JSON.stringify({
478
+ environments: ['dev'],
479
+ envManagement: { provider: 'doppler', config: { name: 'p' } },
480
+ worktrees: { openInCmux: true, cmux: { layout: 'three-pane' } },
481
+ }),
482
+ )
483
+
484
+ const cfg = await getInfraKitConfig()
485
+
486
+ expect(resolveCmuxLayout(cfg)).toBe('three-pane')
487
+ })
488
+ })
489
+
490
+ it('rejects an unknown cmux.layout value', async () => {
491
+ await withTmpRepo(async (tmp) => {
492
+ fs.writeFileSync(
493
+ path.join(tmp, 'infra-kit.json'),
494
+ JSON.stringify({
495
+ environments: ['dev'],
496
+ envManagement: { provider: 'doppler', config: { name: 'p' } },
497
+ worktrees: { cmux: { layout: 'four-pane' } },
498
+ }),
499
+ )
500
+
501
+ await expect(getInfraKitConfig()).rejects.toThrow(/Invalid.*infra-kit/i)
502
+ })
503
+ })
504
+ })
@@ -4,6 +4,14 @@ export {
4
4
  infraKitConfigSchema,
5
5
  infraKitOverrideConfigSchema,
6
6
  resetInfraKitConfigCache,
7
+ resolveCmuxLayout,
7
8
  resolveConfiguredIdes,
9
+ USER_CONFIG_DIR_NAME,
10
+ } from './infra-kit-config'
11
+ export type {
12
+ CmuxLayout,
13
+ ConfiguredIde,
14
+ EnvAutoLoadConfig,
15
+ InfraKitConfig,
16
+ InfraKitConfigPaths,
8
17
  } from './infra-kit-config'
9
- export type { ConfiguredIde, InfraKitConfig, InfraKitConfigPaths } from './infra-kit-config'
@@ -3,12 +3,19 @@ import os from 'node:os'
3
3
  import path from 'node:path'
4
4
  import { z } from 'zod'
5
5
 
6
- import { getProjectRoot, getRepoName } from 'src/lib/git-utils'
6
+ import { getProjectRoot } from 'src/lib/git-utils'
7
7
 
8
8
  const INFRA_KIT_CONFIG_FILE = 'infra-kit.json'
9
9
 
10
- const USER_CONFIG_DIR_NAME = '.infra-kit'
11
- const USER_GLOBAL_CONFIG_FILE = 'config.json'
10
+ /**
11
+ * Directory under the user's home that holds all machine-local infra-kit config:
12
+ * the runtime `infra-kit.json` merge layer, per-project overrides, and the factory
13
+ * registry (`vendor.json`). Single source of truth for `.infra-kit`, reused
14
+ * by the vendor factory-config loader (no import cycle — this module imports no
15
+ * `lib/vendor` code).
16
+ */
17
+ export const USER_CONFIG_DIR_NAME = '.infra-kit'
18
+ const USER_GLOBAL_CONFIG_FILE = 'infra-kit.json'
12
19
  const USER_PROJECTS_DIR = 'projects'
13
20
 
14
21
  // envManagement
@@ -64,12 +71,40 @@ const jiraTaskManagerSchema = z.object({
64
71
 
65
72
  const taskManagerSchema = z.discriminatedUnion('provider', [jiraTaskManagerSchema])
66
73
 
74
+ // cmux pane layout for opened worktree workspaces. Named presets keep the config
75
+ // typo-proof (an enum, not a free string) and let the opener switch on a single
76
+ // value; extend the enum + the opener's switch to add a layout.
77
+ // two-columns — left | right, both full-height (default)
78
+ // three-pane — left split top/bottom + full-height right (legacy layout)
79
+ const cmuxLayouts = ['two-columns', 'three-pane'] as const
80
+
81
+ const cmuxConfigSchema = z.object({
82
+ layout: z.enum(cmuxLayouts).optional(),
83
+ })
84
+
67
85
  // worktrees prompt defaults
68
86
  const worktreesConfigSchema = z.object({
69
87
  openInGithubDesktop: z.boolean().optional(),
70
88
  openInCmux: z.boolean().optional(),
89
+ cmux: cmuxConfigSchema.optional(),
71
90
  })
72
91
 
92
+ // env auto-load: opt-in convenience that primes Doppler env when you work inside
93
+ // this project / a worktree. Absent => disabled. `trigger` selects the moment
94
+ // (pick one):
95
+ // shell-startup — when a new shell opens inside the project
96
+ // cli-invocation — before each `infra-kit` command (primes SUBSEQUENT commands)
97
+ // `config` names which environment to load. It is intentionally NOT validated
98
+ // against `environments` here: an invalid name must DISABLE the feature at
99
+ // resolve time (lib/env-autoload), never throw inside the merged-config parse and
100
+ // brick every command.
101
+ const envAutoLoadSchema = z
102
+ .object({
103
+ trigger: z.enum(['shell-startup', 'cli-invocation']),
104
+ config: z.string().min(1),
105
+ })
106
+ .strict()
107
+
73
108
  // Base object shape, kept separate so `.partial()` (which only works on a plain
74
109
  // ZodObject, not the `.superRefine`-wrapped full schema) can derive the override
75
110
  // schema from it.
@@ -79,6 +114,7 @@ const infraKitConfigObject = z.object({
79
114
  ide: idesSchema.optional(),
80
115
  taskManager: taskManagerSchema.optional(),
81
116
  worktrees: worktreesConfigSchema.optional(),
117
+ envAutoLoad: envAutoLoadSchema.optional(),
82
118
  })
83
119
 
84
120
  // Full schema = base object + a parse-time uniqueness check on the `ide` array.
@@ -110,6 +146,9 @@ export const infraKitOverrideConfigSchema = infraKitConfigObject.partial()
110
146
 
111
147
  export type InfraKitConfig = z.infer<typeof infraKitConfigSchema>
112
148
 
149
+ /** Resolved env auto-load config (`{ trigger, config }`), or `undefined` when off. */
150
+ export type EnvAutoLoadConfig = z.infer<typeof envAutoLoadSchema>
151
+
113
152
  /** A single resolved IDE entry (`{ provider, config }`). */
114
153
  export type ConfiguredIde = z.infer<typeof ideSchema>
115
154
 
@@ -131,6 +170,25 @@ export const resolveConfiguredIdes = (config: InfraKitConfig): ConfiguredIde[] =
131
170
  return Array.isArray(ide) ? ide : [ide]
132
171
  }
133
172
 
173
+ /** A cmux pane layout preset (see {@link cmuxLayouts}). */
174
+ export type CmuxLayout = (typeof cmuxLayouts)[number]
175
+
176
+ /** The layout applied when `worktrees.cmux.layout` is left unset. */
177
+ export const DEFAULT_CMUX_LAYOUT: CmuxLayout = 'two-columns'
178
+
179
+ /**
180
+ * Resolve the cmux pane layout for opened worktree workspaces, falling back to
181
+ * {@link DEFAULT_CMUX_LAYOUT} when unconfigured. The one source of truth for
182
+ * "which layout should the cmux opener build."
183
+ *
184
+ * @example
185
+ * resolveCmuxLayout({ worktrees: { cmux: { layout: 'three-pane' } } }) // => 'three-pane'
186
+ * resolveCmuxLayout({}) // => 'two-columns'
187
+ */
188
+ export const resolveCmuxLayout = (config: InfraKitConfig): CmuxLayout => {
189
+ return config.worktrees?.cmux?.layout ?? DEFAULT_CMUX_LAYOUT
190
+ }
191
+
134
192
  export interface InfraKitConfigPaths {
135
193
  /** Committed project config (required). */
136
194
  main: string
@@ -158,14 +216,16 @@ let cached: CacheEntry | null = null
158
216
  * const paths = await getInfraKitConfigPaths()
159
217
  * // {
160
218
  * // main: '/Users/arthur/projects/api/infra-kit.json',
161
- * // userGlobal: '/Users/arthur/.infra-kit/config.json',
219
+ * // userGlobal: '/Users/arthur/.infra-kit/infra-kit.json',
162
220
  * // userProject: '/Users/arthur/.infra-kit/projects/api/infra-kit.json',
163
221
  * // projectName: 'api',
164
222
  * // }
165
223
  */
166
224
  export const getInfraKitConfigPaths = async (): Promise<InfraKitConfigPaths> => {
167
225
  const projectRoot = await getProjectRoot()
168
- const projectName = await getRepoName()
226
+ // Repo name is the project-root basename (same as getRepoName), derived here to
227
+ // avoid a second `git rev-parse` — getRepoName would re-resolve the root.
228
+ const projectName = path.basename(projectRoot)
169
229
  const userConfigDir = path.join(os.homedir(), USER_CONFIG_DIR_NAME)
170
230
 
171
231
  return {
@@ -180,7 +240,7 @@ export const getInfraKitConfigPaths = async (): Promise<InfraKitConfigPaths> =>
180
240
  * Read and validate `infra-kit.json`, with optional override layers shallow-merged
181
241
  * on top in this order (later wins):
182
242
  * 1. project `infra-kit.json` — committed source of truth
183
- * 2. `~/.infra-kit/config.json` — user-global defaults
243
+ * 2. `~/.infra-kit/infra-kit.json` — user-global defaults
184
244
  * 3. `~/.infra-kit/projects/<repo-name>/infra-kit.json` — user-scope per-project overrides
185
245
  *
186
246
  * Top-level keys (entire capability sections like `ide`, `envManagement`)
@@ -189,7 +249,7 @@ export const getInfraKitConfigPaths = async (): Promise<InfraKitConfigPaths> =>
189
249
  *
190
250
  * @example
191
251
  * // infra-kit.json: { "environments": ["dev"], "envManagement": { "provider": "doppler", "config": { "name": "p" } } }
192
- * // ~/.infra-kit/config.json: { "ide": { "provider": "cursor", "config": { "workspaceConfigPath": "./ws.code-workspace" } } }
252
+ * // ~/.infra-kit/infra-kit.json: { "ide": { "provider": "cursor", "config": { "workspaceConfigPath": "./ws.code-workspace" } } }
193
253
  * const cfg = await getInfraKitConfig()
194
254
  * // => { environments: ['dev'], envManagement: {...}, ide: { provider: 'cursor', config: { workspaceConfigPath: './ws.code-workspace' } } }
195
255
  */
@@ -233,7 +293,7 @@ export const getInfraKitConfig = async (): Promise<InfraKitConfig> => {
233
293
 
234
294
  const layers: ConfigLayer[] = [
235
295
  { label: 'infra-kit.json', path: paths.main, required: true },
236
- { label: '~/.infra-kit/config.json', path: paths.userGlobal, required: false },
296
+ { label: '~/.infra-kit/infra-kit.json', path: paths.userGlobal, required: false },
237
297
  {
238
298
  label: `~/.infra-kit/projects/${paths.projectName}/infra-kit.json`,
239
299
  path: paths.userProject,
@@ -335,12 +395,12 @@ interface ConfigLayer {
335
395
  * An empty/whitespace-only file is treated as `{}` (JSON.parse would throw).
336
396
  *
337
397
  * @example
338
- * await loadLayer({ label: '~/.infra-kit/config.json', path: '/missing.json', required: false })
398
+ * await loadLayer({ label: '~/.infra-kit/infra-kit.json', path: '/missing.json', required: false })
339
399
  * // => null
340
400
  *
341
401
  * @example
342
- * // /home/me/.infra-kit/config.json: '{ "ide": { "provider": "cursor", "config": { "workspaceConfigPath": "./ws.code-workspace" } } }'
343
- * await loadLayer({ label: '~/.infra-kit/config.json', path: '/home/me/.infra-kit/config.json', required: false })
402
+ * // /home/me/.infra-kit/infra-kit.json: '{ "ide": { "provider": "cursor", "config": { "workspaceConfigPath": "./ws.code-workspace" } } }'
403
+ * await loadLayer({ label: '~/.infra-kit/infra-kit.json', path: '/home/me/.infra-kit/infra-kit.json', required: false })
344
404
  * // => { ide: { provider: 'cursor', config: { workspaceConfigPath: './ws.code-workspace' } } }
345
405
  */
346
406
  const loadLayer = async (layer: ConfigLayer): Promise<Record<string, unknown> | null> => {
@@ -0,0 +1,139 @@
1
+ import { Command } from 'commander'
2
+ import { afterEach, describe, expect, it } from 'vitest'
3
+
4
+ import { addJsonOption, emit, jsonOutput } from '../json-output'
5
+
6
+ describe('json-output', () => {
7
+ afterEach(() => {
8
+ jsonOutput.enabled = false
9
+ })
10
+
11
+ describe('emit', () => {
12
+ it('writes structuredContent as pretty JSON when enabled', () => {
13
+ jsonOutput.enabled = true
14
+ const writes: string[] = []
15
+
16
+ const result = emit({ structuredContent: { version: '1.2.3' } }, (text) => {
17
+ writes.push(text)
18
+ })
19
+
20
+ expect(writes).toHaveLength(1)
21
+ const [payload = ''] = writes
22
+
23
+ expect(JSON.parse(payload)).toEqual({ version: '1.2.3' })
24
+ // returns the result unchanged for pass-through use (emit(await handler()))
25
+ expect(result).toEqual({ structuredContent: { version: '1.2.3' } })
26
+ })
27
+
28
+ it('is a no-op when json mode is disabled', () => {
29
+ jsonOutput.enabled = false
30
+ const writes: string[] = []
31
+
32
+ emit({ structuredContent: { a: 1 } }, (text) => {
33
+ writes.push(text)
34
+ })
35
+
36
+ expect(writes).toHaveLength(0)
37
+ })
38
+
39
+ it('writes nothing when structuredContent is undefined or null, even if enabled', () => {
40
+ jsonOutput.enabled = true
41
+ const writes: string[] = []
42
+
43
+ emit(undefined, (text) => {
44
+ writes.push(text)
45
+ })
46
+ emit({}, (text) => {
47
+ writes.push(text)
48
+ })
49
+ emit({ structuredContent: null }, (text) => {
50
+ writes.push(text)
51
+ })
52
+
53
+ expect(writes).toHaveLength(0)
54
+ })
55
+ })
56
+
57
+ // Regression: `--json` must activate on grouped/nested subcommands, not just
58
+ // flat top-level commands. Commander binds a post-subcommand `--json` to the
59
+ // parent group, so the preAction hook must read `optsWithGlobals()`, not
60
+ // `opts()`. This drives the real Commander parse path end-to-end.
61
+ describe('grouped subcommand --json (regression)', () => {
62
+ const buildProgram = (writes: string[]): Command => {
63
+ const program = new Command()
64
+ const group = program.command('group')
65
+
66
+ group.command('leaf').action(() => {
67
+ emit({ structuredContent: { ok: true } }, (text) => {
68
+ writes.push(text)
69
+ })
70
+ })
71
+
72
+ program.commands.forEach(addJsonOption)
73
+ program.hook('preAction', (_thisCommand, actionCommand) => {
74
+ jsonOutput.enabled = Boolean(actionCommand.optsWithGlobals().json)
75
+ })
76
+
77
+ return program
78
+ }
79
+
80
+ it('emits JSON for `group leaf --json` (post-subcommand flag on a nested command)', async () => {
81
+ const writes: string[] = []
82
+
83
+ await buildProgram(writes).parseAsync(['node', 'cli', 'group', 'leaf', '--json'])
84
+
85
+ expect(writes).toHaveLength(1)
86
+ const [payload = ''] = writes
87
+
88
+ expect(JSON.parse(payload)).toEqual({ ok: true })
89
+ })
90
+
91
+ it('emits nothing for `group leaf` without --json', async () => {
92
+ const writes: string[] = []
93
+
94
+ await buildProgram(writes).parseAsync(['node', 'cli', 'group', 'leaf'])
95
+
96
+ expect(writes).toHaveLength(0)
97
+ })
98
+ })
99
+
100
+ describe('addJsonOption', () => {
101
+ it('registers --json on a command', () => {
102
+ const cmd = new Command('demo')
103
+
104
+ addJsonOption(cmd)
105
+
106
+ expect(
107
+ cmd.options.some((option) => {
108
+ return option.long === '--json'
109
+ }),
110
+ ).toBe(true)
111
+ })
112
+
113
+ it('is idempotent (does not double-register)', () => {
114
+ const cmd = new Command('demo')
115
+
116
+ addJsonOption(cmd)
117
+ addJsonOption(cmd)
118
+
119
+ const jsonOptions = cmd.options.filter((option) => {
120
+ return option.long === '--json'
121
+ })
122
+
123
+ expect(jsonOptions).toHaveLength(1)
124
+ })
125
+
126
+ it('registers --json on nested subcommands too', () => {
127
+ const parent = new Command('parent')
128
+ const child = parent.command('child')
129
+
130
+ addJsonOption(parent)
131
+
132
+ expect(
133
+ child.options.some((option) => {
134
+ return option.long === '--json'
135
+ }),
136
+ ).toBe(true)
137
+ })
138
+ })
139
+ })
@@ -0,0 +1 @@
1
+ export { addJsonOption, emit, jsonOutput } from './json-output'
@@ -0,0 +1,47 @@
1
+ import type { Command } from 'commander'
2
+ import process from 'node:process'
3
+
4
+ /**
5
+ * `--json` output mode for the CLI. This is a presentation concern only: every
6
+ * command handler already returns a `structuredContent` payload (the same one
7
+ * the MCP surface consumes). Human/log output goes to stderr (see lib/logger),
8
+ * so writing the structured payload to stdout never collides with it.
9
+ */
10
+
11
+ export interface CommandResult {
12
+ structuredContent?: unknown
13
+ }
14
+
15
+ /** Mutable holder (object so `prefer-const` holds while the flag toggles per run). */
16
+ export const jsonOutput = { enabled: false }
17
+
18
+ /**
19
+ * In `--json` mode, write a command result's `structuredContent` to stdout as
20
+ * pretty JSON and return the result unchanged. Outside `--json` mode it is a
21
+ * no-op pass-through. The writer is injectable for testing.
22
+ */
23
+ export const emit = <T extends CommandResult | void>(
24
+ result: T,
25
+ write: (text: string) => void = (text) => {
26
+ process.stdout.write(text)
27
+ },
28
+ ): T => {
29
+ if (jsonOutput.enabled && result && result.structuredContent != null) {
30
+ write(`${JSON.stringify(result.structuredContent, null, 2)}\n`)
31
+ }
32
+
33
+ return result
34
+ }
35
+
36
+ /** Register `--json` on a command and every nested subcommand (idempotent). */
37
+ export const addJsonOption = (cmd: Command): void => {
38
+ const alreadyHasJson = cmd.options.some((option) => {
39
+ return option.long === '--json'
40
+ })
41
+
42
+ if (!alreadyHasJson) {
43
+ cmd.option('--json', 'Output the structured result as JSON on stdout (human logs stay on stderr)')
44
+ }
45
+
46
+ cmd.commands.forEach(addJsonOption)
47
+ }
@@ -4,8 +4,8 @@
4
4
  *
5
5
  * This is the same mechanism `infra-kit init` already uses for the `~/.zshrc`
6
6
  * shell block (`# -- infra-kit:begin -- … # -- infra-kit:end --`), lifted here so
7
- * it can be reused for the repo agent-instruction files (`AGENTS.md`,
8
- * `CLAUDE.md` import region, `.cursor/rules`). It mirrors the design of OMC's
7
+ * it can be reused for the repo agent-instruction guidance block in `CLAUDE.md`.
8
+ * It mirrors the design of OMC's
9
9
  * `<!-- OMC:START --> … <!-- OMC:END -->` CLAUDE.md installer.
10
10
  */
11
11
 
@@ -64,7 +64,7 @@ export const removeManagedBlock = (content: string, startMarker: string, endMark
64
64
 
65
65
  if (startIdx === -1 || endIdx === -1 || endIdx < startIdx) return null
66
66
 
67
- // eslint-disable-next-line sonarjs/slow-regex
67
+ // eslint-disable-next-line sonarjs/super-linear-regex
68
68
  const before = content.slice(0, startIdx).replace(/\n+$/, '')
69
69
  const after = content.slice(endIdx + endMarker.length).replace(/^\n+/, '')
70
70
 
@@ -138,7 +138,7 @@ export const upsertManagedBlock = ({
138
138
  // append-end, or replace-in-place with no existing block: drop any stale block
139
139
  // then append the fresh one at end-of-file.
140
140
  const stripped = present ? (removeManagedBlock(content, startMarker, endMarker) ?? content) : content
141
- // eslint-disable-next-line sonarjs/slow-regex
141
+ // eslint-disable-next-line sonarjs/super-linear-regex
142
142
  const base = stripped.replace(/\n+$/, '')
143
143
 
144
144
  return base.length > 0 ? `${base}\n${block}\n` : `${block}\n`
@@ -0,0 +1 @@
1
+ export { fileExists, tildify } from './path-display'
@@ -0,0 +1,42 @@
1
+ import fs from 'node:fs/promises'
2
+ import os from 'node:os'
3
+
4
+ /**
5
+ * Shared path-display helpers for CLI output. Extracted from `commands/config`
6
+ * and `commands/init/migrate-config` (which each had private copies) so config
7
+ * surfaces print paths consistently. Keep this dependency-light (node builtins
8
+ * only) — it is imported by several commands.
9
+ */
10
+
11
+ /**
12
+ * Resolve whether a file is reachable, suppressing ENOENT (and any access error)
13
+ * into a boolean. Used to render `[✓]`/`[ ]` existence markers.
14
+ *
15
+ * @example
16
+ * await fileExists('/etc/hosts') // => true
17
+ * await fileExists('/nope.txt') // => false
18
+ */
19
+ export const fileExists = async (filePath: string): Promise<boolean> => {
20
+ try {
21
+ await fs.access(filePath)
22
+
23
+ return true
24
+ } catch {
25
+ return false
26
+ }
27
+ }
28
+
29
+ /**
30
+ * Replace the user's home prefix with `~` so logged paths stay short and portable
31
+ * across machines. Leaves non-home paths untouched.
32
+ *
33
+ * @example
34
+ * // os.homedir() === '/Users/arthur'
35
+ * tildify('/Users/arthur/.infra-kit/infra-kit.json') // => '~/.infra-kit/infra-kit.json'
36
+ * tildify('/etc/hosts') // => '/etc/hosts'
37
+ */
38
+ export const tildify = (filePath: string): string => {
39
+ const home = os.homedir()
40
+
41
+ return filePath.startsWith(home) ? `~${filePath.slice(home.length)}` : filePath
42
+ }
@@ -0,0 +1,48 @@
1
+ import { describe, expect, it } from 'vitest'
2
+
3
+ import { formatAlignedRows } from '../render'
4
+
5
+ describe('formatAlignedRows', () => {
6
+ it('pads every left cell to the widest left cell and joins with the default 2-space gap', () => {
7
+ expect(
8
+ formatAlignedRows([
9
+ ['a', 'x'],
10
+ ['bbb', 'y'],
11
+ ]),
12
+ ).toEqual(['a x', 'bbb y'])
13
+ })
14
+
15
+ it('honours a custom gap', () => {
16
+ expect(
17
+ formatAlignedRows(
18
+ [
19
+ ['a', 'x'],
20
+ ['bb', 'y'],
21
+ ],
22
+ ' | ',
23
+ ),
24
+ ).toEqual(['a | x', 'bb | y'])
25
+ })
26
+
27
+ it('reproduces the menu label format (name padded + 2 spaces + description)', () => {
28
+ const rows: ReadonlyArray<readonly [string, string]> = [
29
+ ['merge-dev', 'Merge dev branch into every release branch'],
30
+ ['release-list', 'List all release branches'],
31
+ ]
32
+
33
+ const width = Math.max(
34
+ ...rows.map(([left]) => {
35
+ return left.length
36
+ }),
37
+ )
38
+ const expected = rows.map(([left, right]) => {
39
+ return `${left.padEnd(width)} ${right}`
40
+ })
41
+
42
+ expect(formatAlignedRows(rows)).toEqual(expected)
43
+ })
44
+
45
+ it('returns an empty array for no rows', () => {
46
+ expect(formatAlignedRows([])).toEqual([])
47
+ })
48
+ })
@@ -0,0 +1 @@
1
+ export { formatAlignedRows } from './render'
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Small shared rendering helpers for aligned terminal output. These replace
3
+ * the ad-hoc "compute the max width, then padEnd each row" pattern that the
4
+ * interactive menu (and similar list views) hand-rolled. Pure string helpers —
5
+ * no I/O, no color — so they are trivially testable and reusable.
6
+ */
7
+
8
+ /**
9
+ * Align a list of two-column rows: pad every left cell to the widest left cell,
10
+ * then join the two cells with `gap`. Returns one rendered line per row.
11
+ *
12
+ * @example
13
+ * formatAlignedRows([['a', 'x'], ['bbb', 'y']])
14
+ * // ['a x', 'bbb y'] (left padded to width 3, default 2-space gap)
15
+ */
16
+ export const formatAlignedRows = (rows: ReadonlyArray<readonly [string, string]>, gap = ' '): string[] => {
17
+ const width = rows.reduce((max, [left]) => {
18
+ return Math.max(max, left.length)
19
+ }, 0)
20
+
21
+ return rows.map(([left, right]) => {
22
+ return `${left.padEnd(width)}${gap}${right}`
23
+ })
24
+ }