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
@@ -8,10 +8,12 @@ import { removeFoldersFromCursorWorkspace, resolveCursorWorkspacePath } from 'sr
8
8
  import { getReleasePRs } from 'src/integrations/gh'
9
9
  import { commandEcho } from 'src/lib/command-echo'
10
10
  import { WORKTREES_DIR_SUFFIX } from 'src/lib/constants'
11
+ import { OperationError } from 'src/lib/errors/operation-error'
11
12
  import { getCurrentWorktrees, getProjectRoot, getRepoName } from 'src/lib/git-utils'
12
13
  import { getInfraKitConfig } from 'src/lib/infra-kit-config'
13
14
  import { logger } from 'src/lib/logger'
14
- import type { RequiredConfirmedOptionArg, ToolsExecutionResult } from 'src/types'
15
+ import { defineMcpTool, textContent } from 'src/types'
16
+ import type { RequiredConfirmedOptionArg } from 'src/types'
15
17
 
16
18
  // Constants
17
19
  const RELEASE_BRANCH_PREFIX = 'release/v'
@@ -23,7 +25,7 @@ interface WorktreeSyncArgs extends RequiredConfirmedOptionArg {}
23
25
  *
24
26
  * Creates worktrees for active release branches and removes unused ones
25
27
  */
26
- export const worktreesSync = async (options: WorktreeSyncArgs): Promise<ToolsExecutionResult> => {
28
+ export const worktreesSync = async (options: WorktreeSyncArgs) => {
27
29
  const { confirmedCommand } = options
28
30
 
29
31
  commandEcho.start('worktrees-sync')
@@ -82,17 +84,15 @@ export const worktreesSync = async (options: WorktreeSyncArgs): Promise<ToolsExe
82
84
  }
83
85
 
84
86
  return {
85
- content: [
86
- {
87
- type: 'text',
88
- text: JSON.stringify(structuredContent, null, 2),
89
- },
90
- ],
87
+ content: textContent(JSON.stringify(structuredContent, null, 2)),
91
88
  structuredContent,
92
89
  }
93
90
  } catch (error) {
94
91
  logger.error({ error }, '❌ Error managing worktrees')
95
- throw error
92
+ throw new OperationError(error, {
93
+ operation: 'sync worktrees with remote',
94
+ remediation: "ensure 'gh auth status' is ok and you can reach origin",
95
+ })
96
96
  }
97
97
  }
98
98
 
@@ -143,7 +143,12 @@ const removeWorktrees = async (args: RemoveWorktreesArgs): Promise<string[]> =>
143
143
  await $`git worktree remove ${worktreePath}`
144
144
  removed.push(branch)
145
145
  } catch (error) {
146
- logger.error({ error, branch }, `❌ Failed to remove worktree for ${branch}`)
146
+ const err = new OperationError(error, {
147
+ operation: `remove stale worktree for ${branch}`,
148
+ remediation: 'inspect the worktree dir manually; rerun with the branch checked out elsewhere',
149
+ })
150
+
151
+ logger.error({ error, branch, msg: err.message })
147
152
  }
148
153
  }
149
154
 
@@ -207,7 +212,7 @@ const logResults = (removed: string[]): void => {
207
212
  }
208
213
 
209
214
  // MCP Tool Registration
210
- export const worktreesSyncMcpTool = {
215
+ export const worktreesSyncMcpTool = defineMcpTool({
211
216
  name: 'worktrees-sync',
212
217
  description:
213
218
  'Remove worktrees whose release PR is no longer open (stale cleanup). Only removes — never creates; use worktrees-add to create worktrees for new releases. The CLI confirmation is auto-skipped for MCP calls, so the caller is responsible for gating.',
@@ -217,4 +222,4 @@ export const worktreesSyncMcpTool = {
217
222
  count: z.number().describe('Number of worktrees removed during sync'),
218
223
  },
219
224
  handler: worktreesSync,
220
- }
225
+ })
package/src/entry/cli.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import select, { Separator } from '@inquirer/select'
2
- import { Command, Option } from 'commander'
2
+ import { Command } from 'commander'
3
3
  import process from 'node:process'
4
4
 
5
5
  import { configEdit, configPath } from 'src/commands/config'
@@ -15,7 +15,7 @@ import { ghReleaseDeploySelected } from 'src/commands/gh-release-deploy-selected
15
15
  import { ghReleaseList } from 'src/commands/gh-release-list'
16
16
  import { init } from 'src/commands/init'
17
17
  import { releaseCreate } from 'src/commands/release-create'
18
- import { releaseCreateBatch } from 'src/commands/release-create-batch'
18
+ import { releaseDescEdit } from 'src/commands/release-desc-edit'
19
19
  import { version } from 'src/commands/version'
20
20
  import { CURSOR_MODES, worktreesAdd } from 'src/commands/worktrees-add'
21
21
  import type { CursorMode } from 'src/commands/worktrees-add'
@@ -24,9 +24,14 @@ import { worktreesOpen } from 'src/commands/worktrees-open'
24
24
  import { worktreesRemove } from 'src/commands/worktrees-remove'
25
25
  import { worktreesSync } from 'src/commands/worktrees-sync'
26
26
  import { logger } from 'src/lib/logger'
27
+ import { parseReleaseSpec } from 'src/lib/version-utils'
27
28
 
28
29
  const program = new Command()
29
30
 
31
+ const collectReleaseSpec = (value: string, prev: string[]): string[] => {
32
+ return [...prev, value]
33
+ }
34
+
30
35
  const normalizeCursorMode = (value: unknown): CursorMode | undefined => {
31
36
  if (typeof value === 'undefined') {
32
37
  return undefined
@@ -80,36 +85,34 @@ program
80
85
 
81
86
  program
82
87
  .command('release-create')
83
- .description('Create a single release branch')
88
+ .description('Create one or more release branches (each entry can mix regular/hotfix and its own description)')
84
89
  .option(
85
- '-v, --version <version>',
86
- 'Version to create, e.g. "1.2.5", "next", or "next,next,1.2.7" (multi-value routes to batch)',
90
+ '-r, --release <spec>',
91
+ 'Release spec "version[:type[:description]]" (repeatable). Examples: "1.2.5", "1.2.5:hotfix", "next:regular:Holiday backend"',
92
+ collectReleaseSpec,
93
+ [],
87
94
  )
88
- .option('-d, --description <description>', 'Optional description for the Jira version')
89
- .addOption(new Option('-t, --type <type>', 'Release type (default: regular)').choices(['regular', 'hotfix']))
90
95
  .option('-y, --yes', 'Skip confirmation prompt')
91
96
  .action(async (options) => {
97
+ const specs = options.release as string[]
98
+ const releases = specs.length > 0 ? specs.map(parseReleaseSpec) : undefined
99
+
92
100
  await releaseCreate({
93
- version: options.version,
94
- description: options.description,
95
- type: options.type,
101
+ releases,
96
102
  confirmedCommand: options.yes,
97
103
  })
98
104
  })
99
105
 
100
106
  program
101
- .command('release-create-batch')
102
- .description('Create multiple release branches (batch operation)')
103
- .option(
104
- '-v, --versions <versions>',
105
- 'Comma-separated versions, e.g. "1.2.5, 1.2.6", "next,next", or "next,next,1.2.7"',
106
- )
107
- .addOption(new Option('-t, --type <type>', 'Release type (default: regular)').choices(['regular', 'hotfix']))
107
+ .command('release-desc-edit')
108
+ .description("Edit a release's description in Jira and in the matching GitHub PR body")
109
+ .option('-v, --version <version>', 'Release version, e.g. 1.2.5')
110
+ .option('-d, --description <description>', 'New description (use "" to clear)')
108
111
  .option('-y, --yes', 'Skip confirmation prompt')
109
112
  .action(async (options) => {
110
- await releaseCreateBatch({
111
- versions: options.versions,
112
- type: options.type,
113
+ await releaseDescEdit({
114
+ version: options.version,
115
+ description: options.description,
113
116
  confirmedCommand: options.yes,
114
117
  })
115
118
  })
@@ -275,7 +278,7 @@ if (process.argv.length <= 2) {
275
278
  'merge-dev',
276
279
  'release-list',
277
280
  'release-create',
278
- 'release-create-batch',
281
+ 'release-desc-edit',
279
282
  'release-deploy-all',
280
283
  'release-deploy-selected',
281
284
  'release-deliver',
@@ -109,6 +109,27 @@ export const getReleasePRsWithInfo = async (): Promise<ReleasePRInfo[]> => {
109
109
  }
110
110
  }
111
111
 
112
+ interface UpdateReleasePRBodyArgs {
113
+ branch: string
114
+ body: string
115
+ }
116
+
117
+ /**
118
+ * Update the body of an open release PR identified by its head branch.
119
+ */
120
+ export const updateReleasePRBody = async (args: UpdateReleasePRBodyArgs): Promise<void> => {
121
+ const { branch, body } = args
122
+
123
+ try {
124
+ $.quiet = true
125
+ await $`gh pr edit ${branch} --body ${body}`
126
+ $.quiet = false
127
+ } catch (error: unknown) {
128
+ logger.error({ error, branch }, `Error updating release PR body for ${branch}`)
129
+ throw error
130
+ }
131
+ }
132
+
112
133
  interface CreateReleaseBranchArgs {
113
134
  version: string
114
135
  jiraVersionUrl: string
@@ -1,2 +1,2 @@
1
- export { createReleaseBranch, getReleasePRs, getReleasePRsWithInfo } from './gh-release-prs'
1
+ export { createReleaseBranch, getReleasePRs, getReleasePRsWithInfo, updateReleasePRBody } from './gh-release-prs'
2
2
  export type { ReleasePRInfo } from './gh-release-prs'
@@ -1,3 +1,3 @@
1
1
  export { validateGitHubCliAndAuth } from './gh-cli-auth'
2
- export { createReleaseBranch, getReleasePRs, getReleasePRsWithInfo } from './gh-release-prs'
2
+ export { createReleaseBranch, getReleasePRs, getReleasePRsWithInfo, updateReleasePRBody } from './gh-release-prs'
3
3
  export type { ReleasePRInfo } from './gh-release-prs'
@@ -145,7 +145,7 @@ export const getProjectVersions = async (config: JiraConfig): Promise<JiraVersio
145
145
  * @param config - Jira configuration
146
146
  * @returns JiraVersion if found, null otherwise
147
147
  */
148
- const findVersionByName = async (versionName: string, config: JiraConfig): Promise<JiraVersion | null> => {
148
+ export const findVersionByName = async (versionName: string, config: JiraConfig): Promise<JiraVersion | null> => {
149
149
  try {
150
150
  const versions = await getProjectVersions(config)
151
151
  const version = versions.find((v) => {
@@ -166,29 +166,20 @@ const findVersionByName = async (versionName: string, config: JiraConfig): Promi
166
166
  * @param config - Jira configuration
167
167
  * @returns Result containing updated version or error
168
168
  */
169
- const updateJiraVersion = async (
169
+ export const updateJiraVersion = async (
170
170
  params: UpdateJiraVersionParams,
171
171
  config: JiraConfig,
172
172
  ): Promise<UpdateJiraVersionResult> => {
173
173
  try {
174
174
  const { baseUrl, token, email } = config
175
175
 
176
- // Prepare request body - only include fields that are provided
177
- const requestBody: Record<string, any> = {
178
- released: params.released ?? true,
179
- archived: params.archived ?? false,
180
- }
181
-
182
- // Add releaseDate if provided, otherwise use current date when releasing
183
- if (params.releaseDate) {
184
- requestBody.releaseDate = params.releaseDate
185
- } else if (params.released !== false) {
186
- requestBody.releaseDate = new Date().toISOString().split('T')[0] // YYYY-MM-DD
187
- }
176
+ // Only include fields the caller explicitly passed.
177
+ const requestBody: Record<string, any> = {}
188
178
 
189
- if (params.description !== undefined) {
190
- requestBody.description = params.description
191
- }
179
+ if (params.released !== undefined) requestBody.released = params.released
180
+ if (params.archived !== undefined) requestBody.archived = params.archived
181
+ if (params.releaseDate !== undefined) requestBody.releaseDate = params.releaseDate
182
+ if (params.description !== undefined) requestBody.description = params.description
192
183
 
193
184
  const url = `${baseUrl}/rest/api/3/version/${params.versionId}`
194
185
  const credentials = btoa(`${email}:${token}`)
@@ -1,9 +1,11 @@
1
1
  export {
2
2
  createJiraVersion,
3
3
  deliverJiraRelease,
4
+ findVersionByName,
4
5
  getProjectVersions,
5
6
  loadJiraConfig,
6
7
  loadJiraConfigOptional,
8
+ updateJiraVersion,
7
9
  } from './api.js'
8
10
  export type {
9
11
  CreateJiraVersionParams,
@@ -37,11 +37,17 @@ const withTmpRepo = async (fn: (tmp: string) => Promise<void>): Promise<void> =>
37
37
 
38
38
  vi.mocked(getProjectRoot).mockResolvedValue(tmp)
39
39
  vi.mocked(getRepoName).mockResolvedValue(path.basename(tmp))
40
+ // Point os.homedir() at the tmp dir so user-scope override layers
41
+ // (~/.infra-kit/config.yml, ~/.infra-kit/projects/<repo>/infra-kit.yml)
42
+ // can't leak the developer's real config into the test.
43
+ const homedirSpy = vi.spyOn(os, 'homedir').mockReturnValue(tmp)
44
+
40
45
  resetInfraKitConfigCache()
41
46
 
42
47
  try {
43
48
  await fn(tmp)
44
49
  } finally {
50
+ homedirSpy.mockRestore()
45
51
  resetInfraKitConfigCache()
46
52
  fs.rmSync(tmp, { recursive: true, force: true })
47
53
  }
@@ -105,6 +111,50 @@ taskManager:
105
111
  })
106
112
  })
107
113
 
114
+ it('accepts a worktrees prompt-defaults block', async () => {
115
+ await withTmpRepo(async (tmp) => {
116
+ fs.writeFileSync(
117
+ path.join(tmp, 'infra-kit.yml'),
118
+ `environments: [dev]
119
+ envManagement:
120
+ provider: doppler
121
+ config:
122
+ name: p
123
+ worktrees:
124
+ openInGithubDesktop: false
125
+ openInCmux: true
126
+ `,
127
+ )
128
+
129
+ const cfg = await getInfraKitConfig()
130
+
131
+ expect(cfg.worktrees?.openInGithubDesktop).toBe(false)
132
+ expect(cfg.worktrees?.openInCmux).toBe(true)
133
+ })
134
+ })
135
+
136
+ it('lets the user-global config layer supply a worktrees block when the project omits it', async () => {
137
+ await withTmpRepo(async (tmp) => {
138
+ fs.writeFileSync(path.join(tmp, 'infra-kit.yml'), VALID_YML)
139
+
140
+ const userGlobalDir = path.join(tmp, '.infra-kit')
141
+
142
+ fs.mkdirSync(userGlobalDir, { recursive: true })
143
+ fs.writeFileSync(
144
+ path.join(userGlobalDir, 'config.yml'),
145
+ `worktrees:
146
+ openInGithubDesktop: false
147
+ openInCmux: true
148
+ `,
149
+ )
150
+
151
+ const cfg = await getInfraKitConfig()
152
+
153
+ expect(cfg.worktrees?.openInGithubDesktop).toBe(false)
154
+ expect(cfg.worktrees?.openInCmux).toBe(true)
155
+ })
156
+ })
157
+
108
158
  it('rejects ide.cursor mode=workspace without workspaceConfigPath', async () => {
109
159
  await withTmpRepo(async (tmp) => {
110
160
  fs.writeFileSync(
@@ -0,0 +1,62 @@
1
+ import { describe, expect, it } from 'vitest'
2
+
3
+ import { OperationError } from '../operation-error'
4
+
5
+ describe('operationError', () => {
6
+ it('formats a minimal message from operation alone', () => {
7
+ const err = new OperationError(new Error('boom'), { operation: 'do the thing' })
8
+
9
+ expect(err).toBeInstanceOf(OperationError)
10
+ expect(err.message).toBe('failed to do the thing')
11
+ expect(err.cause).toBeInstanceOf(Error)
12
+ expect(err.operation).toBe('do the thing')
13
+ })
14
+
15
+ it('appends a remediation hint when provided', () => {
16
+ const err = new OperationError(new Error('boom'), {
17
+ operation: 'do the thing',
18
+ remediation: 'check the docs',
19
+ })
20
+
21
+ expect(err.message).toBe('failed to do the thing — try: check the docs')
22
+ expect(err.remediation).toBe('check the docs')
23
+ })
24
+
25
+ it('extracts a stderr excerpt from a zx ProcessOutput-shaped cause', () => {
26
+ const zxLike = { stderr: 'fatal: not a git repository\n(more lines...)' }
27
+ const err = new OperationError(zxLike, { operation: 'run git' })
28
+
29
+ expect(err.message).toContain('stderr: fatal: not a git repository')
30
+ })
31
+
32
+ it('truncates oversized stderr excerpts', () => {
33
+ const huge = 'X'.repeat(5000)
34
+ const err = new OperationError({ stderr: huge }, { operation: 'run git' })
35
+
36
+ expect(err.message.length).toBeLessThan(300)
37
+ })
38
+
39
+ it('prefers an explicit stderrExcerpt over the cause stderr', () => {
40
+ const err = new OperationError(
41
+ { stderr: 'raw zx output' },
42
+ { operation: 'run git', stderrExcerpt: 'curated excerpt' },
43
+ )
44
+
45
+ expect(err.message).toContain('stderr: curated excerpt')
46
+ expect(err.message).not.toContain('raw zx output')
47
+ })
48
+
49
+ it('preserves the original cause', () => {
50
+ const cause = new Error('underlying')
51
+ const err = new OperationError(cause, { operation: 'do thing' })
52
+
53
+ expect(err.cause).toBe(cause)
54
+ })
55
+
56
+ it('handles non-Error causes', () => {
57
+ const err = new OperationError('a string', { operation: 'do thing' })
58
+
59
+ expect(err.message).toBe('failed to do thing')
60
+ expect(err.cause).toBe('a string')
61
+ })
62
+ })
@@ -0,0 +1,54 @@
1
+ const STDERR_EXCERPT_MAX_BYTES = 500
2
+ const STDOUT_EXCERPT_MAX_BYTES = 200
3
+
4
+ export interface ZxErrorFields {
5
+ exitCode?: number | null
6
+ stderr?: string
7
+ stdout?: string
8
+ message?: string
9
+ name?: string
10
+ }
11
+
12
+ const readTrimmedString = (value: unknown, max: number): string | undefined => {
13
+ if (typeof value !== 'string' || value.length === 0) return undefined
14
+
15
+ return value.slice(-max).trim()
16
+ }
17
+
18
+ /**
19
+ * Extract loggable fields from an error for `logger.error({ err: formatZxError(e) }, ...)`.
20
+ *
21
+ * pino's default `err` serializer handles `Error` subclasses but renders zx's
22
+ * `ProcessOutput` as `{}` because its informative fields (`stderr`, `stdout`,
23
+ * `exitCode`) are non-enumerable / on the prototype. This helper duck-types
24
+ * those fields so subprocess failures surface in logs instead of vanishing.
25
+ */
26
+ export const formatZxError = (error: unknown): ZxErrorFields => {
27
+ if (error === null || typeof error !== 'object') {
28
+ return { message: String(error) }
29
+ }
30
+
31
+ const rec = error as Record<string, unknown>
32
+ const fields: ZxErrorFields = {}
33
+
34
+ if (error instanceof Error) {
35
+ fields.name = error.name
36
+ fields.message = error.message
37
+ } else if (typeof rec.message === 'string') {
38
+ fields.message = rec.message
39
+ }
40
+
41
+ const exitCode = rec.exitCode
42
+
43
+ if (typeof exitCode === 'number' || exitCode === null) fields.exitCode = exitCode
44
+
45
+ const stderr = readTrimmedString(rec.stderr, STDERR_EXCERPT_MAX_BYTES)
46
+
47
+ if (stderr) fields.stderr = stderr
48
+
49
+ const stdout = readTrimmedString(rec.stdout, STDOUT_EXCERPT_MAX_BYTES)
50
+
51
+ if (stdout) fields.stdout = stdout
52
+
53
+ return fields
54
+ }
@@ -0,0 +1,80 @@
1
+ const STDERR_EXCERPT_MAX_BYTES = 200
2
+
3
+ export interface OperationErrorContext {
4
+ operation: string
5
+ remediation?: string
6
+ stderrExcerpt?: string
7
+ }
8
+
9
+ /**
10
+ * Duck-typed read of zx's `ProcessOutput.stderr` (and similar shapes) without
11
+ * importing zx types just for an `instanceof` check.
12
+ *
13
+ * @example
14
+ * extractStderr(new Error('x')) // undefined
15
+ * extractStderr({ stderr: 'fatal: ...' }) // 'fatal: ...'
16
+ * extractStderr({ stderr: '' }) // undefined (empty treated as missing)
17
+ */
18
+ const extractStderr = (cause: unknown): string | undefined => {
19
+ if (cause === null || typeof cause !== 'object') return undefined
20
+ const stderr = (cause as { stderr?: unknown }).stderr
21
+
22
+ return typeof stderr === 'string' && stderr.length > 0 ? stderr : undefined
23
+ }
24
+
25
+ /**
26
+ * Compose the human-and-agent-readable message body for an `OperationError`:
27
+ * `"failed to <operation> [— stderr: <excerpt>] [— try: <remediation>]"`.
28
+ * `stderrExcerpt` overrides anything duck-typed off `cause`; both are trimmed
29
+ * and capped at {@link STDERR_EXCERPT_MAX_BYTES} so a runaway subprocess can't
30
+ * blow up the message.
31
+ */
32
+ const buildMessage = (cause: unknown, ctx: OperationErrorContext): string => {
33
+ const stderr = ctx.stderrExcerpt ?? extractStderr(cause)
34
+ const parts = [`failed to ${ctx.operation}`]
35
+
36
+ if (stderr) parts.push(`stderr: ${stderr.slice(0, STDERR_EXCERPT_MAX_BYTES).trim()}`)
37
+ if (ctx.remediation) parts.push(`try: ${ctx.remediation}`)
38
+
39
+ return parts.join(' — ')
40
+ }
41
+
42
+ /**
43
+ * Error type for any handler-level failure that should surface to the caller
44
+ * (CLI user or MCP-connected agent) with a remediation hint. Wraps an
45
+ * underlying cause and renders a single-line, structured message so logs and
46
+ * agent tool-result text stay scannable.
47
+ *
48
+ * Pattern modeled on the exemplary Doppler errors in
49
+ * `src/integrations/doppler/doppler-cli-auth.ts`.
50
+ *
51
+ * @example
52
+ * // wrap a zx subprocess failure
53
+ * try {
54
+ * await $`git worktree add ${path} ${branch}`
55
+ * } catch (err) {
56
+ * throw new OperationError(err, {
57
+ * operation: `git worktree add for ${branch}`,
58
+ * remediation: 'check the branch name and that the parent dir is writable',
59
+ * })
60
+ * }
61
+ *
62
+ * @example
63
+ * // validation failure with no underlying cause
64
+ * throw new OperationError(undefined, {
65
+ * operation: 'launch deploy-all workflow',
66
+ * remediation: `pass one of: ${environments.join(', ')}`,
67
+ * stderrExcerpt: `invalid environment: ${selectedEnv}`,
68
+ * })
69
+ */
70
+ export class OperationError extends Error {
71
+ readonly operation: string
72
+ readonly remediation?: string
73
+
74
+ constructor(cause: unknown, ctx: OperationErrorContext) {
75
+ super(buildMessage(cause, ctx), { cause })
76
+ this.name = 'OperationError'
77
+ this.operation = ctx.operation
78
+ this.remediation = ctx.remediation
79
+ }
80
+ }
@@ -56,11 +56,18 @@ const jiraTaskManagerSchema = z.object({
56
56
 
57
57
  const taskManagerSchema = z.discriminatedUnion('provider', [jiraTaskManagerSchema])
58
58
 
59
+ // worktrees prompt defaults
60
+ const worktreesConfigSchema = z.object({
61
+ openInGithubDesktop: z.boolean().optional(),
62
+ openInCmux: z.boolean().optional(),
63
+ })
64
+
59
65
  const infraKitConfigSchema = z.object({
60
66
  environments: z.array(z.string().min(1)).min(1),
61
67
  envManagement: envManagementSchema,
62
68
  ide: ideSchema.optional(),
63
69
  taskManager: taskManagerSchema.optional(),
70
+ worktrees: worktreesConfigSchema.optional(),
64
71
  })
65
72
 
66
73
  const infraKitOverrideConfigSchema = infraKitConfigSchema.partial()