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.
- 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 +2 -4
- package/.turbo/turbo-eslint-fix.log +1 -0
- package/.turbo/turbo-prettier-check.log +2 -4
- package/.turbo/turbo-prettier-fix.log +1 -10
- package/.turbo/turbo-test.log +12 -191
- package/.turbo/turbo-ts-check.log +2 -9
- package/dist/cli.js +69 -44
- package/dist/cli.js.map +4 -4
- package/dist/mcp.js +45 -34
- package/dist/mcp.js.map +4 -4
- package/package.json +11 -11
- package/src/commands/config/config.ts +1 -1
- 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/init/init.ts +17 -6
- package/src/commands/release-create/release-create.ts +223 -139
- 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 +34 -26
- 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 +24 -21
- 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/__tests__/infra-kit-config.test.ts +50 -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/lib/infra-kit-config/infra-kit-config.ts +7 -0
- package/src/lib/version-utils/__tests__/next-version.test.ts +128 -23
- package/src/lib/version-utils/index.ts +4 -2
- package/src/lib/version-utils/next-version.ts +64 -25
- package/src/mcp/tools/index.ts +2 -2
- package/src/types.ts +56 -2
- package/tsconfig.tsbuildinfo +1 -1
- package/src/commands/release-create-batch/index.ts +0 -1
- 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
|
|
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
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import select, { Separator } from '@inquirer/select'
|
|
2
|
-
import { Command
|
|
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 {
|
|
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
|
|
88
|
+
.description('Create one or more release branches (each entry can mix regular/hotfix and its own description)')
|
|
84
89
|
.option(
|
|
85
|
-
'-
|
|
86
|
-
'
|
|
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
|
-
|
|
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-
|
|
102
|
-
.description(
|
|
103
|
-
.option(
|
|
104
|
-
|
|
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
|
|
111
|
-
|
|
112
|
-
|
|
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-
|
|
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
|
-
//
|
|
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}`)
|
|
@@ -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()
|