infra-kit 0.1.101 → 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 (129) 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 +41 -39
  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 +77 -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 +8 -67
  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/lib/worktrees/index.ts +1 -0
  121. package/src/lib/worktrees/remove-worktrees.ts +65 -0
  122. package/src/mcp/tools/index.ts +2 -0
  123. package/src/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
  124. package/src/types.ts +1 -1
  125. package/tsconfig.tsbuildinfo +1 -1
  126. package/src/lib/__tests__/infra-kit-config.test.ts +0 -231
  127. /package/src/integrations/{clickup → linear}/.gitkeep +0 -0
  128. /package/src/lib/{__tests__ → constants/__tests__}/constants.test.ts +0 -0
  129. /package/src/lib/{constants.ts → constants/constants.ts} +0 -0
@@ -1,17 +1,27 @@
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'
8
8
  import { deliverJiraRelease, loadJiraConfigOptional } from 'src/integrations/jira'
9
9
  import { commandEcho } from 'src/lib/command-echo'
10
+ import { WORKTREES_DIR_SUFFIX } from 'src/lib/constants'
10
11
  import { formatZxError } from 'src/lib/errors/format-zx-error'
11
12
  import { OperationError } from 'src/lib/errors/operation-error'
13
+ import { getCurrentWorktrees, getProjectRoot, getRepoName } from 'src/lib/git-utils'
12
14
  import { logger } from 'src/lib/logger'
13
- 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'
14
23
  import type { ReleaseType } from 'src/lib/release-utils'
24
+ import { removeWorktrees } from 'src/lib/worktrees'
15
25
  import { defineMcpTool, textContent } from 'src/types'
16
26
  import type { RequiredConfirmedOptionArg } from 'src/types'
17
27
 
@@ -59,8 +69,8 @@ const fetchPRByHead = async (head: string): Promise<PRStatus | null> => {
59
69
  * the merge step on resume. Title-matched on purpose: an older MERGED RC PR
60
70
  * from a different release must not short-circuit this version's flow.
61
71
  */
62
- const fetchMergedRcPRForVersion = async (version: string): Promise<PRStatus | null> => {
63
- const expectedTitle = `Release v${version} (RC)`
72
+ const fetchMergedRcPRForVersion = async (id: ReleaseId): Promise<PRStatus | null> => {
73
+ const expectedTitle = formatRcTitle(id)
64
74
  const result = await $`gh pr list --head dev --base main --state merged --json number,state,title --limit 20`
65
75
  const prs = JSON.parse(result.stdout) as PRStatus[]
66
76
  const match = prs.find((pr) => {
@@ -89,7 +99,7 @@ interface ResolvedTarget {
89
99
  }
90
100
 
91
101
  const resolveTargetFromVersion = async (version: string): Promise<ResolvedTarget> => {
92
- const selectedReleaseBranch = `release/v${version}`
102
+ const selectedReleaseBranch = resolveReleaseBranch(version)
93
103
  const pr = await fetchPRByHead(selectedReleaseBranch)
94
104
 
95
105
  if (!pr) {
@@ -140,6 +150,30 @@ const resolveTargetInteractively = async (): Promise<ResolvedTarget> => {
140
150
  return { selectedReleaseBranch, releasePrTitle: prInfo.title }
141
151
  }
142
152
 
153
+ /**
154
+ * `gh pr merge --delete-branch` also deletes the local branch, which fails if a
155
+ * worktree has it checked out (the actual root cause of the "Failed to merge
156
+ * release PR" surface error). Pre-remove any worktree for the release branch
157
+ * so the local delete can succeed.
158
+ */
159
+ const removeReleaseWorktreeIfPresent = async (releaseBranch: string): Promise<void> => {
160
+ const worktreeBranches = await getCurrentWorktrees('release')
161
+
162
+ if (!worktreeBranches.includes(releaseBranch)) return
163
+
164
+ const [projectRoot, repoName] = await Promise.all([getProjectRoot(), getRepoName()])
165
+ const worktreeDir = `${projectRoot}${WORKTREES_DIR_SUFFIX}`
166
+
167
+ const removed = await removeWorktrees({ branches: [releaseBranch], worktreeDir, repoName })
168
+
169
+ if (removed.length === 0) {
170
+ throw new OperationError(undefined, {
171
+ operation: `remove worktree for ${releaseBranch} before merge`,
172
+ remediation: `run manually: git worktree remove ${worktreeDir}/${releaseBranch} (use --force if uncommitted changes)`,
173
+ })
174
+ }
175
+ }
176
+
143
177
  interface MergeReleasePRArgs {
144
178
  selectedReleaseBranch: string
145
179
  releaseType: ReleaseType
@@ -147,7 +181,9 @@ interface MergeReleasePRArgs {
147
181
 
148
182
  const mergeReleasePR = async (args: MergeReleasePRArgs): Promise<void> => {
149
183
  const { selectedReleaseBranch, releaseType } = args
184
+
150
185
  const mergeTarget = releaseType === 'hotfix' ? 'main' : 'dev'
186
+
151
187
  const releasePr = await fetchPRByHead(selectedReleaseBranch)
152
188
 
153
189
  if (!releasePr) {
@@ -179,8 +215,9 @@ const mergeReleasePR = async (args: MergeReleasePRArgs): Promise<void> => {
179
215
  )
180
216
  }
181
217
 
182
- const resolveRcPRNumber = async (selectedVersion: string): Promise<number> => {
183
- const expectedTitle = `Release v${selectedVersion} (RC)`
218
+ const resolveRcPRNumber = async (id: ReleaseId): Promise<number> => {
219
+ const selectedLabel = displayLabel(id)
220
+ const expectedTitle = formatRcTitle(id)
184
221
  const existingOpen = await fetchOpenDevToMainPR()
185
222
 
186
223
  // Adopt any existing open dev→main PR. GitHub permits only one open PR per
@@ -192,7 +229,7 @@ const resolveRcPRNumber = async (selectedVersion: string): Promise<number> => {
192
229
 
193
230
  if (existingOpen.title !== expectedTitle) {
194
231
  logger.info(
195
- `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}`,
196
233
  )
197
234
  await runStep(
198
235
  `retitle dev → main PR #${rcNumber} to "${expectedTitle}"`,
@@ -207,7 +244,7 @@ const resolveRcPRNumber = async (selectedVersion: string): Promise<number> => {
207
244
  }
208
245
 
209
246
  await runStep(
210
- `create RC PR (dev → main) for v${selectedVersion}`,
247
+ `create RC PR (dev → main) for ${selectedLabel}`,
211
248
  `run 'gh pr create --base main --head dev' manually to surface the underlying error (e.g. no commits between dev and main)`,
212
249
  async () => {
213
250
  await $`gh pr create --base main --head dev --title ${expectedTitle} --body ""`
@@ -218,7 +255,7 @@ const resolveRcPRNumber = async (selectedVersion: string): Promise<number> => {
218
255
 
219
256
  if (!created) {
220
257
  throw new OperationError(undefined, {
221
- operation: `look up RC PR for v${selectedVersion}`,
258
+ operation: `look up RC PR for ${selectedLabel}`,
222
259
  remediation: `verify the RC PR was created ('gh pr list --head dev --base main')`,
223
260
  })
224
261
  }
@@ -226,19 +263,20 @@ const resolveRcPRNumber = async (selectedVersion: string): Promise<number> => {
226
263
  return created.number
227
264
  }
228
265
 
229
- const ensureRcPRMerged = async (selectedVersion: string): Promise<void> => {
230
- const alreadyMerged = await fetchMergedRcPRForVersion(selectedVersion)
266
+ const ensureRcPRMerged = async (id: ReleaseId): Promise<void> => {
267
+ const selectedLabel = displayLabel(id)
268
+ const alreadyMerged = await fetchMergedRcPRForVersion(id)
231
269
 
232
270
  if (alreadyMerged) {
233
- logger.info(`✓ RC PR for v${selectedVersion} already merged into main — skipping`)
271
+ logger.info(`✓ RC PR for ${selectedLabel} already merged into main — skipping`)
234
272
 
235
273
  return
236
274
  }
237
275
 
238
- const rcNumber = await resolveRcPRNumber(selectedVersion)
276
+ const rcNumber = await resolveRcPRNumber(id)
239
277
 
240
278
  await runStep(
241
- `merge RC PR #${rcNumber} (dev → main) for v${selectedVersion}`,
279
+ `merge RC PR #${rcNumber} (dev → main) for ${selectedLabel}`,
242
280
  `check 'gh pr view ${rcNumber}' for mergeability and required reviews`,
243
281
  async () => {
244
282
  await $`gh pr merge ${rcNumber} --squash --admin`
@@ -270,7 +308,7 @@ const syncMainIntoDev = async (): Promise<void> => {
270
308
  )
271
309
  }
272
310
 
273
- const deliverJiraReleaseSafely = async (selectedReleaseBranch: string): Promise<void> => {
311
+ const deliverJiraReleaseSafely = async (id: ReleaseId): Promise<void> => {
274
312
  const jiraConfig = await loadJiraConfigOptional()
275
313
 
276
314
  if (!jiraConfig) {
@@ -280,7 +318,8 @@ const deliverJiraReleaseSafely = async (selectedReleaseBranch: string): Promise<
280
318
  }
281
319
 
282
320
  try {
283
- const versionName = selectedReleaseBranch.replace('release/', '')
321
+ // Jira fix version name: `v1.2.3` | `<name>` — must match create-time formatJiraName.
322
+ const versionName = formatJiraName(id)
284
323
 
285
324
  await deliverJiraRelease({ versionName }, jiraConfig)
286
325
  } catch (error) {
@@ -304,9 +343,21 @@ export const ghReleaseDeliver = async (args: GhReleaseDeliverArgs) => {
304
343
  ? await resolveTargetFromVersion(version)
305
344
  : await resolveTargetInteractively()
306
345
 
307
- 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)
308
358
 
309
359
  commandEcho.addOption('--version', selectedVersion)
360
+ logger.info(`Delivering ${releaseId.kind === 'name' ? 'named release' : 'version'} ${selectedReleaseBranch}`)
310
361
 
311
362
  const releaseType: ReleaseType = detectReleaseType(releasePrTitle)
312
363
 
@@ -330,10 +381,11 @@ export const ghReleaseDeliver = async (args: GhReleaseDeliverArgs) => {
330
381
 
331
382
  $.quiet = true
332
383
 
384
+ await removeReleaseWorktreeIfPresent(selectedReleaseBranch)
333
385
  await mergeReleasePR({ selectedReleaseBranch, releaseType })
334
386
 
335
387
  if (releaseType !== 'hotfix') {
336
- await ensureRcPRMerged(selectedVersion)
388
+ await ensureRcPRMerged(releaseId)
337
389
  }
338
390
 
339
391
  await dispatchDeployWorkflow()
@@ -341,7 +393,7 @@ export const ghReleaseDeliver = async (args: GhReleaseDeliverArgs) => {
341
393
 
342
394
  $.quiet = false
343
395
 
344
- await deliverJiraReleaseSafely(selectedReleaseBranch)
396
+ await deliverJiraReleaseSafely(releaseId)
345
397
 
346
398
  logger.info(`Successfully delivered ${selectedReleaseBranch} to production!`)
347
399
 
@@ -366,7 +418,11 @@ export const ghReleaseDeliverMcpTool = defineMcpTool({
366
418
  description:
367
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).',
368
420
  inputSchema: {
369
- 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
+ ),
370
426
  },
371
427
  outputSchema: {
372
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
+ })