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
@@ -1,7 +1,7 @@
1
1
  import confirm from '@inquirer/confirm'
2
2
  import select from '@inquirer/select'
3
3
  import process from 'node:process'
4
- import { z } from 'zod/v4'
4
+ import { z } from 'zod'
5
5
  import { $ } from 'zx'
6
6
 
7
7
  import { getReleasePRsWithInfo } from 'src/integrations/gh'
@@ -12,7 +12,14 @@ import { formatZxError } from 'src/lib/errors/format-zx-error'
12
12
  import { OperationError } from 'src/lib/errors/operation-error'
13
13
  import { getCurrentWorktrees, getProjectRoot, getRepoName } from 'src/lib/git-utils'
14
14
  import { logger } from 'src/lib/logger'
15
- import { detectReleaseType, formatBranchChoices, getJiraDescriptions } from 'src/lib/release-utils'
15
+ import { displayLabel, formatJiraName, formatRcTitle, parseBranchName } from 'src/lib/release-id'
16
+ import type { ReleaseId } from 'src/lib/release-id'
17
+ import {
18
+ detectReleaseType,
19
+ formatBranchChoices,
20
+ getJiraDescriptions,
21
+ resolveReleaseBranch,
22
+ } from 'src/lib/release-utils'
16
23
  import type { ReleaseType } from 'src/lib/release-utils'
17
24
  import { removeWorktrees } from 'src/lib/worktrees'
18
25
  import { defineMcpTool, textContent } from 'src/types'
@@ -62,8 +69,8 @@ const fetchPRByHead = async (head: string): Promise<PRStatus | null> => {
62
69
  * the merge step on resume. Title-matched on purpose: an older MERGED RC PR
63
70
  * from a different release must not short-circuit this version's flow.
64
71
  */
65
- const fetchMergedRcPRForVersion = async (version: string): Promise<PRStatus | null> => {
66
- const expectedTitle = `Release v${version} (RC)`
72
+ const fetchMergedRcPRForVersion = async (id: ReleaseId): Promise<PRStatus | null> => {
73
+ const expectedTitle = formatRcTitle(id)
67
74
  const result = await $`gh pr list --head dev --base main --state merged --json number,state,title --limit 20`
68
75
  const prs = JSON.parse(result.stdout) as PRStatus[]
69
76
  const match = prs.find((pr) => {
@@ -92,7 +99,7 @@ interface ResolvedTarget {
92
99
  }
93
100
 
94
101
  const resolveTargetFromVersion = async (version: string): Promise<ResolvedTarget> => {
95
- const selectedReleaseBranch = `release/v${version}`
102
+ const selectedReleaseBranch = resolveReleaseBranch(version)
96
103
  const pr = await fetchPRByHead(selectedReleaseBranch)
97
104
 
98
105
  if (!pr) {
@@ -208,8 +215,9 @@ const mergeReleasePR = async (args: MergeReleasePRArgs): Promise<void> => {
208
215
  )
209
216
  }
210
217
 
211
- const resolveRcPRNumber = async (selectedVersion: string): Promise<number> => {
212
- const expectedTitle = `Release v${selectedVersion} (RC)`
218
+ const resolveRcPRNumber = async (id: ReleaseId): Promise<number> => {
219
+ const selectedLabel = displayLabel(id)
220
+ const expectedTitle = formatRcTitle(id)
213
221
  const existingOpen = await fetchOpenDevToMainPR()
214
222
 
215
223
  // Adopt any existing open dev→main PR. GitHub permits only one open PR per
@@ -221,7 +229,7 @@ const resolveRcPRNumber = async (selectedVersion: string): Promise<number> => {
221
229
 
222
230
  if (existingOpen.title !== expectedTitle) {
223
231
  logger.info(
224
- `Adopting open dev → main PR #${rcNumber} ("${existingOpen.title}") and retitling for v${selectedVersion}`,
232
+ `Adopting open dev → main PR #${rcNumber} ("${existingOpen.title}") and retitling for ${selectedLabel}`,
225
233
  )
226
234
  await runStep(
227
235
  `retitle dev → main PR #${rcNumber} to "${expectedTitle}"`,
@@ -236,7 +244,7 @@ const resolveRcPRNumber = async (selectedVersion: string): Promise<number> => {
236
244
  }
237
245
 
238
246
  await runStep(
239
- `create RC PR (dev → main) for v${selectedVersion}`,
247
+ `create RC PR (dev → main) for ${selectedLabel}`,
240
248
  `run 'gh pr create --base main --head dev' manually to surface the underlying error (e.g. no commits between dev and main)`,
241
249
  async () => {
242
250
  await $`gh pr create --base main --head dev --title ${expectedTitle} --body ""`
@@ -247,7 +255,7 @@ const resolveRcPRNumber = async (selectedVersion: string): Promise<number> => {
247
255
 
248
256
  if (!created) {
249
257
  throw new OperationError(undefined, {
250
- operation: `look up RC PR for v${selectedVersion}`,
258
+ operation: `look up RC PR for ${selectedLabel}`,
251
259
  remediation: `verify the RC PR was created ('gh pr list --head dev --base main')`,
252
260
  })
253
261
  }
@@ -255,19 +263,20 @@ const resolveRcPRNumber = async (selectedVersion: string): Promise<number> => {
255
263
  return created.number
256
264
  }
257
265
 
258
- const ensureRcPRMerged = async (selectedVersion: string): Promise<void> => {
259
- const alreadyMerged = await fetchMergedRcPRForVersion(selectedVersion)
266
+ const ensureRcPRMerged = async (id: ReleaseId): Promise<void> => {
267
+ const selectedLabel = displayLabel(id)
268
+ const alreadyMerged = await fetchMergedRcPRForVersion(id)
260
269
 
261
270
  if (alreadyMerged) {
262
- logger.info(`✓ RC PR for v${selectedVersion} already merged into main — skipping`)
271
+ logger.info(`✓ RC PR for ${selectedLabel} already merged into main — skipping`)
263
272
 
264
273
  return
265
274
  }
266
275
 
267
- const rcNumber = await resolveRcPRNumber(selectedVersion)
276
+ const rcNumber = await resolveRcPRNumber(id)
268
277
 
269
278
  await runStep(
270
- `merge RC PR #${rcNumber} (dev → main) for v${selectedVersion}`,
279
+ `merge RC PR #${rcNumber} (dev → main) for ${selectedLabel}`,
271
280
  `check 'gh pr view ${rcNumber}' for mergeability and required reviews`,
272
281
  async () => {
273
282
  await $`gh pr merge ${rcNumber} --squash --admin`
@@ -299,7 +308,7 @@ const syncMainIntoDev = async (): Promise<void> => {
299
308
  )
300
309
  }
301
310
 
302
- const deliverJiraReleaseSafely = async (selectedReleaseBranch: string): Promise<void> => {
311
+ const deliverJiraReleaseSafely = async (id: ReleaseId): Promise<void> => {
303
312
  const jiraConfig = await loadJiraConfigOptional()
304
313
 
305
314
  if (!jiraConfig) {
@@ -309,7 +318,8 @@ const deliverJiraReleaseSafely = async (selectedReleaseBranch: string): Promise<
309
318
  }
310
319
 
311
320
  try {
312
- const versionName = selectedReleaseBranch.replace('release/', '')
321
+ // Jira fix version name: `v1.2.3` | `<name>` — must match create-time formatJiraName.
322
+ const versionName = formatJiraName(id)
313
323
 
314
324
  await deliverJiraRelease({ versionName }, jiraConfig)
315
325
  } catch (error) {
@@ -333,9 +343,21 @@ export const ghReleaseDeliver = async (args: GhReleaseDeliverArgs) => {
333
343
  ? await resolveTargetFromVersion(version)
334
344
  : await resolveTargetInteractively()
335
345
 
336
- const selectedVersion = selectedReleaseBranch.replace('release/v', '')
346
+ // selectedReleaseBranch is always a release branch (operator ref strictly
347
+ // parsed, or picked from discovery-filtered choices) so this cannot be null.
348
+ const releaseId = parseBranchName(selectedReleaseBranch)
349
+
350
+ if (!releaseId) {
351
+ throw new OperationError(undefined, {
352
+ operation: `deliver release ${selectedReleaseBranch}`,
353
+ remediation: 'pass a version (e.g. "1.2.5") or a release name (e.g. "checkout-redesign")',
354
+ })
355
+ }
356
+
357
+ const selectedVersion = displayLabel(releaseId)
337
358
 
338
359
  commandEcho.addOption('--version', selectedVersion)
360
+ logger.info(`Delivering ${releaseId.kind === 'name' ? 'named release' : 'version'} ${selectedReleaseBranch}`)
339
361
 
340
362
  const releaseType: ReleaseType = detectReleaseType(releasePrTitle)
341
363
 
@@ -363,7 +385,7 @@ export const ghReleaseDeliver = async (args: GhReleaseDeliverArgs) => {
363
385
  await mergeReleasePR({ selectedReleaseBranch, releaseType })
364
386
 
365
387
  if (releaseType !== 'hotfix') {
366
- await ensureRcPRMerged(selectedVersion)
388
+ await ensureRcPRMerged(releaseId)
367
389
  }
368
390
 
369
391
  await dispatchDeployWorkflow()
@@ -371,7 +393,7 @@ export const ghReleaseDeliver = async (args: GhReleaseDeliverArgs) => {
371
393
 
372
394
  $.quiet = false
373
395
 
374
- await deliverJiraReleaseSafely(selectedReleaseBranch)
396
+ await deliverJiraReleaseSafely(releaseId)
375
397
 
376
398
  logger.info(`Successfully delivered ${selectedReleaseBranch} to production!`)
377
399
 
@@ -396,7 +418,11 @@ export const ghReleaseDeliverMcpTool = defineMcpTool({
396
418
  description:
397
419
  'Deliver a release to production. For hotfixes: squash-merges the release branch to main and dispatches the deploy-all workflow. For regular releases: squash-merges to dev, opens an RC PR, merges dev into main, dispatches the deploy-all workflow, then syncs main back to dev. Also releases the matching Jira fix version if Jira is configured. Dispatches the deploy workflow fire-and-forget — the tool returns once the workflow is accepted by GitHub, not when the deployment finishes. PR-merge steps are idempotent: re-running after a partial failure skips PRs that are already merged. Irreversible production operation: the confirmation prompt is auto-skipped for MCP calls, so the caller is responsible for gating. "version" is required when invoked via MCP (the picker is unreachable without a TTY).',
398
420
  inputSchema: {
399
- version: z.string().describe('Release version to deliver to production (e.g., "1.2.5"). Required for MCP calls.'),
421
+ version: z
422
+ .string()
423
+ .describe(
424
+ 'Accepts a release version (e.g. "1.2.5") OR a release name (e.g. "checkout-redesign") to deliver to production. Required for MCP calls.',
425
+ ),
400
426
  },
401
427
  outputSchema: {
402
428
  releaseBranch: z.string().describe('The release branch that was delivered'),
@@ -1,5 +1,5 @@
1
1
  import select from '@inquirer/select'
2
- import { z } from 'zod/v4'
2
+ import { z } from 'zod'
3
3
  import { $ } from 'zx'
4
4
 
5
5
  import { getReleasePRsWithInfo } from 'src/integrations/gh'
@@ -7,7 +7,13 @@ import { commandEcho } from 'src/lib/command-echo'
7
7
  import { OperationError } from 'src/lib/errors/operation-error'
8
8
  import { getInfraKitConfig } from 'src/lib/infra-kit-config'
9
9
  import { logger } from 'src/lib/logger'
10
- import { detectReleaseType, formatBranchChoices, getJiraDescriptions } from 'src/lib/release-utils'
10
+ import {
11
+ detectReleaseType,
12
+ formatBranchChoices,
13
+ getJiraDescriptions,
14
+ releaseLabelFromBranch,
15
+ resolveReleaseBranch,
16
+ } from 'src/lib/release-utils'
11
17
  import type { ReleaseType } from 'src/lib/release-utils'
12
18
  import { defineMcpTool, textContent } from 'src/types'
13
19
 
@@ -25,10 +31,10 @@ export const ghReleaseDeployAll = async (args: GhReleaseDeployAllArgs) => {
25
31
 
26
32
  commandEcho.start('release-deploy-all')
27
33
 
28
- let selectedReleaseBranch = '' // "release/v1.8.0"
34
+ let selectedReleaseBranch = '' // "release/v1.8.0" | "release/n/checkout-redesign" | "dev"
29
35
 
30
36
  if (version) {
31
- selectedReleaseBranch = version === 'dev' ? 'dev' : `release/v${version}`
37
+ selectedReleaseBranch = version === 'dev' ? 'dev' : resolveReleaseBranch(version)
32
38
  } else {
33
39
  commandEcho.setInteractive()
34
40
 
@@ -52,7 +58,7 @@ export const ghReleaseDeployAll = async (args: GhReleaseDeployAllArgs) => {
52
58
  })
53
59
  }
54
60
 
55
- const selectedVersion = selectedReleaseBranch === 'dev' ? 'dev' : selectedReleaseBranch.replace('release/v', '')
61
+ const selectedVersion = releaseLabelFromBranch(selectedReleaseBranch)
56
62
 
57
63
  commandEcho.addOption('--version', selectedVersion)
58
64
 
@@ -109,7 +115,7 @@ export const ghReleaseDeployAll = async (args: GhReleaseDeployAllArgs) => {
109
115
 
110
116
  const structuredContent = {
111
117
  releaseBranch: selectedReleaseBranch,
112
- version: selectedReleaseBranch.replace('release/v', ''),
118
+ version: selectedVersion,
113
119
  environment: selectedEnv,
114
120
  skipTerraformDeploy: shouldSkipTerraform,
115
121
  success: true,
@@ -137,7 +143,7 @@ export const ghReleaseDeployAllMcpTool = defineMcpTool({
137
143
  version: z
138
144
  .string()
139
145
  .describe(
140
- 'Release version to deploy from (e.g. "1.2.5") — resolves to the release/vX.Y.Z branch. Pass "dev" to deploy from the dev branch instead. Required for MCP calls.',
146
+ 'Accepts a release version (e.g. "1.2.5") OR a release name (e.g. "checkout-redesign") — resolves to the release/vX.Y.Z or release/n/<name> branch. Pass "dev" to deploy from the dev branch instead. Required for MCP calls.',
141
147
  ),
142
148
  env: z
143
149
  .string()
@@ -3,7 +3,7 @@ import select from '@inquirer/select'
3
3
  import fs from 'node:fs/promises'
4
4
  import { resolve } from 'node:path'
5
5
  import yaml from 'yaml'
6
- import { z } from 'zod/v4'
6
+ import { z } from 'zod'
7
7
  import { $ } from 'zx'
8
8
 
9
9
  import { getReleasePRsWithInfo } from 'src/integrations/gh'
@@ -12,7 +12,13 @@ import { OperationError } from 'src/lib/errors/operation-error'
12
12
  import { getProjectRoot } from 'src/lib/git-utils'
13
13
  import { getInfraKitConfig } from 'src/lib/infra-kit-config'
14
14
  import { logger } from 'src/lib/logger'
15
- import { detectReleaseType, formatBranchChoices, getJiraDescriptions } from 'src/lib/release-utils'
15
+ import {
16
+ detectReleaseType,
17
+ formatBranchChoices,
18
+ getJiraDescriptions,
19
+ releaseLabelFromBranch,
20
+ resolveReleaseBranch,
21
+ } from 'src/lib/release-utils'
16
22
  import type { ReleaseType } from 'src/lib/release-utils'
17
23
  import { defineMcpTool, textContent } from 'src/types'
18
24
 
@@ -35,7 +41,7 @@ export const ghReleaseDeploySelected = async (args: GhReleaseDeploySelectedArgs)
35
41
  let selectedReleaseBranch = ''
36
42
 
37
43
  if (version) {
38
- selectedReleaseBranch = version === 'dev' ? 'dev' : `release/v${version}`
44
+ selectedReleaseBranch = version === 'dev' ? 'dev' : resolveReleaseBranch(version)
39
45
  } else {
40
46
  commandEcho.setInteractive()
41
47
 
@@ -59,7 +65,7 @@ export const ghReleaseDeploySelected = async (args: GhReleaseDeploySelectedArgs)
59
65
  })
60
66
  }
61
67
 
62
- const selectedVersion = selectedReleaseBranch === 'dev' ? 'dev' : selectedReleaseBranch.replace('release/v', '')
68
+ const selectedVersion = releaseLabelFromBranch(selectedReleaseBranch)
63
69
 
64
70
  commandEcho.addOption('--version', selectedVersion)
65
71
 
@@ -172,7 +178,7 @@ export const ghReleaseDeploySelected = async (args: GhReleaseDeploySelectedArgs)
172
178
 
173
179
  const structuredContent = {
174
180
  releaseBranch: selectedReleaseBranch,
175
- version: selectedReleaseBranch.replace('release/v', ''),
181
+ version: selectedVersion,
176
182
  environment: selectedEnv,
177
183
  services: selectedServices,
178
184
  skipTerraformDeploy: shouldSkipTerraform,
@@ -226,7 +232,7 @@ export const ghReleaseDeploySelectedMcpTool = defineMcpTool({
226
232
  version: z
227
233
  .string()
228
234
  .describe(
229
- 'Release version to deploy from (e.g. "1.2.5") — resolves to the release/vX.Y.Z branch. Pass "dev" to deploy from the dev branch instead. Required for MCP calls.',
235
+ 'Accepts a release version (e.g. "1.2.5") OR a release name (e.g. "checkout-redesign") — resolves to the release/vX.Y.Z or release/n/<name> branch. Pass "dev" to deploy from the dev branch instead. Required for MCP calls.',
230
236
  ),
231
237
  env: z
232
238
  .string()
@@ -1,7 +1,8 @@
1
- import { z } from 'zod/v4'
1
+ import { z } from 'zod'
2
2
 
3
3
  import { getReleasePRsWithInfo } from 'src/integrations/gh'
4
4
  import { logger } from 'src/lib/logger'
5
+ import { displayLabel, formatJiraName, parseBranchName } from 'src/lib/release-id'
5
6
  import { detectReleaseType, formatVersionLabel, getJiraDescriptions } from 'src/lib/release-utils'
6
7
  import { defineMcpTool, textContent } from 'src/types'
7
8
 
@@ -11,11 +12,21 @@ import { defineMcpTool, textContent } from 'src/types'
11
12
  export const ghReleaseList = async () => {
12
13
  const releasePRs = await getReleasePRsWithInfo()
13
14
 
14
- const releases = releasePRs.map((pr) => {
15
- return {
16
- version: pr.branch.replace('release/', ''),
17
- type: detectReleaseType(pr.title),
18
- }
15
+ // Skip branches that do not parse as release ids (lenient discovery source).
16
+ const releases = releasePRs.flatMap((pr) => {
17
+ const id = parseBranchName(pr.branch)
18
+
19
+ if (!id) return []
20
+
21
+ return [
22
+ {
23
+ // Human display label: `1.2.3` | `<name>`.
24
+ version: displayLabel(id),
25
+ // Jira-descriptions map is keyed by the Jira version NAME (`v1.2.3` | `<name>`).
26
+ jiraKey: formatJiraName(id),
27
+ type: detectReleaseType(pr.title),
28
+ },
29
+ ]
19
30
  })
20
31
 
21
32
  const jiraDescriptions = await getJiraDescriptions()
@@ -28,7 +39,7 @@ export const ghReleaseList = async () => {
28
39
 
29
40
  const formattedLines = releases.map((release) => {
30
41
  const label = formatVersionLabel(release.version, release.type, maxVersionLength)
31
- const description = jiraDescriptions.get(release.version)
42
+ const description = jiraDescriptions.get(release.jiraKey)
32
43
 
33
44
  if (description) {
34
45
  return `${label} ${description}`
@@ -45,7 +56,7 @@ export const ghReleaseList = async () => {
45
56
  return {
46
57
  version: release.version,
47
58
  type: release.type,
48
- description: jiraDescriptions.get(release.version) || null,
59
+ description: jiraDescriptions.get(release.jiraKey) || null,
49
60
  }
50
61
  }),
51
62
  count: releases.length,
@@ -0,0 +1,160 @@
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 { migrateLegacyConfig } from '../migrate-config'
10
+
11
+ vi.mock('src/lib/git-utils', () => {
12
+ return {
13
+ getProjectRoot: vi.fn(),
14
+ getRepoName: vi.fn(),
15
+ }
16
+ })
17
+
18
+ const MAIN_YML = `environments:
19
+ - dev
20
+ - staging
21
+ envManagement:
22
+ provider: doppler
23
+ config:
24
+ name: my-project
25
+ `
26
+
27
+ const writeFile = (filePath: string, content: string): void => {
28
+ fs.mkdirSync(path.dirname(filePath), { recursive: true })
29
+ fs.writeFileSync(filePath, content, 'utf-8')
30
+ }
31
+
32
+ const withTmpRepo = async (fn: (tmp: string) => Promise<void>): Promise<void> => {
33
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'infra-kit-init-migrate-test-'))
34
+
35
+ vi.mocked(getProjectRoot).mockResolvedValue(tmp)
36
+ vi.mocked(getRepoName).mockResolvedValue(path.basename(tmp))
37
+ const homedirSpy = vi.spyOn(os, 'homedir').mockReturnValue(tmp)
38
+
39
+ try {
40
+ await fn(tmp)
41
+ } finally {
42
+ homedirSpy.mockRestore()
43
+ fs.rmSync(tmp, { recursive: true, force: true })
44
+ }
45
+ }
46
+
47
+ describe('migrateLegacyConfig', () => {
48
+ beforeEach(() => {
49
+ vi.clearAllMocks()
50
+ })
51
+
52
+ afterEach(() => {
53
+ vi.clearAllMocks()
54
+ })
55
+
56
+ it('converts a legacy infra-kit.yml to infra-kit.json and removes the .yml', async () => {
57
+ await withTmpRepo(async (tmp) => {
58
+ const ymlPath = path.join(tmp, 'infra-kit.yml')
59
+ const jsonPath = path.join(tmp, 'infra-kit.json')
60
+
61
+ writeFile(ymlPath, MAIN_YML)
62
+
63
+ await migrateLegacyConfig()
64
+
65
+ expect(fs.existsSync(ymlPath)).toBe(false)
66
+ expect(fs.existsSync(jsonPath)).toBe(true)
67
+ expect(JSON.parse(fs.readFileSync(jsonPath, 'utf-8'))).toEqual({
68
+ environments: ['dev', 'staging'],
69
+ envManagement: { provider: 'doppler', config: { name: 'my-project' } },
70
+ })
71
+ })
72
+ })
73
+
74
+ it('is an idempotent no-op when the config is already JSON', async () => {
75
+ await withTmpRepo(async (tmp) => {
76
+ const jsonPath = path.join(tmp, 'infra-kit.json')
77
+ const json = '{"environments":["dev"],"envManagement":{"provider":"doppler","config":{"name":"p"}}}'
78
+
79
+ writeFile(jsonPath, json)
80
+
81
+ await expect(migrateLegacyConfig()).resolves.toBeUndefined()
82
+
83
+ expect(fs.readFileSync(jsonPath, 'utf-8')).toBe(json)
84
+ })
85
+ })
86
+
87
+ it('warns and skips (does not throw or overwrite) when both .yml and .json exist', async () => {
88
+ await withTmpRepo(async (tmp) => {
89
+ const ymlPath = path.join(tmp, 'infra-kit.yml')
90
+ const jsonPath = path.join(tmp, 'infra-kit.json')
91
+ const existingJson = '{"environments":["keep"],"envManagement":{"provider":"doppler","config":{"name":"keep"}}}'
92
+
93
+ writeFile(ymlPath, MAIN_YML)
94
+ writeFile(jsonPath, existingJson)
95
+
96
+ await expect(migrateLegacyConfig()).resolves.toBeUndefined()
97
+
98
+ // Conflict left untouched — no overwrite, .yml preserved.
99
+ expect(fs.existsSync(ymlPath)).toBe(true)
100
+ expect(fs.readFileSync(jsonPath, 'utf-8')).toBe(existingJson)
101
+ })
102
+ })
103
+
104
+ it('skips an invalid layer but still converts a valid sibling layer (non-fatal, per-layer)', async () => {
105
+ await withTmpRepo(async (tmp) => {
106
+ const mainYml = path.join(tmp, 'infra-kit.yml')
107
+ const mainJson = path.join(tmp, 'infra-kit.json')
108
+ const userGlobalYml = path.join(tmp, '.infra-kit', 'config.yml')
109
+ const userGlobalJson = path.join(tmp, '.infra-kit', 'config.json')
110
+
111
+ writeFile(mainYml, MAIN_YML)
112
+ // Invalid override: environments present but empty (min(1) fails).
113
+ writeFile(userGlobalYml, 'environments: []\n')
114
+
115
+ await expect(migrateLegacyConfig()).resolves.toBeUndefined()
116
+
117
+ // Valid main layer converted…
118
+ expect(fs.existsSync(mainYml)).toBe(false)
119
+ expect(fs.existsSync(mainJson)).toBe(true)
120
+ // …invalid user-global layer left as-is (no JSON written).
121
+ expect(fs.existsSync(userGlobalYml)).toBe(true)
122
+ expect(fs.existsSync(userGlobalJson)).toBe(false)
123
+ })
124
+ })
125
+
126
+ it('warns and skips a malformed .yml without throwing', async () => {
127
+ await withTmpRepo(async (tmp) => {
128
+ const ymlPath = path.join(tmp, 'infra-kit.yml')
129
+ const jsonPath = path.join(tmp, 'infra-kit.json')
130
+
131
+ // Unparseable YAML (bad indentation / flow) — yaml.parse throws.
132
+ writeFile(ymlPath, 'environments: [dev\n : : :\n')
133
+
134
+ await expect(migrateLegacyConfig()).resolves.toBeUndefined()
135
+
136
+ expect(fs.existsSync(ymlPath)).toBe(true)
137
+ expect(fs.existsSync(jsonPath)).toBe(false)
138
+ })
139
+ })
140
+
141
+ it('converts all three merge-chain layers in one run', async () => {
142
+ await withTmpRepo(async (tmp) => {
143
+ const projectName = path.basename(tmp)
144
+ const mainYml = path.join(tmp, 'infra-kit.yml')
145
+ const userGlobalYml = path.join(tmp, '.infra-kit', 'config.yml')
146
+ const userProjectYml = path.join(tmp, '.infra-kit', 'projects', projectName, 'infra-kit.yml')
147
+
148
+ writeFile(mainYml, MAIN_YML)
149
+ writeFile(userGlobalYml, 'worktrees:\n openInCmux: true\n')
150
+ writeFile(userProjectYml, 'worktrees:\n openInGithubDesktop: false\n')
151
+
152
+ await migrateLegacyConfig()
153
+
154
+ for (const yml of [mainYml, userGlobalYml, userProjectYml]) {
155
+ expect(fs.existsSync(yml)).toBe(false)
156
+ expect(fs.existsSync(yml.replace(/\.yml$/, '.json'))).toBe(true)
157
+ }
158
+ })
159
+ })
160
+ })
@@ -4,49 +4,53 @@ import path from 'node:path'
4
4
 
5
5
  import { logger } from 'src/lib/logger'
6
6
 
7
+ import { migrateLegacyConfig } from './migrate-config'
8
+
7
9
  export const MARKER_START = '# -- infra-kit:begin --'
8
10
  export const MARKER_END = '# -- infra-kit:end --'
9
11
 
10
12
  const LEGACY_PAIRED: [start: string, end: string][] = [['# region infra-kit', '# endregion infra-kit']]
11
13
  const LEGACY_SINGLE = '# infra-kit shell functions'
12
14
 
13
- const USER_GLOBAL_CONFIG_STUB = `# infra-kit user-global config ~/.infra-kit/config.yml
14
- #
15
- # Merge chain (later layers override earlier ones at top-level keys):
16
- # 1. <repo>/infra-kit.yml — committed project config (required)
17
- # 2. ~/.infra-kit/config.yml — this file (user-global)
18
- # 3. ~/.infra-kit/projects/<repo-name>/infra-kit.yml — user-scope per-project override
19
- #
20
- # Merge is shallow: setting a top-level key here replaces that whole section
21
- # from layer 1. Arrays do not concatenate. Top-level keys recognized:
22
- # environments, envManagement, ide, taskManager, worktrees.
23
- #
24
- # Uncomment the blocks you want to apply globally across every project on this
25
- # machine. Per-project tweaks belong in layer 3 run \`infra-kit config edit\`.
26
-
27
- # Per-developer IDE config
28
- # ide:
29
- # provider: cursor
30
- # config:
31
- # mode: workspace
32
- # workspaceConfigPath: /path/to/your.code-workspace
33
-
34
- # Worktree prompt defaults — silences the follow-up prompts in \`worktrees-add\`
35
- # worktrees:
36
- # openInGithubDesktop: false
37
- # openInCmux: true
15
+ // JSON can't carry comments, so the real config is an empty-but-valid object…
16
+ const USER_GLOBAL_CONFIG_STUB = '{}\n'
17
+
18
+ // …and the annotated guidance lives next to it in a non-loaded .example.jsonc
19
+ // (the loader only reads the three exact `infra-kit.json` / `config.json` files).
20
+ const USER_GLOBAL_CONFIG_EXAMPLE = `// infra-kit user-global config — ~/.infra-kit/config.json
21
+ //
22
+ // Merge chain (later layers override earlier ones at top-level keys):
23
+ // 1. <repo>/infra-kit.json — committed project config (required)
24
+ // 2. ~/.infra-kit/config.json — user-global (the sibling of this file)
25
+ // 3. ~/.infra-kit/projects/<repo-name>/infra-kit.json — user-scope per-project override
26
+ //
27
+ // Merge is shallow: setting a top-level key replaces that whole section from
28
+ // layer 1. Arrays do not concatenate. Top-level keys recognized:
29
+ // environments, envManagement, ide, taskManager, worktrees.
30
+ //
31
+ // This .example.jsonc is reference only — it is NOT loaded. Put real global
32
+ // overrides in the sibling config.json (strict JSON: no comments, double-quoted
33
+ // keys). Per-project tweaks belong in layer 3 — run \`infra-kit config edit\`.
34
+ {
35
+ // "ide": {
36
+ // "provider": "cursor",
37
+ // "config": { "mode": "workspace", "workspaceConfigPath": "/path/to/your.code-workspace" }
38
+ // },
39
+ // "worktrees": { "openInGithubDesktop": false, "openInCmux": true }
40
+ }
38
41
  `
39
42
 
40
43
  /**
41
- * Append infra-kit shell functions to .zshrc and seed the user-global
42
- * config stub at ~/.infra-kit/config.yml on first run. Idempotent: a
43
- * subsequent run replaces the existing zshrc block in place and leaves
44
+ * Append infra-kit shell functions to .zshrc, migrate any legacy
45
+ * `infra-kit.yml` config layers to JSON, and seed the user-global config at
46
+ * ~/.infra-kit/config.json on first run. Idempotent: a subsequent run replaces
47
+ * the existing zshrc block in place, has nothing left to migrate, and leaves
44
48
  * the user-global config untouched.
45
49
  *
46
50
  * @example
47
51
  * // CLI: `infra-kit init` (or via the `pnpm dx-init` alias)
48
52
  * // INFO: Added infra-kit shell functions to /Users/me/.zshrc
49
- * // INFO: Wrote user-global config stub to /Users/me/.infra-kit/config.yml
53
+ * // INFO: Wrote user-global config to /Users/me/.infra-kit/config.json (see …/config.example.jsonc …)
50
54
  * // INFO: Run `source ~/.zshrc` or open a new terminal to activate.
51
55
  */
52
56
  export const init = async (): Promise<void> => {
@@ -63,23 +67,29 @@ export const init = async (): Promise<void> => {
63
67
  fs.appendFileSync(zshrcPath, `\n${shellBlock}\n`)
64
68
  logger.info(`Added infra-kit shell functions to ${zshrcPath}`)
65
69
 
70
+ // Convert any legacy infra-kit.yml config layers to JSON before seeding, so a
71
+ // migrated config.json is not re-seeded as an empty stub.
72
+ await migrateLegacyConfig()
73
+
66
74
  seedUserGlobalConfig()
67
75
 
68
76
  logger.info('Run `source ~/.zshrc` or open a new terminal to activate.')
69
77
  }
70
78
 
71
79
  /**
72
- * Create `~/.infra-kit/config.yml` with the documented stub when absent.
73
- * Skips silently if the file already exists so user edits are preserved.
80
+ * Create `~/.infra-kit/config.json` (empty `{}`) plus an annotated
81
+ * `~/.infra-kit/config.example.jsonc` reference when absent. Skips silently if
82
+ * the config already exists so user edits are preserved.
74
83
  *
75
84
  * @example
76
85
  * seedUserGlobalConfig()
77
- * // first call: writes ~/.infra-kit/config.yml from USER_GLOBAL_CONFIG_STUB
78
- * // later calls: leaves the file alone, logs that it is already present
86
+ * // first call: writes ~/.infra-kit/config.json ({}) + config.example.jsonc
87
+ * // later calls: leaves the config alone, logs that it is already present
79
88
  */
80
89
  const seedUserGlobalConfig = (): void => {
81
90
  const userConfigDir = path.join(os.homedir(), '.infra-kit')
82
- const userConfigPath = path.join(userConfigDir, 'config.yml')
91
+ const userConfigPath = path.join(userConfigDir, 'config.json')
92
+ const userConfigExamplePath = path.join(userConfigDir, 'config.example.jsonc')
83
93
 
84
94
  if (fs.existsSync(userConfigPath)) {
85
95
  logger.info(`User-global config already present at ${userConfigPath}`)
@@ -89,8 +99,11 @@ const seedUserGlobalConfig = (): void => {
89
99
 
90
100
  fs.mkdirSync(userConfigDir, { recursive: true })
91
101
  fs.writeFileSync(userConfigPath, USER_GLOBAL_CONFIG_STUB, 'utf-8')
102
+ fs.writeFileSync(userConfigExamplePath, USER_GLOBAL_CONFIG_EXAMPLE, 'utf-8')
92
103
 
93
- logger.info(`Wrote user-global config stub to ${userConfigPath}`)
104
+ logger.info(
105
+ `Wrote user-global config to ${userConfigPath} (see ${userConfigExamplePath} for the annotated reference)`,
106
+ )
94
107
  }
95
108
 
96
109
  const isBlockLine = (line: string): boolean => {