infra-kit 0.1.99 → 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.
- package/.eslintcache +1 -1
- package/.omc/state/agent-replay-d367c3be-9c2a-48e7-bcea-b45861af568c.jsonl +2 -0
- package/.omc/state/agent-replay-f2846d8f-974c-486c-b16f-4bdaa28ca45f.jsonl +1 -0
- package/.omc/state/last-tool-error.json +7 -0
- package/.omc/state/subagent-tracking.json +7 -0
- package/.turbo/turbo-eslint-check.log +1 -4
- package/.turbo/turbo-eslint-fix.log +1 -0
- package/.turbo/turbo-prettier-check.log +1 -4
- package/.turbo/turbo-prettier-fix.log +1 -10
- package/.turbo/turbo-test.log +5 -11
- package/.turbo/turbo-ts-check.log +1 -4
- package/dist/cli.js +45 -36
- package/dist/cli.js.map +4 -4
- package/dist/mcp.js +42 -34
- package/dist/mcp.js.map +4 -4
- package/package.json +11 -11
- package/src/commands/doctor/doctor.ts +62 -12
- package/src/commands/env-clear/env-clear.ts +5 -10
- package/src/commands/env-list/env-list.ts +5 -10
- package/src/commands/env-load/env-load.ts +5 -10
- package/src/commands/env-status/env-status.ts +5 -10
- package/src/commands/gh-merge-dev/gh-merge-dev.ts +17 -18
- package/src/commands/gh-release-deliver/gh-release-deliver.ts +290 -89
- package/src/commands/gh-release-deploy-all/gh-release-deploy-all.ts +15 -14
- package/src/commands/gh-release-deploy-selected/gh-release-deploy-selected.ts +30 -23
- package/src/commands/gh-release-list/gh-release-list.ts +5 -10
- package/src/commands/release-create/release-create.ts +22 -18
- package/src/commands/release-desc-edit/index.ts +1 -0
- package/src/commands/release-desc-edit/release-desc-edit.ts +207 -0
- package/src/commands/version/version.ts +5 -10
- package/src/commands/worktrees-add/worktrees-add.ts +18 -14
- package/src/commands/worktrees-list/worktrees-list.ts +6 -11
- package/src/commands/worktrees-open/worktrees-open.ts +10 -6
- package/src/commands/worktrees-remove/worktrees-remove.ts +18 -14
- package/src/commands/worktrees-sync/worktrees-sync.ts +17 -12
- package/src/entry/cli.ts +16 -0
- package/src/integrations/gh/gh-release-prs/gh-release-prs.ts +21 -0
- package/src/integrations/gh/gh-release-prs/index.ts +1 -1
- package/src/integrations/gh/index.ts +1 -1
- package/src/integrations/jira/api.ts +8 -17
- package/src/integrations/jira/index.ts +2 -0
- package/src/lib/errors/__tests__/operation-error.test.ts +62 -0
- package/src/lib/errors/format-zx-error.ts +54 -0
- package/src/lib/errors/operation-error.ts +80 -0
- package/src/mcp/tools/index.ts +2 -0
- package/src/types.ts +56 -2
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -9,16 +9,18 @@ import { removeFoldersFromCursorWorkspace, resolveCursorWorkspacePath } from 'sr
|
|
|
9
9
|
import { getReleasePRsWithInfo } from 'src/integrations/gh'
|
|
10
10
|
import { commandEcho } from 'src/lib/command-echo'
|
|
11
11
|
import { WORKTREES_DIR_SUFFIX } from 'src/lib/constants'
|
|
12
|
+
import { OperationError } from 'src/lib/errors/operation-error'
|
|
12
13
|
import { getCurrentWorktrees, getProjectRoot, getRepoName } from 'src/lib/git-utils'
|
|
13
14
|
import { getInfraKitConfig } from 'src/lib/infra-kit-config'
|
|
14
15
|
import { logger } from 'src/lib/logger'
|
|
15
16
|
import { detectReleaseType, formatBranchChoices, getJiraDescriptions } from 'src/lib/release-utils'
|
|
16
17
|
import type { ReleaseType } from 'src/lib/release-utils'
|
|
17
|
-
import
|
|
18
|
+
import { defineMcpTool, textContent } from 'src/types'
|
|
19
|
+
import type { RequiredConfirmedOptionArg } from 'src/types'
|
|
18
20
|
|
|
19
21
|
// Constants
|
|
20
22
|
interface WorktreeManagementArgs extends RequiredConfirmedOptionArg {
|
|
21
|
-
all
|
|
23
|
+
all?: boolean
|
|
22
24
|
versions?: string
|
|
23
25
|
}
|
|
24
26
|
|
|
@@ -26,7 +28,7 @@ interface WorktreeManagementArgs extends RequiredConfirmedOptionArg {
|
|
|
26
28
|
* Manage git worktrees for release branches
|
|
27
29
|
* Creates worktrees for active release branches and removes unused ones
|
|
28
30
|
*/
|
|
29
|
-
export const worktreesRemove = async (options: WorktreeManagementArgs)
|
|
31
|
+
export const worktreesRemove = async (options: WorktreeManagementArgs) => {
|
|
30
32
|
const { confirmedCommand, all, versions } = options
|
|
31
33
|
|
|
32
34
|
commandEcho.start('worktrees-remove')
|
|
@@ -40,7 +42,7 @@ export const worktreesRemove = async (options: WorktreeManagementArgs): Promise<
|
|
|
40
42
|
commandEcho.print()
|
|
41
43
|
|
|
42
44
|
return {
|
|
43
|
-
content:
|
|
45
|
+
content: textContent(JSON.stringify({ removedWorktrees: [], count: 0 }, null, 2)),
|
|
44
46
|
structuredContent: { removedWorktrees: [], count: 0 },
|
|
45
47
|
}
|
|
46
48
|
}
|
|
@@ -131,17 +133,15 @@ export const worktreesRemove = async (options: WorktreeManagementArgs): Promise<
|
|
|
131
133
|
}
|
|
132
134
|
|
|
133
135
|
return {
|
|
134
|
-
content:
|
|
135
|
-
{
|
|
136
|
-
type: 'text',
|
|
137
|
-
text: JSON.stringify(structuredContent, null, 2),
|
|
138
|
-
},
|
|
139
|
-
],
|
|
136
|
+
content: textContent(JSON.stringify(structuredContent, null, 2)),
|
|
140
137
|
structuredContent,
|
|
141
138
|
}
|
|
142
139
|
} catch (error) {
|
|
143
140
|
logger.error({ error }, '❌ Error managing worktrees')
|
|
144
|
-
throw error
|
|
141
|
+
throw new OperationError(error, {
|
|
142
|
+
operation: 'remove worktrees',
|
|
143
|
+
remediation: "check 'git worktree list' for the path; uncommitted changes block removal",
|
|
144
|
+
})
|
|
145
145
|
}
|
|
146
146
|
}
|
|
147
147
|
|
|
@@ -179,8 +179,12 @@ const removeWorktrees = async (args: RemoveWorktreesArgs): Promise<string[]> =>
|
|
|
179
179
|
removed.push(result.value)
|
|
180
180
|
} else {
|
|
181
181
|
const branch = branches[index]
|
|
182
|
+
const err = new OperationError(result.reason, {
|
|
183
|
+
operation: `remove worktree for ${branch}`,
|
|
184
|
+
remediation: "check 'git worktree list' for the path; uncommitted changes block removal",
|
|
185
|
+
})
|
|
182
186
|
|
|
183
|
-
logger.error({ error: result.reason
|
|
187
|
+
logger.error({ error: result.reason, msg: err.message })
|
|
184
188
|
}
|
|
185
189
|
}
|
|
186
190
|
|
|
@@ -252,7 +256,7 @@ const logResults = (removed: string[]): void => {
|
|
|
252
256
|
}
|
|
253
257
|
|
|
254
258
|
// MCP Tool Registration
|
|
255
|
-
export const worktreesRemoveMcpTool = {
|
|
259
|
+
export const worktreesRemoveMcpTool = defineMcpTool({
|
|
256
260
|
name: 'worktrees-remove',
|
|
257
261
|
description:
|
|
258
262
|
'Remove local git worktrees for release branches. When everything is removed, also runs "git worktree prune" and deletes the worktrees directory. When invoked via MCP, pass either "versions" (comma-separated) or all=true — the branch picker is unreachable without a TTY, and the CLI confirmation is auto-skipped for MCP calls, so the caller is responsible for gating.',
|
|
@@ -275,4 +279,4 @@ export const worktreesRemoveMcpTool = {
|
|
|
275
279
|
count: z.number().describe('Number of git worktrees removed'),
|
|
276
280
|
},
|
|
277
281
|
handler: worktreesRemove,
|
|
278
|
-
}
|
|
282
|
+
})
|
|
@@ -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
|
|
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)
|
|
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
|
-
|
|
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
|
@@ -15,6 +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 { releaseDescEdit } from 'src/commands/release-desc-edit'
|
|
18
19
|
import { version } from 'src/commands/version'
|
|
19
20
|
import { CURSOR_MODES, worktreesAdd } from 'src/commands/worktrees-add'
|
|
20
21
|
import type { CursorMode } from 'src/commands/worktrees-add'
|
|
@@ -102,6 +103,20 @@ program
|
|
|
102
103
|
})
|
|
103
104
|
})
|
|
104
105
|
|
|
106
|
+
program
|
|
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)')
|
|
111
|
+
.option('-y, --yes', 'Skip confirmation prompt')
|
|
112
|
+
.action(async (options) => {
|
|
113
|
+
await releaseDescEdit({
|
|
114
|
+
version: options.version,
|
|
115
|
+
description: options.description,
|
|
116
|
+
confirmedCommand: options.yes,
|
|
117
|
+
})
|
|
118
|
+
})
|
|
119
|
+
|
|
105
120
|
program
|
|
106
121
|
.command('release-deploy-all')
|
|
107
122
|
.description('Deploy any release branch to any environment')
|
|
@@ -263,6 +278,7 @@ if (process.argv.length <= 2) {
|
|
|
263
278
|
'merge-dev',
|
|
264
279
|
'release-list',
|
|
265
280
|
'release-create',
|
|
281
|
+
'release-desc-edit',
|
|
266
282
|
'release-deploy-all',
|
|
267
283
|
'release-deploy-selected',
|
|
268
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
|
-
//
|
|
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.
|
|
190
|
-
|
|
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}`)
|
|
@@ -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
|
+
}
|
package/src/mcp/tools/index.ts
CHANGED
|
@@ -10,6 +10,7 @@ import { ghReleaseDeployAllMcpTool } from 'src/commands/gh-release-deploy-all'
|
|
|
10
10
|
import { ghReleaseDeploySelectedMcpTool } from 'src/commands/gh-release-deploy-selected'
|
|
11
11
|
import { ghReleaseListMcpTool } from 'src/commands/gh-release-list'
|
|
12
12
|
import { releaseCreateMcpTool } from 'src/commands/release-create'
|
|
13
|
+
import { releaseDescEditMcpTool } from 'src/commands/release-desc-edit'
|
|
13
14
|
import { versionMcpTool } from 'src/commands/version'
|
|
14
15
|
import { worktreesAddMcpTool } from 'src/commands/worktrees-add'
|
|
15
16
|
import { worktreesListMcpTool } from 'src/commands/worktrees-list'
|
|
@@ -25,6 +26,7 @@ const tools = [
|
|
|
25
26
|
envClearMcpTool,
|
|
26
27
|
ghMergeDevMcpTool,
|
|
27
28
|
releaseCreateMcpTool,
|
|
29
|
+
releaseDescEditMcpTool,
|
|
28
30
|
ghReleaseDeliverMcpTool,
|
|
29
31
|
ghReleaseDeployAllMcpTool,
|
|
30
32
|
ghReleaseDeploySelectedMcpTool,
|
package/src/types.ts
CHANGED
|
@@ -1,12 +1,66 @@
|
|
|
1
|
-
|
|
1
|
+
import type { z } from 'zod/v4'
|
|
2
|
+
|
|
3
|
+
export interface ToolsExecutionResult<TStructured = Record<string, unknown>> {
|
|
2
4
|
[x: string]: unknown
|
|
3
5
|
content: {
|
|
4
6
|
type: 'text'
|
|
5
7
|
text: string
|
|
6
8
|
}[]
|
|
7
|
-
structuredContent?:
|
|
9
|
+
structuredContent?: TStructured
|
|
8
10
|
}
|
|
9
11
|
|
|
10
12
|
export interface RequiredConfirmedOptionArg {
|
|
11
13
|
confirmedCommand: boolean
|
|
12
14
|
}
|
|
15
|
+
|
|
16
|
+
export interface McpTool<TIn extends z.ZodRawShape = z.ZodRawShape, TOut extends z.ZodRawShape = z.ZodRawShape> {
|
|
17
|
+
name: string
|
|
18
|
+
description: string
|
|
19
|
+
inputSchema: TIn
|
|
20
|
+
outputSchema: TOut
|
|
21
|
+
handler: (
|
|
22
|
+
params: z.infer<z.ZodObject<TIn>> & RequiredConfirmedOptionArg,
|
|
23
|
+
) => Promise<ToolsExecutionResult<z.infer<z.ZodObject<TOut>>>>
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Build the dual-channel content array shared by every MCP tool. Narrows the
|
|
28
|
+
* literal `type: 'text'` so handlers can use inferred return types without TS
|
|
29
|
+
* widening `type` to `string` — which would otherwise break assignability
|
|
30
|
+
* against the MCP SDK's content union.
|
|
31
|
+
*
|
|
32
|
+
* @example
|
|
33
|
+
* return {
|
|
34
|
+
* content: textContent(JSON.stringify(structuredContent, null, 2)),
|
|
35
|
+
* structuredContent,
|
|
36
|
+
* }
|
|
37
|
+
*/
|
|
38
|
+
export const textContent = (text: string): ToolsExecutionResult['content'] => {
|
|
39
|
+
return [{ type: 'text', text }]
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Factory that ties the handler's return type to the declared `outputSchema`
|
|
44
|
+
* so `structuredContent` is checked against the schema at compile time. If a
|
|
45
|
+
* handler accidentally drops or renames a field, TS errors at the registration
|
|
46
|
+
* site rather than at runtime in an MCP client.
|
|
47
|
+
*
|
|
48
|
+
* @example
|
|
49
|
+
* export const envLoadMcpTool = defineMcpTool({
|
|
50
|
+
* name: 'env-load',
|
|
51
|
+
* description: '...',
|
|
52
|
+
* inputSchema: { config: z.string() },
|
|
53
|
+
* outputSchema: {
|
|
54
|
+
* filePath: z.string(),
|
|
55
|
+
* variableCount: z.number(),
|
|
56
|
+
* project: z.string(),
|
|
57
|
+
* config: z.string(),
|
|
58
|
+
* },
|
|
59
|
+
* handler: envLoad,
|
|
60
|
+
* })
|
|
61
|
+
*/
|
|
62
|
+
export const defineMcpTool = <TIn extends z.ZodRawShape, TOut extends z.ZodRawShape>(
|
|
63
|
+
tool: McpTool<TIn, TOut>,
|
|
64
|
+
): McpTool<TIn, TOut> => {
|
|
65
|
+
return tool
|
|
66
|
+
}
|