infra-kit 0.1.98 → 0.1.100

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 (56) hide show
  1. package/.eslintcache +1 -1
  2. package/.omc/state/agent-replay-d367c3be-9c2a-48e7-bcea-b45861af568c.jsonl +2 -0
  3. package/.omc/state/agent-replay-f2846d8f-974c-486c-b16f-4bdaa28ca45f.jsonl +1 -0
  4. package/.omc/state/last-tool-error.json +7 -0
  5. package/.omc/state/subagent-tracking.json +7 -0
  6. package/.turbo/turbo-eslint-check.log +2 -4
  7. package/.turbo/turbo-eslint-fix.log +1 -0
  8. package/.turbo/turbo-prettier-check.log +2 -4
  9. package/.turbo/turbo-prettier-fix.log +1 -10
  10. package/.turbo/turbo-test.log +12 -191
  11. package/.turbo/turbo-ts-check.log +2 -9
  12. package/dist/cli.js +69 -44
  13. package/dist/cli.js.map +4 -4
  14. package/dist/mcp.js +45 -34
  15. package/dist/mcp.js.map +4 -4
  16. package/package.json +11 -11
  17. package/src/commands/config/config.ts +1 -1
  18. package/src/commands/doctor/doctor.ts +62 -12
  19. package/src/commands/env-clear/env-clear.ts +5 -10
  20. package/src/commands/env-list/env-list.ts +5 -10
  21. package/src/commands/env-load/env-load.ts +5 -10
  22. package/src/commands/env-status/env-status.ts +5 -10
  23. package/src/commands/gh-merge-dev/gh-merge-dev.ts +17 -18
  24. package/src/commands/gh-release-deliver/gh-release-deliver.ts +290 -89
  25. package/src/commands/gh-release-deploy-all/gh-release-deploy-all.ts +15 -14
  26. package/src/commands/gh-release-deploy-selected/gh-release-deploy-selected.ts +30 -23
  27. package/src/commands/gh-release-list/gh-release-list.ts +5 -10
  28. package/src/commands/init/init.ts +17 -6
  29. package/src/commands/release-create/release-create.ts +223 -139
  30. package/src/commands/release-desc-edit/index.ts +1 -0
  31. package/src/commands/release-desc-edit/release-desc-edit.ts +207 -0
  32. package/src/commands/version/version.ts +5 -10
  33. package/src/commands/worktrees-add/worktrees-add.ts +34 -26
  34. package/src/commands/worktrees-list/worktrees-list.ts +6 -11
  35. package/src/commands/worktrees-open/worktrees-open.ts +10 -6
  36. package/src/commands/worktrees-remove/worktrees-remove.ts +18 -14
  37. package/src/commands/worktrees-sync/worktrees-sync.ts +17 -12
  38. package/src/entry/cli.ts +24 -21
  39. package/src/integrations/gh/gh-release-prs/gh-release-prs.ts +21 -0
  40. package/src/integrations/gh/gh-release-prs/index.ts +1 -1
  41. package/src/integrations/gh/index.ts +1 -1
  42. package/src/integrations/jira/api.ts +8 -17
  43. package/src/integrations/jira/index.ts +2 -0
  44. package/src/lib/__tests__/infra-kit-config.test.ts +50 -0
  45. package/src/lib/errors/__tests__/operation-error.test.ts +62 -0
  46. package/src/lib/errors/format-zx-error.ts +54 -0
  47. package/src/lib/errors/operation-error.ts +80 -0
  48. package/src/lib/infra-kit-config/infra-kit-config.ts +7 -0
  49. package/src/lib/version-utils/__tests__/next-version.test.ts +128 -23
  50. package/src/lib/version-utils/index.ts +4 -2
  51. package/src/lib/version-utils/next-version.ts +64 -25
  52. package/src/mcp/tools/index.ts +2 -2
  53. package/src/types.ts +56 -2
  54. package/tsconfig.tsbuildinfo +1 -1
  55. package/src/commands/release-create-batch/index.ts +0 -1
  56. package/src/commands/release-create-batch/release-create-batch.ts +0 -222
@@ -7,23 +7,103 @@ import { $ } from 'zx'
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 { formatZxError } from 'src/lib/errors/format-zx-error'
11
+ import { OperationError } from 'src/lib/errors/operation-error'
10
12
  import { logger } from 'src/lib/logger'
11
13
  import { detectReleaseType, formatBranchChoices, getJiraDescriptions } from 'src/lib/release-utils'
12
14
  import type { ReleaseType } from 'src/lib/release-utils'
13
- import type { RequiredConfirmedOptionArg, ToolsExecutionResult } from 'src/types'
15
+ import { defineMcpTool, textContent } from 'src/types'
16
+ import type { RequiredConfirmedOptionArg } from 'src/types'
14
17
 
15
18
  interface GhReleaseDeliverArgs extends RequiredConfirmedOptionArg {
16
19
  version: string
17
20
  }
18
21
 
22
+ type PRState = 'OPEN' | 'MERGED' | 'CLOSED'
23
+
24
+ interface PRStatus {
25
+ number: number
26
+ state: PRState
27
+ title: string
28
+ }
29
+
19
30
  /**
20
- * Deliver a release branch to production
31
+ * Wrap a delivery step so its failure logs structured zx fields and surfaces
32
+ * an `OperationError` whose message names the actual step that failed —
33
+ * instead of the previous blanket "merging release branch into dev" message.
21
34
  */
22
- export const ghReleaseDeliver = async (args: GhReleaseDeliverArgs): Promise<ToolsExecutionResult> => {
23
- const { version, confirmedCommand } = args
35
+ const runStep = async <T>(operation: string, remediation: string, fn: () => Promise<T>): Promise<T> => {
36
+ try {
37
+ return await fn()
38
+ } catch (error) {
39
+ logger.error({ err: formatZxError(error) }, `❌ Failed to ${operation}`)
40
+ throw new OperationError(error, { operation, remediation })
41
+ }
42
+ }
24
43
 
25
- commandEcho.start('release-deliver')
44
+ /**
45
+ * Fetch the (most-recent) PR for the given head branch, across all states, so
46
+ * we can resume a partially-completed delivery: a PR merged on a prior attempt
47
+ * still appears here as `state: 'MERGED'`, letting the caller skip the merge.
48
+ */
49
+ const fetchPRByHead = async (head: string): Promise<PRStatus | null> => {
50
+ const result = await $`gh pr list --head ${head} --state all --json number,state,title --limit 1`
51
+ const prs = JSON.parse(result.stdout) as PRStatus[]
52
+
53
+ return prs[0] ?? null
54
+ }
55
+
56
+ /**
57
+ * Find a MERGED RC PR (dev → main) whose title matches the given version, used
58
+ * to detect that a prior delivery run already merged the RC and we should skip
59
+ * the merge step on resume. Title-matched on purpose: an older MERGED RC PR
60
+ * from a different release must not short-circuit this version's flow.
61
+ */
62
+ const fetchMergedRcPRForVersion = async (version: string): Promise<PRStatus | null> => {
63
+ const expectedTitle = `Release v${version} (RC)`
64
+ const result = await $`gh pr list --head dev --base main --state merged --json number,state,title --limit 20`
65
+ const prs = JSON.parse(result.stdout) as PRStatus[]
66
+ const match = prs.find((pr) => {
67
+ return pr.title === expectedTitle
68
+ })
26
69
 
70
+ return match ?? null
71
+ }
72
+
73
+ /**
74
+ * Find an open dev → main PR, if any. GitHub allows at most one open PR per
75
+ * head/base pair, so an existing open PR here is the RC PR for this release —
76
+ * even if its title was set by a previous (failed) delivery run. Adopting it
77
+ * is what makes the flow recoverable after a mid-run failure.
78
+ */
79
+ const fetchOpenDevToMainPR = async (): Promise<PRStatus | null> => {
80
+ const result = await $`gh pr list --head dev --base main --state open --json number,state,title --limit 5`
81
+ const prs = JSON.parse(result.stdout) as PRStatus[]
82
+
83
+ return prs[0] ?? null
84
+ }
85
+
86
+ interface ResolvedTarget {
87
+ selectedReleaseBranch: string
88
+ releasePrTitle: string
89
+ }
90
+
91
+ const resolveTargetFromVersion = async (version: string): Promise<ResolvedTarget> => {
92
+ const selectedReleaseBranch = `release/v${version}`
93
+ const pr = await fetchPRByHead(selectedReleaseBranch)
94
+
95
+ if (!pr) {
96
+ logger.error(`❌ No PR found for branch ${selectedReleaseBranch}.`)
97
+ throw new OperationError(undefined, {
98
+ operation: `deliver release ${selectedReleaseBranch}`,
99
+ remediation: `confirm a PR exists ('gh pr list --head ${selectedReleaseBranch} --state all')`,
100
+ })
101
+ }
102
+
103
+ return { selectedReleaseBranch, releasePrTitle: pr.title }
104
+ }
105
+
106
+ const resolveTargetInteractively = async (): Promise<ResolvedTarget> => {
27
107
  const releasePRsInfo = await getReleasePRsWithInfo()
28
108
 
29
109
  const branches = releasePRsInfo.map((pr) => {
@@ -36,36 +116,199 @@ export const ghReleaseDeliver = async (args: GhReleaseDeliverArgs): Promise<Tool
36
116
  }),
37
117
  )
38
118
 
39
- let selectedReleaseBranch = '' // "release/v1.8.0"
119
+ commandEcho.setInteractive()
40
120
 
41
- if (version) {
42
- selectedReleaseBranch = `release/v${version}`
43
- } else {
44
- commandEcho.setInteractive()
121
+ const descriptions = await getJiraDescriptions()
122
+
123
+ const selectedReleaseBranch = await select({
124
+ message: '🌿 Select release branch',
125
+ choices: formatBranchChoices({ branches, descriptions, types: releaseTypes }),
126
+ })
45
127
 
46
- const descriptions = await getJiraDescriptions()
128
+ const prInfo = releasePRsInfo.find((pr) => {
129
+ return pr.branch === selectedReleaseBranch
130
+ })
47
131
 
48
- selectedReleaseBranch = await select({
49
- message: '🌿 Select release branch',
50
- choices: formatBranchChoices({ branches, descriptions, types: releaseTypes }),
132
+ if (!prInfo) {
133
+ logger.error(`❌ Release branch ${selectedReleaseBranch} not found in open PRs.`)
134
+ throw new OperationError(undefined, {
135
+ operation: `deliver release ${selectedReleaseBranch}`,
136
+ remediation: `confirm an open PR exists for ${selectedReleaseBranch} ('gh pr list')`,
51
137
  })
52
138
  }
53
139
 
54
- const selectedVersion = selectedReleaseBranch.replace('release/v', '')
140
+ return { selectedReleaseBranch, releasePrTitle: prInfo.title }
141
+ }
55
142
 
56
- commandEcho.addOption('--version', selectedVersion)
143
+ interface MergeReleasePRArgs {
144
+ selectedReleaseBranch: string
145
+ releaseType: ReleaseType
146
+ }
57
147
 
58
- // Check if release branch exists in the list
59
- const prInfo = releasePRsInfo.find((pr) => {
60
- return pr.branch === selectedReleaseBranch
61
- })
148
+ const mergeReleasePR = async (args: MergeReleasePRArgs): Promise<void> => {
149
+ const { selectedReleaseBranch, releaseType } = args
150
+ const mergeTarget = releaseType === 'hotfix' ? 'main' : 'dev'
151
+ const releasePr = await fetchPRByHead(selectedReleaseBranch)
62
152
 
63
- if (!prInfo) {
64
- logger.error(`❌ Release branch ${selectedReleaseBranch} not found in open PRs. Exiting...`)
65
- process.exit(1)
153
+ if (!releasePr) {
154
+ throw new OperationError(undefined, {
155
+ operation: `look up release PR for ${selectedReleaseBranch}`,
156
+ remediation: `verify the PR exists in GitHub`,
157
+ })
66
158
  }
67
159
 
68
- const releaseType: ReleaseType = detectReleaseType(prInfo.title)
160
+ if (releasePr.state === 'MERGED') {
161
+ logger.info(`✓ Release PR ${selectedReleaseBranch} already merged — skipping`)
162
+
163
+ return
164
+ }
165
+
166
+ if (releasePr.state === 'CLOSED') {
167
+ throw new OperationError(undefined, {
168
+ operation: `merge release PR ${selectedReleaseBranch} into ${mergeTarget}`,
169
+ remediation: `the PR is closed without merge; reopen it or create a new release`,
170
+ })
171
+ }
172
+
173
+ await runStep(
174
+ `merge release PR ${selectedReleaseBranch} into ${mergeTarget}`,
175
+ `check 'gh pr view ${selectedReleaseBranch}' for mergeability and required reviews`,
176
+ async () => {
177
+ await $`gh pr merge ${selectedReleaseBranch} --squash --admin --delete-branch`
178
+ },
179
+ )
180
+ }
181
+
182
+ const resolveRcPRNumber = async (selectedVersion: string): Promise<number> => {
183
+ const expectedTitle = `Release v${selectedVersion} (RC)`
184
+ const existingOpen = await fetchOpenDevToMainPR()
185
+
186
+ // Adopt any existing open dev→main PR. GitHub permits only one open PR per
187
+ // head/base pair, so a stale open RC PR (left behind by a prior failed run
188
+ // — the single most common cause of "Error merging release branch into
189
+ // dev") blocks `gh pr create`. Retitle it instead of fighting it.
190
+ if (existingOpen) {
191
+ const rcNumber = existingOpen.number
192
+
193
+ if (existingOpen.title !== expectedTitle) {
194
+ logger.info(
195
+ `Adopting open dev → main PR #${rcNumber} ("${existingOpen.title}") and retitling for v${selectedVersion}`,
196
+ )
197
+ await runStep(
198
+ `retitle dev → main PR #${rcNumber} to "${expectedTitle}"`,
199
+ `update manually: gh pr edit ${rcNumber} --title "${expectedTitle}"`,
200
+ async () => {
201
+ await $`gh pr edit ${rcNumber} --title ${expectedTitle}`
202
+ },
203
+ )
204
+ }
205
+
206
+ return rcNumber
207
+ }
208
+
209
+ await runStep(
210
+ `create RC PR (dev → main) for v${selectedVersion}`,
211
+ `run 'gh pr create --base main --head dev' manually to surface the underlying error (e.g. no commits between dev and main)`,
212
+ async () => {
213
+ await $`gh pr create --base main --head dev --title ${expectedTitle} --body ""`
214
+ },
215
+ )
216
+
217
+ const created = await fetchOpenDevToMainPR()
218
+
219
+ if (!created) {
220
+ throw new OperationError(undefined, {
221
+ operation: `look up RC PR for v${selectedVersion}`,
222
+ remediation: `verify the RC PR was created ('gh pr list --head dev --base main')`,
223
+ })
224
+ }
225
+
226
+ return created.number
227
+ }
228
+
229
+ const ensureRcPRMerged = async (selectedVersion: string): Promise<void> => {
230
+ const alreadyMerged = await fetchMergedRcPRForVersion(selectedVersion)
231
+
232
+ if (alreadyMerged) {
233
+ logger.info(`✓ RC PR for v${selectedVersion} already merged into main — skipping`)
234
+
235
+ return
236
+ }
237
+
238
+ const rcNumber = await resolveRcPRNumber(selectedVersion)
239
+
240
+ await runStep(
241
+ `merge RC PR #${rcNumber} (dev → main) for v${selectedVersion}`,
242
+ `check 'gh pr view ${rcNumber}' for mergeability and required reviews`,
243
+ async () => {
244
+ await $`gh pr merge ${rcNumber} --squash --admin`
245
+ },
246
+ )
247
+ }
248
+
249
+ const dispatchDeployWorkflow = async (): Promise<void> => {
250
+ $.quiet = false
251
+
252
+ await runStep(
253
+ `dispatch deploy-all workflow on main`,
254
+ `check 'gh workflow list' and that you have permission to dispatch deploy-all.yml`,
255
+ async () => {
256
+ await $`gh workflow run deploy-all.yml --ref main -f environment=prod`
257
+ },
258
+ )
259
+
260
+ $.quiet = true
261
+ }
262
+
263
+ const syncMainIntoDev = async (): Promise<void> => {
264
+ await runStep(
265
+ `sync main back into dev`,
266
+ `run manually: git switch main && git pull && git switch dev && git pull && git merge main --no-edit && git push`,
267
+ async () => {
268
+ await $`git switch main && git pull && git switch dev && git pull && git merge main --no-edit && git push`
269
+ },
270
+ )
271
+ }
272
+
273
+ const deliverJiraReleaseSafely = async (selectedReleaseBranch: string): Promise<void> => {
274
+ const jiraConfig = await loadJiraConfigOptional()
275
+
276
+ if (!jiraConfig) {
277
+ logger.info('🔔 Jira is not configured, skipping Jira release delivery')
278
+
279
+ return
280
+ }
281
+
282
+ try {
283
+ const versionName = selectedReleaseBranch.replace('release/', '')
284
+
285
+ await deliverJiraRelease({ versionName }, jiraConfig)
286
+ } catch (error) {
287
+ logger.error({ err: formatZxError(error) }, 'Failed to deliver Jira release (non-blocking)')
288
+ }
289
+ }
290
+
291
+ /**
292
+ * Deliver a release branch to production. Each network/git step is run inside
293
+ * `runStep` so the surfaced error names the failing operation and includes the
294
+ * subprocess stderr. PR-merge steps are idempotent: if the release PR or RC PR
295
+ * is already MERGED, the step is skipped, so re-running after a mid-flight
296
+ * failure picks up where it stopped.
297
+ */
298
+ export const ghReleaseDeliver = async (args: GhReleaseDeliverArgs) => {
299
+ const { version, confirmedCommand } = args
300
+
301
+ commandEcho.start('release-deliver')
302
+
303
+ const { selectedReleaseBranch, releasePrTitle } = version
304
+ ? await resolveTargetFromVersion(version)
305
+ : await resolveTargetInteractively()
306
+
307
+ const selectedVersion = selectedReleaseBranch.replace('release/v', '')
308
+
309
+ commandEcho.addOption('--version', selectedVersion)
310
+
311
+ const releaseType: ReleaseType = detectReleaseType(releasePrTitle)
69
312
 
70
313
  const answer = confirmedCommand
71
314
  ? true
@@ -85,85 +328,43 @@ export const ghReleaseDeliver = async (args: GhReleaseDeliverArgs): Promise<Tool
85
328
  // Track --yes flag if confirmation was interactive (user confirmed)
86
329
  commandEcho.addOption('--yes', true)
87
330
 
88
- try {
89
- $.quiet = true
90
-
91
- if (releaseType === 'hotfix') {
92
- // Hotfix: merge directly into main, deploy, sync back to dev
93
- await $`gh pr merge ${selectedReleaseBranch} --squash --admin --delete-branch`
94
-
95
- $.quiet = false
96
-
97
- await $`gh workflow run deploy-all.yml --ref main -f environment=prod`
98
-
99
- $.quiet = true
331
+ $.quiet = true
100
332
 
101
- // Sync main into dev
102
- await $`git switch main && git pull && git switch dev && git pull && git merge main --no-edit && git push`
103
- } else {
104
- // Regular: merge into dev, create RC PR to main, merge to main, deploy, sync
105
- await $`gh pr merge ${selectedReleaseBranch} --squash --admin --delete-branch`
106
- await $`gh pr create --base main --head dev --title "Release v${selectedVersion} (RC)" --body ""`
107
- await $`gh pr merge dev --squash --admin`
108
-
109
- $.quiet = false
110
-
111
- await $`gh workflow run deploy-all.yml --ref main -f environment=prod`
112
-
113
- $.quiet = true
114
-
115
- // Sync main into dev
116
- await $`git switch main && git pull && git switch dev && git pull && git merge main --no-edit && git push`
117
- }
333
+ await mergeReleasePR({ selectedReleaseBranch, releaseType })
118
334
 
119
- $.quiet = false
335
+ if (releaseType !== 'hotfix') {
336
+ await ensureRcPRMerged(selectedVersion)
337
+ }
120
338
 
121
- // Deliver Jira release if Jira is configured
122
- const jiraConfig = await loadJiraConfigOptional()
339
+ await dispatchDeployWorkflow()
340
+ await syncMainIntoDev()
123
341
 
124
- if (jiraConfig) {
125
- try {
126
- const versionName = selectedReleaseBranch.replace('release/', '')
342
+ $.quiet = false
127
343
 
128
- await deliverJiraRelease({ versionName }, jiraConfig)
129
- } catch (error) {
130
- logger.error({ error }, 'Failed to deliver Jira release (non-blocking)')
131
- }
132
- } else {
133
- logger.info('🔔 Jira is not configured, skipping Jira release delivery')
134
- }
344
+ await deliverJiraReleaseSafely(selectedReleaseBranch)
135
345
 
136
- logger.info(`Successfully delivered ${selectedReleaseBranch} to production!`)
346
+ logger.info(`Successfully delivered ${selectedReleaseBranch} to production!`)
137
347
 
138
- commandEcho.print()
348
+ commandEcho.print()
139
349
 
140
- const structuredContent = {
141
- releaseBranch: selectedReleaseBranch,
142
- version: selectedReleaseBranch.replace('release/v', ''),
143
- type: releaseType,
144
- success: true,
145
- }
350
+ const structuredContent = {
351
+ releaseBranch: selectedReleaseBranch,
352
+ version: selectedVersion,
353
+ type: releaseType,
354
+ success: true,
355
+ }
146
356
 
147
- return {
148
- content: [
149
- {
150
- type: 'text',
151
- text: JSON.stringify(structuredContent, null, 2),
152
- },
153
- ],
154
- structuredContent,
155
- }
156
- } catch (error: unknown) {
157
- logger.error({ error }, '❌ Error merging release branch into dev')
158
- process.exit(1)
357
+ return {
358
+ content: textContent(JSON.stringify(structuredContent, null, 2)),
359
+ structuredContent,
159
360
  }
160
361
  }
161
362
 
162
363
  // MCP Tool Registration
163
- export const ghReleaseDeliverMcpTool = {
364
+ export const ghReleaseDeliverMcpTool = defineMcpTool({
164
365
  name: 'gh-release-deliver',
165
366
  description:
166
- '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. 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).',
367
+ '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).',
167
368
  inputSchema: {
168
369
  version: z.string().describe('Release version to deliver to production (e.g., "1.2.5"). Required for MCP calls.'),
169
370
  },
@@ -174,4 +375,4 @@ export const ghReleaseDeliverMcpTool = {
174
375
  success: z.boolean().describe('Whether the delivery was successful'),
175
376
  },
176
377
  handler: ghReleaseDeliver,
177
- }
378
+ })
@@ -1,15 +1,15 @@
1
1
  import select from '@inquirer/select'
2
- import process from 'node:process'
3
2
  import { z } from 'zod/v4'
4
3
  import { $ } from 'zx'
5
4
 
6
5
  import { getReleasePRsWithInfo } from 'src/integrations/gh'
7
6
  import { commandEcho } from 'src/lib/command-echo'
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
10
  import { detectReleaseType, formatBranchChoices, getJiraDescriptions } from 'src/lib/release-utils'
11
11
  import type { ReleaseType } from 'src/lib/release-utils'
12
- import type { ToolsExecutionResult } from 'src/types'
12
+ import { defineMcpTool, textContent } from 'src/types'
13
13
 
14
14
  interface GhReleaseDeployAllArgs {
15
15
  version: string
@@ -20,7 +20,7 @@ interface GhReleaseDeployAllArgs {
20
20
  /**
21
21
  * Deploy a release branch to an environment
22
22
  */
23
- export const ghReleaseDeployAll = async (args: GhReleaseDeployAllArgs): Promise<ToolsExecutionResult> => {
23
+ export const ghReleaseDeployAll = async (args: GhReleaseDeployAllArgs) => {
24
24
  const { version, env, skipTerraform } = args
25
25
 
26
26
  commandEcho.start('release-deploy-all')
@@ -79,8 +79,11 @@ export const ghReleaseDeployAll = async (args: GhReleaseDeployAllArgs): Promise<
79
79
  commandEcho.addOption('--env', selectedEnv)
80
80
 
81
81
  if (!environments.includes(selectedEnv)) {
82
- logger.error(`❌ Invalid environment: ${selectedEnv}. Exiting...`)
83
- process.exit(1)
82
+ throw new OperationError(undefined, {
83
+ operation: 'launch deploy-all workflow',
84
+ remediation: `pass one of: ${environments.join(', ')}`,
85
+ stderrExcerpt: `invalid environment: ${selectedEnv}`,
86
+ })
84
87
  }
85
88
 
86
89
  const shouldSkipTerraform = skipTerraform ?? false
@@ -113,22 +116,20 @@ export const ghReleaseDeployAll = async (args: GhReleaseDeployAllArgs): Promise<
113
116
  }
114
117
 
115
118
  return {
116
- content: [
117
- {
118
- type: 'text',
119
- text: JSON.stringify(structuredContent, null, 2),
120
- },
121
- ],
119
+ content: textContent(JSON.stringify(structuredContent, null, 2)),
122
120
  structuredContent,
123
121
  }
124
122
  } catch (error: unknown) {
125
123
  logger.error({ error }, '❌ Error launching workflow')
126
- process.exit(1)
124
+ throw new OperationError(error, {
125
+ operation: 'launch deploy-all workflow',
126
+ remediation: "check 'gh workflow list' and that deploy-all.yml exists on the target ref",
127
+ })
127
128
  }
128
129
  }
129
130
 
130
131
  // MCP Tool Registration
131
- export const ghReleaseDeployAllMcpTool = {
132
+ export const ghReleaseDeployAllMcpTool = defineMcpTool({
132
133
  name: 'gh-release-deploy-all',
133
134
  description:
134
135
  'Dispatch the deploy-all.yml GitHub Actions workflow to deploy every service from a release branch to the given environment. Fire-and-forget — returns once GitHub accepts the workflow_dispatch, NOT when the deployment finishes; watch the workflow run for completion status. Use gh-release-deploy-selected for a subset of services. Pass version="dev" to deploy from the dev branch instead of a release branch. Both "version" and "env" are required when invoked via MCP (interactive pickers are unavailable without a TTY).',
@@ -153,4 +154,4 @@ export const ghReleaseDeployAllMcpTool = {
153
154
  success: z.boolean().describe('Whether the deployment was successful'),
154
155
  },
155
156
  handler: ghReleaseDeployAll,
156
- }
157
+ })
@@ -2,19 +2,19 @@ import checkbox from '@inquirer/checkbox'
2
2
  import select from '@inquirer/select'
3
3
  import fs from 'node:fs/promises'
4
4
  import { resolve } from 'node:path'
5
- import process from 'node:process'
6
5
  import yaml from 'yaml'
7
6
  import { z } from 'zod/v4'
8
7
  import { $ } from 'zx'
9
8
 
10
9
  import { getReleasePRsWithInfo } from 'src/integrations/gh'
11
10
  import { commandEcho } from 'src/lib/command-echo'
11
+ 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
15
  import { detectReleaseType, formatBranchChoices, getJiraDescriptions } from 'src/lib/release-utils'
16
16
  import type { ReleaseType } from 'src/lib/release-utils'
17
- import type { ToolsExecutionResult } from 'src/types'
17
+ import { defineMcpTool, textContent } from 'src/types'
18
18
 
19
19
  interface GhReleaseDeploySelectedArgs {
20
20
  version: string
@@ -27,7 +27,7 @@ interface GhReleaseDeploySelectedArgs {
27
27
  * Deploy selected services from a release branch to an environment
28
28
  */
29
29
  // eslint-disable-next-line sonarjs/cognitive-complexity
30
- export const ghReleaseDeploySelected = async (args: GhReleaseDeploySelectedArgs): Promise<ToolsExecutionResult> => {
30
+ export const ghReleaseDeploySelected = async (args: GhReleaseDeploySelectedArgs) => {
31
31
  const { version, env, services, skipTerraform } = args
32
32
 
33
33
  commandEcho.start('release-deploy-selected')
@@ -86,17 +86,22 @@ export const ghReleaseDeploySelected = async (args: GhReleaseDeploySelectedArgs)
86
86
  commandEcho.addOption('--env', selectedEnv)
87
87
 
88
88
  if (!environments.includes(selectedEnv)) {
89
- logger.error(`❌ Invalid environment: ${selectedEnv}. Exiting...`)
90
- process.exit(1)
89
+ throw new OperationError(undefined, {
90
+ operation: 'launch deploy-selected workflow',
91
+ remediation: `pass one of: ${environments.join(', ')}`,
92
+ stderrExcerpt: `invalid environment: ${selectedEnv}`,
93
+ })
91
94
  }
92
95
 
93
96
  // Parse available services from workflow file
94
97
  const availableServices = await parseServicesFromWorkflow()
95
98
 
96
99
  if (availableServices.length === 0) {
97
- logger.error('❌ No services found in workflow file. Exiting...')
98
-
99
- process.exit(1)
100
+ throw new OperationError(undefined, {
101
+ operation: 'launch deploy-selected workflow',
102
+ remediation: 'check .github/workflows/deploy-selected-services.yml for boolean service inputs',
103
+ stderrExcerpt: 'no services found in workflow file',
104
+ })
100
105
  }
101
106
 
102
107
  let selectedServices: string[] = []
@@ -120,8 +125,11 @@ export const ghReleaseDeploySelected = async (args: GhReleaseDeploySelectedArgs)
120
125
  commandEcho.addOption('--services', selectedServices)
121
126
 
122
127
  if (selectedServices.length === 0) {
123
- logger.error('❌ No services selected. Exiting...')
124
- process.exit(1)
128
+ throw new OperationError(undefined, {
129
+ operation: 'launch deploy-selected workflow',
130
+ remediation: `pass at least one service from: ${availableServices.join(', ')}`,
131
+ stderrExcerpt: 'no services selected',
132
+ })
125
133
  }
126
134
 
127
135
  // Validate all selected services
@@ -130,10 +138,11 @@ export const ghReleaseDeploySelected = async (args: GhReleaseDeploySelectedArgs)
130
138
  })
131
139
 
132
140
  if (invalidServices.length > 0) {
133
- logger.error(
134
- `❌ Invalid services: ${invalidServices.join(', ')}. Available services: ${availableServices.join(', ')}`,
135
- )
136
- process.exit(1)
141
+ throw new OperationError(undefined, {
142
+ operation: 'launch deploy-selected workflow',
143
+ remediation: `pass services from: ${availableServices.join(', ')}`,
144
+ stderrExcerpt: `invalid services: ${invalidServices.join(', ')}`,
145
+ })
137
146
  }
138
147
 
139
148
  const shouldSkipTerraform = skipTerraform ?? false
@@ -171,17 +180,15 @@ export const ghReleaseDeploySelected = async (args: GhReleaseDeploySelectedArgs)
171
180
  }
172
181
 
173
182
  return {
174
- content: [
175
- {
176
- type: 'text',
177
- text: JSON.stringify(structuredContent, null, 2),
178
- },
179
- ],
183
+ content: textContent(JSON.stringify(structuredContent, null, 2)),
180
184
  structuredContent,
181
185
  }
182
186
  } catch (error: unknown) {
183
187
  logger.error({ error }, '❌ Error launching workflow')
184
- process.exit(1)
188
+ throw new OperationError(error, {
189
+ operation: 'launch deploy-selected workflow',
190
+ remediation: "check 'gh workflow list' and that deploy-selected-services.yml exists on the target ref",
191
+ })
185
192
  }
186
193
  }
187
194
 
@@ -211,7 +218,7 @@ const parseServicesFromWorkflow = async (): Promise<string[]> => {
211
218
  }
212
219
 
213
220
  // MCP Tool Registration
214
- export const ghReleaseDeploySelectedMcpTool = {
221
+ export const ghReleaseDeploySelectedMcpTool = defineMcpTool({
215
222
  name: 'gh-release-deploy-selected',
216
223
  description:
217
224
  'Dispatch the deploy-selected-services.yml GitHub Actions workflow to deploy a chosen subset of services from a release branch to the given environment. Fire-and-forget — returns once GitHub accepts the workflow_dispatch, NOT when the deployment finishes; watch the workflow run for completion status. Service names are validated against the boolean inputs declared in the workflow. Use gh-release-deploy-all for every service. "version", "env", and "services" are all required when invoked via MCP (interactive pickers are unavailable without a TTY).',
@@ -242,4 +249,4 @@ export const ghReleaseDeploySelectedMcpTool = {
242
249
  success: z.boolean().describe('Whether the deployment was successful'),
243
250
  },
244
251
  handler: ghReleaseDeploySelected,
245
- }
252
+ })