infra-kit 0.1.102 → 0.1.105

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 (127) hide show
  1. package/.eslintcache +1 -1
  2. package/.omc/state/agent-replay-0a58307d-2a37-4c69-851c-83a646502d62.jsonl +1 -0
  3. package/.omc/state/agent-replay-11c41aa0-51fa-49e1-a1dc-26dcd6ac26cc.jsonl +16 -0
  4. package/.omc/state/agent-replay-4cf1c186-81b2-497c-b002-d7f84e7839f3.jsonl +9 -0
  5. package/.omc/state/agent-replay-5c4ab554-64f1-42ae-83e3-21e0237e955c.jsonl +11 -0
  6. package/.omc/state/agent-replay-a60ac2ec-afbd-449f-a540-6df287392fc2.jsonl +1 -0
  7. package/.omc/state/agent-replay-be37e426-6fc8-47f4-8178-221c8494551c.jsonl +3 -0
  8. package/.omc/state/agent-replay-c967c819-3d1c-447b-ab48-56a8448ef9f8.jsonl +2 -0
  9. package/.omc/state/idle-notif-cooldown.json +3 -0
  10. package/.omc/state/last-tool-error.json +4 -4
  11. package/.omc/state/mission-state.json +53 -0
  12. package/.omc/state/sessions/0a58307d-2a37-4c69-851c-83a646502d62/pre-tool-advisory-throttle.json +18 -0
  13. package/.omc/state/sessions/0a58307d-2a37-4c69-851c-83a646502d62/subagent-tracking-state.json +7 -0
  14. package/.omc/state/sessions/11c41aa0-51fa-49e1-a1dc-26dcd6ac26cc/last-tool-error-state.json +7 -0
  15. package/.omc/state/sessions/11c41aa0-51fa-49e1-a1dc-26dcd6ac26cc/mission-state.json +117 -0
  16. package/.omc/state/sessions/11c41aa0-51fa-49e1-a1dc-26dcd6ac26cc/pre-tool-advisory-throttle.json +42 -0
  17. package/.omc/state/sessions/11c41aa0-51fa-49e1-a1dc-26dcd6ac26cc/subagent-tracking-state.json +53 -0
  18. package/.omc/state/sessions/4cf1c186-81b2-497c-b002-d7f84e7839f3/last-tool-error-state.json +7 -0
  19. package/.omc/state/sessions/4cf1c186-81b2-497c-b002-d7f84e7839f3/pre-tool-advisory-throttle.json +18 -0
  20. package/.omc/state/sessions/4cf1c186-81b2-497c-b002-d7f84e7839f3/subagent-tracking-state.json +7 -0
  21. package/.omc/state/sessions/5c4ab554-64f1-42ae-83e3-21e0237e955c/mission-state.json +117 -0
  22. package/.omc/state/sessions/5c4ab554-64f1-42ae-83e3-21e0237e955c/pre-tool-advisory-throttle.json +18 -0
  23. package/.omc/state/sessions/5c4ab554-64f1-42ae-83e3-21e0237e955c/subagent-tracking-state.json +17 -0
  24. package/.omc/state/sessions/a60ac2ec-afbd-449f-a540-6df287392fc2/pre-tool-advisory-throttle.json +18 -0
  25. package/.omc/state/sessions/a60ac2ec-afbd-449f-a540-6df287392fc2/subagent-tracking-state.json +7 -0
  26. package/.omc/state/sessions/c967c819-3d1c-447b-ab48-56a8448ef9f8/pre-tool-advisory-throttle.json +10 -0
  27. package/.omc/state/sessions/c967c819-3d1c-447b-ab48-56a8448ef9f8/subagent-tracking-state.json +7 -0
  28. package/.omc/state/subagent-tracking.json +14 -4
  29. package/.turbo/turbo-build.log +7 -0
  30. package/.turbo/turbo-check.log +14 -0
  31. package/.turbo/turbo-prettier-fix.log +2 -1
  32. package/.turbo/turbo-test.log +28 -5
  33. package/.turbo/turbo-validate.log +14 -0
  34. package/dist/cli.js +81 -74
  35. package/dist/cli.js.map +4 -4
  36. package/dist/entry/index.d.ts +2 -0
  37. package/dist/index.js +2 -0
  38. package/dist/index.js.map +7 -0
  39. package/dist/lib/package-config/package-config.d.ts +71 -0
  40. package/dist/mcp.js +43 -41
  41. package/dist/mcp.js.map +4 -4
  42. package/eslint.config.js +1 -1
  43. package/infra-kit.config.ts +5 -0
  44. package/package.json +20 -13
  45. package/scripts/build.js +32 -3
  46. package/src/.omc/state/sessions/0a58307d-2a37-4c69-851c-83a646502d62/pre-tool-advisory-throttle.json +18 -0
  47. package/src/commands/.omc/state/sessions/c967c819-3d1c-447b-ab48-56a8448ef9f8/pre-tool-advisory-throttle.json +18 -0
  48. package/src/commands/audit/__tests__/audit.test.ts +59 -0
  49. package/src/commands/audit/audit.ts +177 -0
  50. package/src/commands/audit/index.ts +1 -0
  51. package/src/commands/config/config.ts +49 -7
  52. package/src/commands/doctor/doctor.ts +3 -3
  53. package/src/commands/env-clear/env-clear.ts +1 -1
  54. package/src/commands/env-list/env-list.ts +3 -3
  55. package/src/commands/env-load/env-load.ts +1 -1
  56. package/src/commands/env-status/env-status.ts +1 -1
  57. package/src/commands/gh-merge-dev/gh-merge-dev.ts +3 -8
  58. package/src/commands/gh-release-deliver/gh-release-deliver.ts +47 -21
  59. package/src/commands/gh-release-deploy-all/gh-release-deploy-all.ts +13 -7
  60. package/src/commands/gh-release-deploy-selected/gh-release-deploy-selected.ts +12 -6
  61. package/src/commands/gh-release-list/gh-release-list.ts +19 -8
  62. package/src/commands/init/__tests__/migrate-config.test.ts +160 -0
  63. package/src/commands/init/init.ts +48 -35
  64. package/src/commands/init/migrate-config.ts +146 -0
  65. package/src/commands/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
  66. package/src/commands/release-create/__tests__/release-create.test.ts +55 -0
  67. package/src/commands/release-create/release-create.ts +142 -38
  68. package/src/commands/release-desc-edit/release-desc-edit.ts +28 -8
  69. package/src/commands/version/version.ts +1 -1
  70. package/src/commands/worktrees-add/worktrees-add.ts +7 -12
  71. package/src/commands/worktrees-list/worktrees-list.ts +13 -5
  72. package/src/commands/worktrees-open/worktrees-open.ts +1 -1
  73. package/src/commands/worktrees-remove/worktrees-remove.ts +6 -10
  74. package/src/commands/worktrees-sync/worktrees-sync.ts +3 -5
  75. package/src/entry/cli.ts +49 -6
  76. package/src/entry/index.ts +5 -0
  77. package/src/integrations/cmux/open-workspace-with-layout.ts +4 -4
  78. package/src/integrations/cmux/workspace-title.ts +10 -4
  79. package/src/integrations/doppler/doppler-project.ts +1 -1
  80. package/src/integrations/gh/gh-release-prs/__tests__/gh-release-prs.test.ts +115 -0
  81. package/src/integrations/gh/gh-release-prs/gh-release-prs.ts +49 -32
  82. package/src/lib/.omc/state/sessions/a60ac2ec-afbd-449f-a540-6df287392fc2/pre-tool-advisory-throttle.json +14 -0
  83. package/src/lib/constants/index.ts +15 -0
  84. package/src/lib/git-utils/__tests__/git-utils.test.ts +49 -0
  85. package/src/lib/git-utils/git-utils.ts +3 -1
  86. package/src/lib/infra-kit-config/__tests__/infra-kit-config.test.ts +270 -0
  87. package/src/lib/infra-kit-config/index.ts +7 -1
  88. package/src/lib/infra-kit-config/infra-kit-config.ts +46 -28
  89. package/src/lib/package-config/__tests__/package-config.test.ts +95 -0
  90. package/src/lib/package-config/index.ts +3 -0
  91. package/src/lib/package-config/package-config-schema.ts +19 -0
  92. package/src/lib/package-config/package-config.ts +99 -0
  93. package/src/lib/package-validator/__tests__/package-validator.test.ts +263 -0
  94. package/src/lib/package-validator/checks/__tests__/checks.test.ts +130 -0
  95. package/src/lib/package-validator/checks/config-check.ts +30 -0
  96. package/src/lib/package-validator/checks/files-check.ts +29 -0
  97. package/src/lib/package-validator/checks/index.ts +4 -0
  98. package/src/lib/package-validator/checks/scripts-check.ts +23 -0
  99. package/src/lib/package-validator/checks/turbo-check.ts +47 -0
  100. package/src/lib/package-validator/fs-utils.ts +18 -0
  101. package/src/lib/package-validator/index.ts +3 -0
  102. package/src/lib/package-validator/loader/config-loader.ts +77 -0
  103. package/src/lib/package-validator/loader/index.ts +2 -0
  104. package/src/lib/package-validator/loader/package-discovery.ts +98 -0
  105. package/src/lib/package-validator/package-validator.ts +48 -0
  106. package/src/lib/package-validator/types.ts +15 -0
  107. package/src/lib/release-id/__tests__/release-id.test.ts +351 -0
  108. package/src/lib/release-id/__tests__/versioned-regression.test.ts +69 -0
  109. package/src/lib/release-id/index.ts +15 -0
  110. package/src/lib/release-id/release-id.ts +257 -0
  111. package/src/lib/release-utils/__tests__/release-utils.test.ts +122 -0
  112. package/src/lib/release-utils/index.ts +4 -0
  113. package/src/lib/release-utils/release-utils.ts +85 -17
  114. package/src/lib/version-utils/__tests__/load-existing-versions.test.ts +37 -0
  115. package/src/lib/version-utils/__tests__/next-version.test.ts +119 -13
  116. package/src/lib/version-utils/index.ts +3 -0
  117. package/src/lib/version-utils/load-existing-versions.ts +29 -10
  118. package/src/lib/version-utils/next-version.ts +67 -12
  119. package/src/lib/version-utils/version-utils.ts +13 -4
  120. package/src/mcp/tools/index.ts +2 -0
  121. package/src/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
  122. package/src/types.ts +1 -1
  123. package/tsconfig.tsbuildinfo +1 -1
  124. package/src/lib/__tests__/infra-kit-config.test.ts +0 -231
  125. /package/src/integrations/{clickup → linear}/.gitkeep +0 -0
  126. /package/src/lib/{__tests__ → constants/__tests__}/constants.test.ts +0 -0
  127. /package/src/lib/{constants.ts → constants/constants.ts} +0 -0
@@ -0,0 +1,49 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest'
2
+
3
+ import { getCurrentWorktrees } from 'src/lib/git-utils'
4
+
5
+ const worktreeList = vi.hoisted(() => {
6
+ return { stdout: '' }
7
+ })
8
+
9
+ vi.mock('zx', () => {
10
+ return {
11
+ $: vi.fn(() => {
12
+ return Promise.resolve(worktreeList)
13
+ }),
14
+ }
15
+ })
16
+
17
+ const asWorktreeLine = (branch: string): string => {
18
+ return `/repos/project-worktrees/${branch} abc1234 [${branch}]`
19
+ }
20
+
21
+ describe('getCurrentWorktrees', () => {
22
+ beforeEach(() => {
23
+ worktreeList.stdout = [
24
+ asWorktreeLine('main'),
25
+ asWorktreeLine('release/v1.18.22'),
26
+ asWorktreeLine('release/n/checkout-redesign'),
27
+ asWorktreeLine('release/garbage'),
28
+ asWorktreeLine('feature/login-page'),
29
+ '/repos/project abc1234 (bare)',
30
+ '',
31
+ ].join('\n')
32
+ })
33
+
34
+ it('returns versioned AND named release worktrees for type release', async () => {
35
+ await expect(getCurrentWorktrees('release')).resolves.toEqual(['release/v1.18.22', 'release/n/checkout-redesign'])
36
+ })
37
+
38
+ it('excludes junk release branches and non-release branches', async () => {
39
+ const branches = await getCurrentWorktrees('release')
40
+
41
+ expect(branches).not.toContain('release/garbage')
42
+ expect(branches).not.toContain('feature/login-page')
43
+ expect(branches).not.toContain('main')
44
+ })
45
+
46
+ it('returns feature worktrees for type feature', async () => {
47
+ await expect(getCurrentWorktrees('feature')).resolves.toEqual(['feature/login-page'])
48
+ })
49
+ })
@@ -1,6 +1,8 @@
1
1
  import path from 'node:path'
2
2
  import { $ } from 'zx'
3
3
 
4
+ import { isReleaseBranch } from 'src/lib/release-id'
5
+
4
6
  /**
5
7
  * Get current git worktrees
6
8
  *
@@ -60,7 +62,7 @@ const parseWorktreeBranch = (line: string): string | null => {
60
62
  const releaseWorktreePredicate = (line: string): string | null => {
61
63
  const branch = parseWorktreeBranch(line)
62
64
 
63
- return branch?.startsWith('release/v') ? branch : null
65
+ return isReleaseBranch(branch) ? branch : null
64
66
  }
65
67
 
66
68
  /**
@@ -0,0 +1,270 @@
1
+ import fs from 'node:fs'
2
+ import os from 'node:os'
3
+ import path from 'node:path'
4
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
5
+
6
+ // Import AFTER the mock is declared so the module picks up the mocked dep.
7
+ import { getProjectRoot, getRepoName } from 'src/lib/git-utils'
8
+
9
+ import { getInfraKitConfig, resetInfraKitConfigCache } from '../infra-kit-config'
10
+
11
+ vi.mock('src/lib/git-utils', () => {
12
+ return {
13
+ getProjectRoot: vi.fn(),
14
+ getRepoName: vi.fn(),
15
+ }
16
+ })
17
+
18
+ const VALID_JSON = JSON.stringify({
19
+ environments: ['dev', 'staging'],
20
+ envManagement: { provider: 'doppler', config: { name: 'my-project' } },
21
+ })
22
+
23
+ const ALTERNATE_JSON = JSON.stringify({
24
+ environments: ['dev'],
25
+ envManagement: { provider: 'doppler', config: { name: 'other-project' } },
26
+ })
27
+
28
+ const withTmpRepo = async (fn: (tmp: string) => Promise<void>): Promise<void> => {
29
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'infra-kit-config-test-'))
30
+
31
+ vi.mocked(getProjectRoot).mockResolvedValue(tmp)
32
+ vi.mocked(getRepoName).mockResolvedValue(path.basename(tmp))
33
+ // Point os.homedir() at the tmp dir so user-scope override layers
34
+ // (~/.infra-kit/config.json, ~/.infra-kit/projects/<repo>/infra-kit.json)
35
+ // can't leak the developer's real config into the test.
36
+ const homedirSpy = vi.spyOn(os, 'homedir').mockReturnValue(tmp)
37
+
38
+ resetInfraKitConfigCache()
39
+
40
+ try {
41
+ await fn(tmp)
42
+ } finally {
43
+ homedirSpy.mockRestore()
44
+ resetInfraKitConfigCache()
45
+ fs.rmSync(tmp, { recursive: true, force: true })
46
+ }
47
+ }
48
+
49
+ describe('getInfraKitConfig', () => {
50
+ beforeEach(() => {
51
+ resetInfraKitConfigCache()
52
+ })
53
+
54
+ afterEach(() => {
55
+ resetInfraKitConfigCache()
56
+ vi.clearAllMocks()
57
+ })
58
+
59
+ it('reads and validates a well-formed infra-kit.json', async () => {
60
+ await withTmpRepo(async (tmp) => {
61
+ fs.writeFileSync(path.join(tmp, 'infra-kit.json'), VALID_JSON)
62
+
63
+ const cfg = await getInfraKitConfig()
64
+
65
+ expect(cfg.envManagement.config.name).toBe('my-project')
66
+ expect(cfg.environments).toEqual(['dev', 'staging'])
67
+ expect(cfg.taskManager).toBeUndefined()
68
+ expect(cfg.ide).toBeUndefined()
69
+ })
70
+ })
71
+
72
+ it('accepts ide and taskManager when provided', async () => {
73
+ await withTmpRepo(async (tmp) => {
74
+ fs.writeFileSync(
75
+ path.join(tmp, 'infra-kit.json'),
76
+ JSON.stringify({
77
+ environments: ['dev'],
78
+ envManagement: { provider: 'doppler', config: { name: 'p' } },
79
+ ide: { provider: 'cursor', config: { mode: 'workspace', workspaceConfigPath: './ws.code-workspace' } },
80
+ taskManager: { provider: 'jira', config: { baseUrl: 'https://example.atlassian.net', projectId: 123 } },
81
+ }),
82
+ )
83
+
84
+ const cfg = await getInfraKitConfig()
85
+
86
+ expect(cfg.ide?.provider).toBe('cursor')
87
+
88
+ if (cfg.ide?.provider === 'cursor') {
89
+ expect(cfg.ide.config.mode).toBe('workspace')
90
+ expect(cfg.ide.config.workspaceConfigPath).toBe('./ws.code-workspace')
91
+ }
92
+
93
+ expect(cfg.taskManager?.provider).toBe('jira')
94
+ })
95
+ })
96
+
97
+ it('accepts a worktrees prompt-defaults block', async () => {
98
+ await withTmpRepo(async (tmp) => {
99
+ fs.writeFileSync(
100
+ path.join(tmp, 'infra-kit.json'),
101
+ JSON.stringify({
102
+ environments: ['dev'],
103
+ envManagement: { provider: 'doppler', config: { name: 'p' } },
104
+ worktrees: { openInGithubDesktop: false, openInCmux: true },
105
+ }),
106
+ )
107
+
108
+ const cfg = await getInfraKitConfig()
109
+
110
+ expect(cfg.worktrees?.openInGithubDesktop).toBe(false)
111
+ expect(cfg.worktrees?.openInCmux).toBe(true)
112
+ })
113
+ })
114
+
115
+ it('lets the user-global config layer supply a worktrees block when the project omits it', async () => {
116
+ await withTmpRepo(async (tmp) => {
117
+ fs.writeFileSync(path.join(tmp, 'infra-kit.json'), VALID_JSON)
118
+
119
+ const userGlobalDir = path.join(tmp, '.infra-kit')
120
+
121
+ fs.mkdirSync(userGlobalDir, { recursive: true })
122
+ fs.writeFileSync(
123
+ path.join(userGlobalDir, 'config.json'),
124
+ JSON.stringify({ worktrees: { openInGithubDesktop: false, openInCmux: true } }),
125
+ )
126
+
127
+ const cfg = await getInfraKitConfig()
128
+
129
+ expect(cfg.worktrees?.openInGithubDesktop).toBe(false)
130
+ expect(cfg.worktrees?.openInCmux).toBe(true)
131
+ })
132
+ })
133
+
134
+ it('treats an empty optional layer file as {}', async () => {
135
+ await withTmpRepo(async (tmp) => {
136
+ fs.writeFileSync(path.join(tmp, 'infra-kit.json'), VALID_JSON)
137
+
138
+ const userGlobalDir = path.join(tmp, '.infra-kit')
139
+
140
+ fs.mkdirSync(userGlobalDir, { recursive: true })
141
+ fs.writeFileSync(path.join(userGlobalDir, 'config.json'), ' \n')
142
+
143
+ const cfg = await getInfraKitConfig()
144
+
145
+ expect(cfg.envManagement.config.name).toBe('my-project')
146
+ })
147
+ })
148
+
149
+ it('throws a descriptive error on malformed JSON', async () => {
150
+ await withTmpRepo(async (tmp) => {
151
+ fs.writeFileSync(path.join(tmp, 'infra-kit.json'), '{ not valid json ')
152
+
153
+ await expect(getInfraKitConfig()).rejects.toThrow(/Invalid JSON in infra-kit\.json/)
154
+ })
155
+ })
156
+
157
+ it('rejects ide.cursor mode=workspace without workspaceConfigPath', async () => {
158
+ await withTmpRepo(async (tmp) => {
159
+ fs.writeFileSync(
160
+ path.join(tmp, 'infra-kit.json'),
161
+ JSON.stringify({
162
+ environments: ['dev'],
163
+ envManagement: { provider: 'doppler', config: { name: 'p' } },
164
+ ide: { provider: 'cursor', config: { mode: 'workspace' } },
165
+ }),
166
+ )
167
+
168
+ await expect(getInfraKitConfig()).rejects.toThrow(/workspaceConfigPath/)
169
+ })
170
+ })
171
+
172
+ it('throws a plain not-found error when neither infra-kit.json nor a legacy .yml exists', async () => {
173
+ await withTmpRepo(async () => {
174
+ await expect(getInfraKitConfig()).rejects.toThrow(/not found/)
175
+ await expect(getInfraKitConfig()).rejects.not.toThrow(/infra-kit init/)
176
+ })
177
+ })
178
+
179
+ it('points at `infra-kit init` when a legacy infra-kit.yml exists but infra-kit.json does not', async () => {
180
+ await withTmpRepo(async (tmp) => {
181
+ fs.writeFileSync(path.join(tmp, 'infra-kit.yml'), 'environments:\n - dev\n')
182
+
183
+ await expect(getInfraKitConfig()).rejects.toThrow(/infra-kit init/)
184
+ })
185
+ })
186
+
187
+ it('ignores a non-loaded infra-kit.example.jsonc sibling', async () => {
188
+ await withTmpRepo(async (tmp) => {
189
+ fs.writeFileSync(path.join(tmp, 'infra-kit.json'), VALID_JSON)
190
+ // Content that would FAIL schema if it were ever merged into the config.
191
+ fs.writeFileSync(path.join(tmp, 'infra-kit.example.jsonc'), '{\n // comment\n "environments": []\n}\n')
192
+
193
+ const cfg = await getInfraKitConfig()
194
+
195
+ expect(cfg.environments).toEqual(['dev', 'staging'])
196
+ expect(cfg.envManagement.config.name).toBe('my-project')
197
+ })
198
+ })
199
+
200
+ it('ignores a non-loaded config.example.jsonc in the user-global layer', async () => {
201
+ await withTmpRepo(async (tmp) => {
202
+ fs.writeFileSync(path.join(tmp, 'infra-kit.json'), VALID_JSON)
203
+
204
+ const userGlobalDir = path.join(tmp, '.infra-kit')
205
+
206
+ fs.mkdirSync(userGlobalDir, { recursive: true })
207
+ // Schema-failing content that must never be merged.
208
+ fs.writeFileSync(path.join(userGlobalDir, 'config.example.jsonc'), '{\n // comment\n "environments": []\n}\n')
209
+
210
+ const cfg = await getInfraKitConfig()
211
+
212
+ expect(cfg.environments).toEqual(['dev', 'staging'])
213
+ expect(cfg.envManagement.config.name).toBe('my-project')
214
+ })
215
+ })
216
+
217
+ it('throws when infra-kit.json is missing', async () => {
218
+ await withTmpRepo(async () => {
219
+ await expect(getInfraKitConfig()).rejects.toThrow(/not found/)
220
+ })
221
+ })
222
+
223
+ it('throws a descriptive error on schema violations', async () => {
224
+ await withTmpRepo(async (tmp) => {
225
+ fs.writeFileSync(
226
+ path.join(tmp, 'infra-kit.json'),
227
+ JSON.stringify({ environments: [], envManagement: { provider: 'doppler', config: { name: '' } } }),
228
+ )
229
+
230
+ await expect(getInfraKitConfig()).rejects.toThrow(/Invalid infra-kit\.json/)
231
+ })
232
+ })
233
+
234
+ it('re-reads the file when mtime changes (long-running MCP scenario)', async () => {
235
+ await withTmpRepo(async (tmp) => {
236
+ const jsonPath = path.join(tmp, 'infra-kit.json')
237
+
238
+ fs.writeFileSync(jsonPath, VALID_JSON)
239
+
240
+ const first = await getInfraKitConfig()
241
+
242
+ expect(first.envManagement.config.name).toBe('my-project')
243
+
244
+ // Advance mtime past the previous stat to simulate an edit; write new content.
245
+ const future = new Date(Date.now() + 2_000)
246
+
247
+ fs.writeFileSync(jsonPath, ALTERNATE_JSON)
248
+ fs.utimesSync(jsonPath, future, future)
249
+
250
+ const second = await getInfraKitConfig()
251
+
252
+ expect(second.envManagement.config.name).toBe('other-project')
253
+ expect(second.environments).toEqual(['dev'])
254
+ })
255
+ })
256
+
257
+ it('returns the cached value on repeated calls when mtime is unchanged', async () => {
258
+ await withTmpRepo(async (tmp) => {
259
+ const jsonPath = path.join(tmp, 'infra-kit.json')
260
+
261
+ fs.writeFileSync(jsonPath, VALID_JSON)
262
+
263
+ const a = await getInfraKitConfig()
264
+ const b = await getInfraKitConfig()
265
+
266
+ // Same object reference — no re-parse.
267
+ expect(a).toBe(b)
268
+ })
269
+ })
270
+ })
@@ -1,2 +1,8 @@
1
- export { getInfraKitConfig, getInfraKitConfigPaths, resetInfraKitConfigCache } from './infra-kit-config'
1
+ export {
2
+ getInfraKitConfig,
3
+ getInfraKitConfigPaths,
4
+ infraKitConfigSchema,
5
+ infraKitOverrideConfigSchema,
6
+ resetInfraKitConfigCache,
7
+ } from './infra-kit-config'
2
8
  export type { InfraKitConfig, InfraKitConfigPaths } from './infra-kit-config'
@@ -1,15 +1,14 @@
1
1
  import fs from 'node:fs/promises'
2
2
  import os from 'node:os'
3
3
  import path from 'node:path'
4
- import yaml from 'yaml'
5
- import { z } from 'zod/v4'
4
+ import { z } from 'zod'
6
5
 
7
6
  import { getProjectRoot, getRepoName } from 'src/lib/git-utils'
8
7
 
9
- const INFRA_KIT_CONFIG_FILE = 'infra-kit.yml'
8
+ const INFRA_KIT_CONFIG_FILE = 'infra-kit.json'
10
9
 
11
10
  const USER_CONFIG_DIR_NAME = '.infra-kit'
12
- const USER_GLOBAL_CONFIG_FILE = 'config.yml'
11
+ const USER_GLOBAL_CONFIG_FILE = 'config.json'
13
12
  const USER_PROJECTS_DIR = 'projects'
14
13
 
15
14
  // envManagement
@@ -62,7 +61,7 @@ const worktreesConfigSchema = z.object({
62
61
  openInCmux: z.boolean().optional(),
63
62
  })
64
63
 
65
- const infraKitConfigSchema = z.object({
64
+ export const infraKitConfigSchema = z.object({
66
65
  environments: z.array(z.string().min(1)).min(1),
67
66
  envManagement: envManagementSchema,
68
67
  ide: ideSchema.optional(),
@@ -70,7 +69,7 @@ const infraKitConfigSchema = z.object({
70
69
  worktrees: worktreesConfigSchema.optional(),
71
70
  })
72
71
 
73
- const infraKitOverrideConfigSchema = infraKitConfigSchema.partial()
72
+ export const infraKitOverrideConfigSchema = infraKitConfigSchema.partial()
74
73
 
75
74
  export type InfraKitConfig = z.infer<typeof infraKitConfigSchema>
76
75
 
@@ -79,7 +78,7 @@ export interface InfraKitConfigPaths {
79
78
  main: string
80
79
  /** User-scope global overrides applied to every project. */
81
80
  userGlobal: string
82
- /** User-scope per-project overrides — `<userProjectsDir>/<projectName>/infra-kit.yml`. */
81
+ /** User-scope per-project overrides — `<userProjectsDir>/<projectName>/infra-kit.json`. */
83
82
  userProject: string
84
83
  /** Repo basename (`path.basename(projectRoot)`) used to namespace the user-project file. */
85
84
  projectName: string
@@ -100,9 +99,9 @@ let cached: CacheEntry | null = null
100
99
  * @example
101
100
  * const paths = await getInfraKitConfigPaths()
102
101
  * // {
103
- * // main: '/Users/arthur/projects/api/infra-kit.yml',
104
- * // userGlobal: '/Users/arthur/.infra-kit/config.yml',
105
- * // userProject: '/Users/arthur/.infra-kit/projects/api/infra-kit.yml',
102
+ * // main: '/Users/arthur/projects/api/infra-kit.json',
103
+ * // userGlobal: '/Users/arthur/.infra-kit/config.json',
104
+ * // userProject: '/Users/arthur/.infra-kit/projects/api/infra-kit.json',
106
105
  * // projectName: 'api',
107
106
  * // }
108
107
  */
@@ -120,19 +119,19 @@ export const getInfraKitConfigPaths = async (): Promise<InfraKitConfigPaths> =>
120
119
  }
121
120
 
122
121
  /**
123
- * Read and validate `infra-kit.yml`, with optional override layers shallow-merged
122
+ * Read and validate `infra-kit.json`, with optional override layers shallow-merged
124
123
  * on top in this order (later wins):
125
- * 1. project `infra-kit.yml` — committed source of truth
126
- * 2. `~/.infra-kit/config.yml` — user-global defaults
127
- * 3. `~/.infra-kit/projects/<repo-name>/infra-kit.yml` — user-scope per-project overrides
124
+ * 1. project `infra-kit.json` — committed source of truth
125
+ * 2. `~/.infra-kit/config.json` — user-global defaults
126
+ * 3. `~/.infra-kit/projects/<repo-name>/infra-kit.json` — user-scope per-project overrides
128
127
  *
129
128
  * Top-level keys (entire capability sections like `ide`, `envManagement`)
130
129
  * replace wholesale. Results are cached per file mtimes so the long-running
131
130
  * MCP server picks up edits without a restart.
132
131
  *
133
132
  * @example
134
- * // infra-kit.yml: { environments: ['dev'], envManagement: { provider: 'doppler', config: { name: 'p' } } }
135
- * // ~/.infra-kit/config.yml: { ide: { provider: 'cursor', config: { mode: 'windows' } } }
133
+ * // infra-kit.json: { "environments": ["dev"], "envManagement": { "provider": "doppler", "config": { "name": "p" } } }
134
+ * // ~/.infra-kit/config.json: { "ide": { "provider": "cursor", "config": { "mode": "windows" } } }
136
135
  * const cfg = await getInfraKitConfig()
137
136
  * // => { environments: ['dev'], envManagement: {...}, ide: { provider: 'cursor', config: { mode: 'windows' } } }
138
137
  */
@@ -145,7 +144,18 @@ export const getInfraKitConfig = async (): Promise<InfraKitConfig> => {
145
144
  mainStat = await fs.stat(paths.main)
146
145
  } catch {
147
146
  cached = null
148
- throw new Error(`infra-kit.yml not found at ${paths.main}`)
147
+
148
+ // Bridge the YAML→JSON cutover: if a legacy infra-kit.yml is sitting where
149
+ // the JSON config should be, point the user at the one-shot migration.
150
+ const legacyYmlPath = paths.main.replace(/\.json$/, '.yml')
151
+
152
+ if (await statIfExists(legacyYmlPath)) {
153
+ throw new Error(
154
+ `infra-kit.json not found at ${paths.main}. A legacy infra-kit.yml exists — run \`infra-kit init\` to convert it.`,
155
+ )
156
+ }
157
+
158
+ throw new Error(`infra-kit.json not found at ${paths.main}`)
149
159
  }
150
160
 
151
161
  const [userGlobalStat, userProjectStat] = await Promise.all([
@@ -164,10 +174,10 @@ export const getInfraKitConfig = async (): Promise<InfraKitConfig> => {
164
174
  }
165
175
 
166
176
  const layers: ConfigLayer[] = [
167
- { label: 'infra-kit.yml', path: paths.main, required: true },
168
- { label: '~/.infra-kit/config.yml', path: paths.userGlobal, required: false },
177
+ { label: 'infra-kit.json', path: paths.main, required: true },
178
+ { label: '~/.infra-kit/config.json', path: paths.userGlobal, required: false },
169
179
  {
170
- label: `~/.infra-kit/projects/${paths.projectName}/infra-kit.yml`,
180
+ label: `~/.infra-kit/projects/${paths.projectName}/infra-kit.json`,
171
181
  path: paths.userProject,
172
182
  required: false,
173
183
  },
@@ -224,8 +234,8 @@ const statIfExists = async (filePath: string): Promise<Awaited<ReturnType<typeof
224
234
  * `fs.readFile` that returns `null` instead of throwing on ENOENT.
225
235
  *
226
236
  * @example
227
- * const raw = await readIfExists('/missing.yml') // => null
228
- * const raw = await readIfExists('/exists.yml') // => 'environments: [dev]\n'
237
+ * const raw = await readIfExists('/missing.json') // => null
238
+ * const raw = await readIfExists('/exists.json') // => '{ "environments": ["dev"] }\n'
229
239
  */
230
240
  const readIfExists = async (filePath: string): Promise<string | null> => {
231
241
  try {
@@ -261,17 +271,18 @@ interface ConfigLayer {
261
271
  }
262
272
 
263
273
  /**
264
- * Read a single layer of the merge chain: parse the YAML if the file exists
274
+ * Read a single layer of the merge chain: parse the JSON if the file exists
265
275
  * and validate it against the override schema. Returns `null` if an optional
266
- * layer is missing; throws if the layer is required or invalid.
276
+ * layer is missing; throws if the layer is required, malformed, or invalid.
277
+ * An empty/whitespace-only file is treated as `{}` (JSON.parse would throw).
267
278
  *
268
279
  * @example
269
- * await loadLayer({ label: '~/.infra-kit/config.yml', path: '/missing.yml', required: false })
280
+ * await loadLayer({ label: '~/.infra-kit/config.json', path: '/missing.json', required: false })
270
281
  * // => null
271
282
  *
272
283
  * @example
273
- * // /home/me/.infra-kit/config.yml: 'ide:\n provider: cursor\n config: { mode: windows }'
274
- * await loadLayer({ label: '~/.infra-kit/config.yml', path: '/home/me/.infra-kit/config.yml', required: false })
284
+ * // /home/me/.infra-kit/config.json: '{ "ide": { "provider": "cursor", "config": { "mode": "windows" } } }'
285
+ * await loadLayer({ label: '~/.infra-kit/config.json', path: '/home/me/.infra-kit/config.json', required: false })
275
286
  * // => { ide: { provider: 'cursor', config: { mode: 'windows' } } }
276
287
  */
277
288
  const loadLayer = async (layer: ConfigLayer): Promise<Record<string, unknown> | null> => {
@@ -285,7 +296,14 @@ const loadLayer = async (layer: ConfigLayer): Promise<Record<string, unknown> |
285
296
  return null
286
297
  }
287
298
 
288
- const parsedRaw = yaml.parse(raw) ?? {}
299
+ let parsedRaw: unknown
300
+
301
+ try {
302
+ parsedRaw = raw.trim() === '' ? {} : JSON.parse(raw)
303
+ } catch (err) {
304
+ throw new Error(`Invalid JSON in ${layer.label} at ${layer.path}: ${(err as Error).message}`)
305
+ }
306
+
289
307
  const result = infraKitOverrideConfigSchema.safeParse(parsedRaw)
290
308
 
291
309
  if (!result.success) {
@@ -0,0 +1,95 @@
1
+ import { describe, expect, it } from 'vitest'
2
+
3
+ import { DEFAULT_RULES, ROOT_DEFAULT_RULES, defineConfig, resolvePackageConfig } from '../package-config'
4
+ import { packageConfigSchema } from '../package-config-schema'
5
+
6
+ describe('defineConfig', () => {
7
+ it('returns an object input unchanged (identity)', () => {
8
+ const input = { requiredScripts: ['build'] }
9
+
10
+ expect(defineConfig(input)).toBe(input)
11
+ })
12
+
13
+ it('returns a factory input unchanged so the loader can resolve it', () => {
14
+ const factory = () => {
15
+ return { requiredFiles: ['a.txt'] }
16
+ }
17
+
18
+ expect(defineConfig(factory)).toBe(factory)
19
+ })
20
+ })
21
+
22
+ describe('resolvePackageConfig', () => {
23
+ it('falls back to defaults for every unset key', () => {
24
+ const rules = resolvePackageConfig({})
25
+
26
+ expect(rules.requiredScripts).toEqual(DEFAULT_RULES.requiredScripts)
27
+ expect(rules.requiredFiles).toEqual(DEFAULT_RULES.requiredFiles)
28
+ })
29
+
30
+ it('replaces a key wholesale when provided, including an empty array opt-out', () => {
31
+ const rules = resolvePackageConfig({ requiredScripts: [], requiredFiles: ['serverless.common.yml'] })
32
+
33
+ expect(rules.requiredScripts).toEqual([])
34
+ expect(rules.requiredFiles).toEqual(['serverless.common.yml'])
35
+ })
36
+
37
+ it('does not share the default array reference with the resolved result', () => {
38
+ const rules = resolvePackageConfig({})
39
+
40
+ rules.requiredScripts.push('mutated')
41
+
42
+ expect(DEFAULT_RULES.requiredScripts).not.toContain('mutated')
43
+ })
44
+
45
+ it('falls back to the supplied baseline (root) for unset keys, including turbo tasks', () => {
46
+ const rules = resolvePackageConfig({}, ROOT_DEFAULT_RULES)
47
+
48
+ expect(rules.requiredScripts).toEqual(ROOT_DEFAULT_RULES.requiredScripts)
49
+ expect(rules.turboTasks).toEqual(ROOT_DEFAULT_RULES.turboTasks)
50
+ })
51
+
52
+ it('lets a config override turbo.requiredTasks', () => {
53
+ const rules = resolvePackageConfig({ turbo: { requiredTasks: ['build'] } }, ROOT_DEFAULT_RULES)
54
+
55
+ expect(rules.turboTasks).toEqual(['build'])
56
+ })
57
+
58
+ it('defaults turboTasks to an empty array for packages', () => {
59
+ const rules = resolvePackageConfig({})
60
+
61
+ expect(rules.turboTasks).toEqual([])
62
+ })
63
+ })
64
+
65
+ describe('packageConfigSchema', () => {
66
+ it('rejects unknown keys so config typos surface as errors', () => {
67
+ const result = packageConfigSchema.safeParse({ requiredScript: ['build'] })
68
+
69
+ expect(result.success).toBe(false)
70
+ })
71
+
72
+ it('rejects a non-array requiredScripts', () => {
73
+ const result = packageConfigSchema.safeParse({ requiredScripts: 'build' })
74
+
75
+ expect(result.success).toBe(false)
76
+ })
77
+
78
+ it('accepts a well-formed config', () => {
79
+ const result = packageConfigSchema.safeParse({ requiredScripts: ['build'], requiredFiles: ['tsconfig.json'] })
80
+
81
+ expect(result.success).toBe(true)
82
+ })
83
+
84
+ it('accepts a turbo.requiredTasks block', () => {
85
+ const result = packageConfigSchema.safeParse({ turbo: { requiredTasks: ['build', 'validate'] } })
86
+
87
+ expect(result.success).toBe(true)
88
+ })
89
+
90
+ it('rejects an unknown key inside turbo', () => {
91
+ const result = packageConfigSchema.safeParse({ turbo: { tasks: ['build'] } })
92
+
93
+ expect(result.success).toBe(false)
94
+ })
95
+ })
@@ -0,0 +1,3 @@
1
+ export { DEFAULT_RULES, defineConfig, resolvePackageConfig, ROOT_DEFAULT_RULES } from './package-config'
2
+ export type { InfraKitPackageConfig, InfraKitPackageConfigInput, ResolvedPackageRules } from './package-config'
3
+ export { packageConfigSchema } from './package-config-schema'
@@ -0,0 +1,19 @@
1
+ import { z } from 'zod'
2
+
3
+ /**
4
+ * Schema for the resolved (post-factory) package config object. `strictObject`
5
+ * rejects unknown keys so typos in `infra-kit.config.ts` surface as validation
6
+ * errors instead of being silently ignored.
7
+ *
8
+ * Kept in its own module — separate from the public `defineConfig`/types entry —
9
+ * so the published `infra-kit` type surface stays free of a `zod` import.
10
+ */
11
+ export const packageConfigSchema = z.strictObject({
12
+ requiredScripts: z.array(z.string().min(1)).optional(),
13
+ requiredFiles: z.array(z.string().min(1)).optional(),
14
+ turbo: z
15
+ .strictObject({
16
+ requiredTasks: z.array(z.string().min(1)).optional(),
17
+ })
18
+ .optional(),
19
+ })