infra-kit 0.1.110 → 0.1.113
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-127837d3-a000-4656-9453-2324ab353723.jsonl +5 -0
- package/.omc/state/agent-replay-15c409af-24fa-4026-88d3-a82dd1e68b87.jsonl +11 -0
- package/.omc/state/agent-replay-74afa912-1443-4011-b4f2-db9f055cdd9f.jsonl +3 -0
- package/.omc/state/agent-replay-8dd0ad2f-3a90-4158-b9de-ec8e19a7c983.jsonl +3 -0
- package/.omc/state/agent-replay-d5aae73c-340d-4aa2-9fe0-1bf2d79b69dc.jsonl +10 -0
- package/.omc/state/idle-notif-cooldown.json +1 -1
- package/.omc/state/sessions/127837d3-a000-4656-9453-2324ab353723/mission-state.json +61 -0
- package/.omc/state/sessions/127837d3-a000-4656-9453-2324ab353723/pre-tool-advisory-throttle.json +22 -0
- package/.omc/state/sessions/127837d3-a000-4656-9453-2324ab353723/skill-active-state.json +15 -0
- package/.omc/state/sessions/127837d3-a000-4656-9453-2324ab353723/subagent-tracking-state.json +26 -0
- package/.omc/state/sessions/15c409af-24fa-4026-88d3-a82dd1e68b87/last-tool-error-state.json +7 -0
- package/.omc/state/sessions/15c409af-24fa-4026-88d3-a82dd1e68b87/mission-state.json +89 -0
- package/.omc/state/sessions/15c409af-24fa-4026-88d3-a82dd1e68b87/pre-tool-advisory-throttle.json +34 -0
- package/.omc/state/sessions/15c409af-24fa-4026-88d3-a82dd1e68b87/ralph-state.json +13 -0
- package/.omc/state/sessions/15c409af-24fa-4026-88d3-a82dd1e68b87/skill-active-state.json +15 -0
- package/.omc/state/sessions/15c409af-24fa-4026-88d3-a82dd1e68b87/subagent-tracking-state.json +44 -0
- package/.omc/state/sessions/15c409af-24fa-4026-88d3-a82dd1e68b87/ultrawork-state.json +11 -0
- package/.omc/state/sessions/74afa912-1443-4011-b4f2-db9f055cdd9f/pre-tool-advisory-throttle.json +18 -0
- package/.omc/state/sessions/74afa912-1443-4011-b4f2-db9f055cdd9f/skill-active-state.json +15 -0
- package/.omc/state/sessions/74afa912-1443-4011-b4f2-db9f055cdd9f/subagent-tracking-state.json +17 -0
- package/.omc/state/sessions/8dd0ad2f-3a90-4158-b9de-ec8e19a7c983/pre-tool-advisory-throttle.json +14 -0
- package/.omc/state/sessions/8dd0ad2f-3a90-4158-b9de-ec8e19a7c983/ralph-state.json +13 -0
- package/.omc/state/sessions/8dd0ad2f-3a90-4158-b9de-ec8e19a7c983/subagent-tracking-state.json +17 -0
- package/.omc/state/sessions/8dd0ad2f-3a90-4158-b9de-ec8e19a7c983/ultrawork-state.json +11 -0
- package/.omc/state/sessions/d5aae73c-340d-4aa2-9fe0-1bf2d79b69dc/last-tool-error-state.json +7 -0
- package/.omc/state/sessions/d5aae73c-340d-4aa2-9fe0-1bf2d79b69dc/mission-state.json +103 -0
- package/.omc/state/sessions/d5aae73c-340d-4aa2-9fe0-1bf2d79b69dc/pre-tool-advisory-throttle.json +30 -0
- package/.omc/state/sessions/d5aae73c-340d-4aa2-9fe0-1bf2d79b69dc/ralph-state.json +13 -0
- package/.omc/state/sessions/d5aae73c-340d-4aa2-9fe0-1bf2d79b69dc/subagent-tracking-state.json +62 -0
- package/.omc/state/sessions/d5aae73c-340d-4aa2-9fe0-1bf2d79b69dc/ultrawork-state.json +11 -0
- package/.turbo/turbo-build.log +6 -6
- package/.turbo/turbo-eslint-check.log +1 -1
- package/.turbo/turbo-prettier-check.log +1 -1
- package/.turbo/turbo-prettier-fix.log +1 -2
- package/.turbo/turbo-test.log +79 -70
- package/.turbo/turbo-ts-check.log +1 -1
- package/dist/cli.js +64 -56
- package/dist/cli.js.map +4 -4
- package/dist/mcp.js +39 -39
- package/dist/mcp.js.map +4 -4
- package/package.json +1 -1
- package/src/.omc/state/agent-replay-8dd0ad2f-3a90-4158-b9de-ec8e19a7c983.jsonl +11 -0
- package/src/.omc/state/idle-notif-cooldown.json +3 -0
- package/src/.omc/state/sessions/8dd0ad2f-3a90-4158-b9de-ec8e19a7c983/mission-state.json +79 -0
- package/src/.omc/state/sessions/8dd0ad2f-3a90-4158-b9de-ec8e19a7c983/pre-tool-advisory-throttle.json +22 -0
- package/src/.omc/state/sessions/8dd0ad2f-3a90-4158-b9de-ec8e19a7c983/ralph-state.json +13 -0
- package/src/.omc/state/sessions/8dd0ad2f-3a90-4158-b9de-ec8e19a7c983/subagent-tracking-state.json +26 -0
- package/src/.omc/state/sessions/8dd0ad2f-3a90-4158-b9de-ec8e19a7c983/ultrawork-state.json +11 -0
- package/src/commands/doctor/.omc/state/sessions/8dd0ad2f-3a90-4158-b9de-ec8e19a7c983/pre-tool-advisory-throttle.json +14 -0
- package/src/commands/doctor/__tests__/check-ide-installed.test.ts +142 -0
- package/src/commands/doctor/doctor.ts +98 -1
- package/src/commands/gh-release-deliver/gh-release-deliver.ts +32 -1
- package/src/commands/init/.omc/state/sessions/8dd0ad2f-3a90-4158-b9de-ec8e19a7c983/pre-tool-advisory-throttle.json +18 -0
- package/src/commands/init/__tests__/migrate-config.test.ts +132 -1
- package/src/commands/init/init.ts +19 -6
- package/src/commands/init/migrate-config.ts +104 -0
- package/src/commands/release-create/release-create.ts +5 -13
- package/src/commands/worktrees-add/index.ts +1 -2
- package/src/commands/worktrees-add/worktrees-add.ts +24 -60
- package/src/commands/worktrees-open/worktrees-open.ts +55 -68
- package/src/commands/worktrees-reload/__tests__/reload-cmux.test.ts +119 -0
- package/src/commands/worktrees-reload/index.ts +1 -0
- package/src/commands/worktrees-reload/worktrees-reload.ts +229 -0
- package/src/commands/worktrees-remove/worktrees-remove.ts +2 -44
- package/src/commands/worktrees-sync/worktrees-sync.ts +2 -44
- package/src/entry/cli.ts +35 -25
- package/src/integrations/cursor/index.ts +1 -0
- package/src/integrations/cursor/open-cursor-workspace.ts +70 -0
- package/src/integrations/ide/__tests__/ide-facade.test.ts +242 -0
- package/src/integrations/ide/add-ide-worktree-folders.ts +79 -0
- package/src/integrations/ide/index.ts +6 -0
- package/src/integrations/ide/open-ide-workspace.ts +51 -0
- package/src/integrations/ide/provider-label.ts +24 -0
- package/src/integrations/ide/remove-ide-worktree-folders.ts +80 -0
- package/src/integrations/ide/types.ts +32 -0
- package/src/integrations/zed/__tests__/open-zed-workspace.test.ts +85 -0
- package/src/integrations/zed/add-folders-to-zed-workspace.ts +30 -0
- package/src/integrations/zed/index.ts +2 -0
- package/src/integrations/zed/open-zed-workspace.ts +57 -0
- package/src/lib/assert-never/assert-never.ts +15 -0
- package/src/lib/assert-never/index.ts +1 -0
- package/src/lib/git-utils/__tests__/git-primitives.test.ts +122 -1
- package/src/lib/git-utils/git-utils.ts +35 -0
- package/src/lib/git-utils/index.ts +2 -0
- package/src/lib/infra-kit-config/.omc/state/sessions/8dd0ad2f-3a90-4158-b9de-ec8e19a7c983/pre-tool-advisory-throttle.json +18 -0
- package/src/lib/infra-kit-config/__tests__/infra-kit-config.test.ts +172 -9
- package/src/lib/infra-kit-config/index.ts +2 -1
- package/src/lib/infra-kit-config/infra-kit-config.ts +80 -22
- package/src/lib/package-config/.omc/state/sessions/dde22a92-a0e9-4948-905d-25c9a812ab35/last-tool-error-state.json +7 -0
- package/src/lib/package-config/.omc/state/sessions/dde22a92-a0e9-4948-905d-25c9a812ab35/pre-tool-advisory-throttle.json +10 -0
- package/src/lib/version-utils/__tests__/next-version.test.ts +84 -0
- package/src/lib/version-utils/index.ts +1 -0
- package/src/lib/version-utils/next-version.ts +56 -15
- package/src/mcp/tools/index.ts +2 -0
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { $ } from 'zx'
|
|
2
|
+
|
|
3
|
+
import { logger } from 'src/lib/logger'
|
|
4
|
+
|
|
5
|
+
interface OpenZedWorkspaceArgs {
|
|
6
|
+
projectRoot: string
|
|
7
|
+
worktreeDir: string
|
|
8
|
+
currentBranches: string[]
|
|
9
|
+
/**
|
|
10
|
+
* When true, skip launching Zed if there are no release worktrees. Lets
|
|
11
|
+
* `worktrees-reload` avoid popping a bare Zed window while keeping the
|
|
12
|
+
* cold-start `worktrees-open` behaviour (launch unconditionally) intact.
|
|
13
|
+
*/
|
|
14
|
+
skipRelaunchWhenEmpty: boolean
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface OpenZedWorkspaceOutcome {
|
|
18
|
+
ran: boolean
|
|
19
|
+
added: number
|
|
20
|
+
removed: number
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Opens a single Zed workspace containing the project root plus every release
|
|
25
|
+
* worktree, via one `zed <root> <wt...>` invocation (Zed realizes a multi-folder
|
|
26
|
+
* workspace from multiple path arguments). Shared by `worktrees-open` (cold-start
|
|
27
|
+
* restore) and `worktrees-reload` (relaunch).
|
|
28
|
+
*
|
|
29
|
+
* Zed has no portable workspace file and no folder-remove CLI, so there is
|
|
30
|
+
* nothing to reconcile: `added` is the number of worktrees opened and `removed`
|
|
31
|
+
* is always 0. All failures are swallowed into a warning — opening Zed is
|
|
32
|
+
* best-effort, mirroring the Cursor integration.
|
|
33
|
+
*/
|
|
34
|
+
export const openZedWorkspace = async (args: OpenZedWorkspaceArgs): Promise<OpenZedWorkspaceOutcome> => {
|
|
35
|
+
const { projectRoot, worktreeDir, currentBranches, skipRelaunchWhenEmpty } = args
|
|
36
|
+
|
|
37
|
+
if (skipRelaunchWhenEmpty && currentBranches.length === 0) {
|
|
38
|
+
return { ran: false, added: 0, removed: 0 }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const paths = [
|
|
42
|
+
projectRoot,
|
|
43
|
+
...currentBranches.map((branch) => {
|
|
44
|
+
return `${worktreeDir}/${branch}`
|
|
45
|
+
}),
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
try {
|
|
49
|
+
await $`zed ${paths}`
|
|
50
|
+
|
|
51
|
+
return { ran: true, added: currentBranches.length, removed: 0 }
|
|
52
|
+
} catch (error) {
|
|
53
|
+
logger.warn({ error }, '⚠️ Failed to open Zed workspace')
|
|
54
|
+
|
|
55
|
+
return { ran: false, added: 0, removed: 0 }
|
|
56
|
+
}
|
|
57
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Exhaustiveness guard for discriminated unions. Place in the `default` branch of
|
|
3
|
+
* a `switch` over a union: once every case is handled the argument narrows to
|
|
4
|
+
* `never`, so adding a new variant later turns this into a compile error.
|
|
5
|
+
*
|
|
6
|
+
* @example
|
|
7
|
+
* switch (provider) {
|
|
8
|
+
* case 'cursor': return openCursor()
|
|
9
|
+
* case 'zed': return openZed()
|
|
10
|
+
* default: return assertNever(provider) // compile error if a provider is unhandled
|
|
11
|
+
* }
|
|
12
|
+
*/
|
|
13
|
+
export const assertNever = (value: never): never => {
|
|
14
|
+
throw new Error(`Unexpected value: ${JSON.stringify(value)}`)
|
|
15
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { assertNever } from './assert-never'
|
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
deleteLocalBranch,
|
|
5
|
+
deleteRemoteBranch,
|
|
6
|
+
getCurrentBranch,
|
|
7
|
+
isInsideLinkedWorktree,
|
|
8
|
+
isWorkingTreeClean,
|
|
9
|
+
} from 'src/lib/git-utils'
|
|
4
10
|
|
|
5
11
|
// Controllable per-command git output. The mocked `$` dispatches on the command
|
|
6
12
|
// text so a single test can make `--absolute-git-dir` and `--git-common-dir`
|
|
@@ -12,6 +18,12 @@ const git = vi.hoisted(() => {
|
|
|
12
18
|
status: '',
|
|
13
19
|
absoluteGitDir: '/repo/.git',
|
|
14
20
|
commonDir: '.git',
|
|
21
|
+
branchList: '',
|
|
22
|
+
lsRemote: '',
|
|
23
|
+
lsRemoteThrows: false,
|
|
24
|
+
localDeleteThrows: false,
|
|
25
|
+
remoteDeleteThrows: false,
|
|
26
|
+
calls: [] as string[],
|
|
15
27
|
}
|
|
16
28
|
})
|
|
17
29
|
|
|
@@ -19,11 +31,29 @@ vi.mock('zx', () => {
|
|
|
19
31
|
const run = (strings: TemplateStringsArray): Promise<{ stdout: string }> => {
|
|
20
32
|
const command = strings.join('')
|
|
21
33
|
|
|
34
|
+
git.calls.push(command)
|
|
35
|
+
|
|
22
36
|
if (command.includes('--show-toplevel')) return Promise.resolve({ stdout: `${git.toplevel}\n` })
|
|
23
37
|
if (command.includes('--abbrev-ref HEAD')) return Promise.resolve({ stdout: `${git.branch}\n` })
|
|
24
38
|
if (command.includes('status --porcelain')) return Promise.resolve({ stdout: git.status })
|
|
25
39
|
if (command.includes('--absolute-git-dir')) return Promise.resolve({ stdout: `${git.absoluteGitDir}\n` })
|
|
26
40
|
if (command.includes('--git-common-dir')) return Promise.resolve({ stdout: `${git.commonDir}\n` })
|
|
41
|
+
if (command.includes('branch --list')) return Promise.resolve({ stdout: git.branchList })
|
|
42
|
+
if (command.includes('branch -D')) {
|
|
43
|
+
return git.localDeleteThrows
|
|
44
|
+
? Promise.reject(new Error('branch checked out in another worktree'))
|
|
45
|
+
: Promise.resolve({ stdout: '' })
|
|
46
|
+
}
|
|
47
|
+
if (command.includes('ls-remote --heads')) {
|
|
48
|
+
return git.lsRemoteThrows
|
|
49
|
+
? Promise.reject(new Error('could not read from remote repository'))
|
|
50
|
+
: Promise.resolve({ stdout: git.lsRemote })
|
|
51
|
+
}
|
|
52
|
+
if (command.includes('push origin --delete')) {
|
|
53
|
+
return git.remoteDeleteThrows
|
|
54
|
+
? Promise.reject(new Error('remote ref does not exist'))
|
|
55
|
+
: Promise.resolve({ stdout: '' })
|
|
56
|
+
}
|
|
27
57
|
|
|
28
58
|
return Promise.resolve({ stdout: '' })
|
|
29
59
|
}
|
|
@@ -94,3 +124,94 @@ describe('isInsideLinkedWorktree', () => {
|
|
|
94
124
|
await expect(isInsideLinkedWorktree()).resolves.toBe(true)
|
|
95
125
|
})
|
|
96
126
|
})
|
|
127
|
+
|
|
128
|
+
const ranLocalDelete = (): boolean => {
|
|
129
|
+
return git.calls.some((c) => {
|
|
130
|
+
return c.includes('branch -D')
|
|
131
|
+
})
|
|
132
|
+
}
|
|
133
|
+
const ranRemoteDelete = (): boolean => {
|
|
134
|
+
return git.calls.some((c) => {
|
|
135
|
+
return c.includes('push origin --delete')
|
|
136
|
+
})
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
describe('deleteLocalBranch', () => {
|
|
140
|
+
beforeEach(() => {
|
|
141
|
+
git.branch = 'dev'
|
|
142
|
+
git.branchList = ''
|
|
143
|
+
git.localDeleteThrows = false
|
|
144
|
+
git.calls = []
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
it('force-deletes the branch when it exists and is not current', async () => {
|
|
148
|
+
git.branchList = ' release/v1.2.3\n'
|
|
149
|
+
|
|
150
|
+
await deleteLocalBranch('release/v1.2.3')
|
|
151
|
+
|
|
152
|
+
expect(ranLocalDelete()).toBe(true)
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
it('is a no-op when the branch does not exist locally', async () => {
|
|
156
|
+
git.branchList = ''
|
|
157
|
+
|
|
158
|
+
await deleteLocalBranch('release/v1.2.3')
|
|
159
|
+
|
|
160
|
+
expect(ranLocalDelete()).toBe(false)
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
it('is a no-op when the branch is the current checkout', async () => {
|
|
164
|
+
git.branch = 'release/v1.2.3'
|
|
165
|
+
git.branchList = '* release/v1.2.3\n'
|
|
166
|
+
|
|
167
|
+
await deleteLocalBranch('release/v1.2.3')
|
|
168
|
+
|
|
169
|
+
expect(ranLocalDelete()).toBe(false)
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
it('propagates when the delete itself fails (e.g. checked out in another worktree)', async () => {
|
|
173
|
+
git.branchList = ' release/v1.2.3\n'
|
|
174
|
+
git.localDeleteThrows = true
|
|
175
|
+
|
|
176
|
+
await expect(deleteLocalBranch('release/v1.2.3')).rejects.toThrow()
|
|
177
|
+
})
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
describe('deleteRemoteBranch', () => {
|
|
181
|
+
beforeEach(() => {
|
|
182
|
+
git.lsRemote = ''
|
|
183
|
+
git.lsRemoteThrows = false
|
|
184
|
+
git.remoteDeleteThrows = false
|
|
185
|
+
git.calls = []
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
it('deletes the remote branch when ls-remote finds it', async () => {
|
|
189
|
+
git.lsRemote = 'abc123\trefs/heads/release/v1.2.3\n'
|
|
190
|
+
|
|
191
|
+
await deleteRemoteBranch('release/v1.2.3')
|
|
192
|
+
|
|
193
|
+
expect(ranRemoteDelete()).toBe(true)
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
it('is a no-op when the branch does not exist on the remote', async () => {
|
|
197
|
+
git.lsRemote = ''
|
|
198
|
+
|
|
199
|
+
await deleteRemoteBranch('release/v1.2.3')
|
|
200
|
+
|
|
201
|
+
expect(ranRemoteDelete()).toBe(false)
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
it('propagates a network/auth failure instead of treating it as "branch absent"', async () => {
|
|
205
|
+
git.lsRemoteThrows = true
|
|
206
|
+
|
|
207
|
+
await expect(deleteRemoteBranch('release/v1.2.3')).rejects.toThrow()
|
|
208
|
+
expect(ranRemoteDelete()).toBe(false)
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
it('propagates when the remote delete itself fails', async () => {
|
|
212
|
+
git.lsRemote = 'abc123\trefs/heads/release/v1.2.3\n'
|
|
213
|
+
git.remoteDeleteThrows = true
|
|
214
|
+
|
|
215
|
+
await expect(deleteRemoteBranch('release/v1.2.3')).rejects.toThrow()
|
|
216
|
+
})
|
|
217
|
+
})
|
|
@@ -142,3 +142,38 @@ export const getRepoName = async (): Promise<string> => {
|
|
|
142
142
|
|
|
143
143
|
return path.basename(projectRoot)
|
|
144
144
|
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Delete a local branch if it exists and is not the current checkout.
|
|
148
|
+
*
|
|
149
|
+
* Idempotent: a no-op when the branch is absent (`git branch --list` prints
|
|
150
|
+
* nothing). Uses force `-D` because a delivered release branch was
|
|
151
|
+
* squash-merged — its tip is unreachable from the base, so `-d` would refuse
|
|
152
|
+
* with "not fully merged". The delete itself still rejects if the branch is
|
|
153
|
+
* checked out in another worktree; callers decide how to handle that.
|
|
154
|
+
*/
|
|
155
|
+
export const deleteLocalBranch = async (branch: string): Promise<void> => {
|
|
156
|
+
const listed = await $`git branch --list ${branch}`
|
|
157
|
+
|
|
158
|
+
if (listed.stdout.trim().length === 0) return
|
|
159
|
+
|
|
160
|
+
if ((await getCurrentBranch()) === branch) return
|
|
161
|
+
|
|
162
|
+
await $`git branch -D ${branch}`
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Delete a branch on the `origin` remote if it exists.
|
|
167
|
+
*
|
|
168
|
+
* Idempotent: a no-op when the branch is absent on the remote. Existence is
|
|
169
|
+
* probed with `git ls-remote --heads` (empty stdout = absent) rather than
|
|
170
|
+
* `--exit-code`, so a genuine network/auth failure rejects and propagates to
|
|
171
|
+
* the caller instead of being silently misread as "branch absent".
|
|
172
|
+
*/
|
|
173
|
+
export const deleteRemoteBranch = async (branch: string): Promise<void> => {
|
|
174
|
+
const refs = await $`git ls-remote --heads origin ${branch}`
|
|
175
|
+
|
|
176
|
+
if (refs.stdout.trim().length === 0) return
|
|
177
|
+
|
|
178
|
+
await $`git push origin --delete ${branch}`
|
|
179
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 1,
|
|
3
|
+
"entries": {
|
|
4
|
+
"79a93d4a2f8f50b95f852280616242fee1855dc99a3c75211917f55e72e95fae": {
|
|
5
|
+
"last_emitted_at_ms": 1782040911131,
|
|
6
|
+
"message": "Use parallel execution for independent tasks. Use run_in_background for long operations (npm install, builds, tests)."
|
|
7
|
+
},
|
|
8
|
+
"445ed27a3872b681d98190bae61ccb84954e1bc4e140df5370be958dee776b3a": {
|
|
9
|
+
"last_emitted_at_ms": 1782040633210,
|
|
10
|
+
"message": "Verify changes work after editing. Test functionality before marking complete."
|
|
11
|
+
},
|
|
12
|
+
"466399dafa2d20f60587180bad0a07358eb7f2bce724df0ff682f038ad33f5ad": {
|
|
13
|
+
"last_emitted_at_ms": 1782040627481,
|
|
14
|
+
"message": "Read multiple files in parallel when possible for faster analysis."
|
|
15
|
+
}
|
|
16
|
+
},
|
|
17
|
+
"updated_at": "2026-06-21T11:21:51.131Z"
|
|
18
|
+
}
|
|
@@ -6,7 +6,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
|
6
6
|
// Import AFTER the mock is declared so the module picks up the mocked dep.
|
|
7
7
|
import { getProjectRoot, getRepoName } from 'src/lib/git-utils'
|
|
8
8
|
|
|
9
|
-
import { getInfraKitConfig, resetInfraKitConfigCache } from '../infra-kit-config'
|
|
9
|
+
import { getInfraKitConfig, resetInfraKitConfigCache, resolveConfiguredIdes } from '../infra-kit-config'
|
|
10
|
+
import type { InfraKitConfig } from '../infra-kit-config'
|
|
10
11
|
|
|
11
12
|
vi.mock('src/lib/git-utils', () => {
|
|
12
13
|
return {
|
|
@@ -76,24 +77,143 @@ describe('getInfraKitConfig', () => {
|
|
|
76
77
|
JSON.stringify({
|
|
77
78
|
environments: ['dev'],
|
|
78
79
|
envManagement: { provider: 'doppler', config: { name: 'p' } },
|
|
79
|
-
ide: { provider: 'cursor', config: {
|
|
80
|
+
ide: { provider: 'cursor', config: { workspaceConfigPath: './ws.code-workspace' } },
|
|
80
81
|
taskManager: { provider: 'jira', config: { baseUrl: 'https://example.atlassian.net', projectId: 123 } },
|
|
81
82
|
}),
|
|
82
83
|
)
|
|
83
84
|
|
|
84
85
|
const cfg = await getInfraKitConfig()
|
|
85
86
|
|
|
86
|
-
|
|
87
|
+
const ide = resolveConfiguredIdes(cfg)[0]
|
|
87
88
|
|
|
88
|
-
if (
|
|
89
|
-
|
|
90
|
-
|
|
89
|
+
if (!ide) {
|
|
90
|
+
throw new Error('expected one configured ide')
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
expect(ide.provider).toBe('cursor')
|
|
94
|
+
|
|
95
|
+
if (ide.provider === 'cursor') {
|
|
96
|
+
expect(ide.config.workspaceConfigPath).toBe('./ws.code-workspace')
|
|
91
97
|
}
|
|
92
98
|
|
|
93
99
|
expect(cfg.taskManager?.provider).toBe('jira')
|
|
94
100
|
})
|
|
95
101
|
})
|
|
96
102
|
|
|
103
|
+
it('accepts a zed ide provider with no workspaceConfigPath', async () => {
|
|
104
|
+
await withTmpRepo(async (tmp) => {
|
|
105
|
+
fs.writeFileSync(
|
|
106
|
+
path.join(tmp, 'infra-kit.json'),
|
|
107
|
+
JSON.stringify({
|
|
108
|
+
environments: ['dev'],
|
|
109
|
+
envManagement: { provider: 'doppler', config: { name: 'p' } },
|
|
110
|
+
ide: { provider: 'zed', config: {} },
|
|
111
|
+
}),
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
const cfg = await getInfraKitConfig()
|
|
115
|
+
|
|
116
|
+
expect(resolveConfiguredIdes(cfg)[0]?.provider).toBe('zed')
|
|
117
|
+
})
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
it('strips a legacy "mode" key (backward compat)', async () => {
|
|
121
|
+
await withTmpRepo(async (tmp) => {
|
|
122
|
+
fs.writeFileSync(
|
|
123
|
+
path.join(tmp, 'infra-kit.json'),
|
|
124
|
+
JSON.stringify({
|
|
125
|
+
environments: ['dev'],
|
|
126
|
+
envManagement: { provider: 'doppler', config: { name: 'p' } },
|
|
127
|
+
ide: { provider: 'zed', config: { mode: 'windows' } },
|
|
128
|
+
}),
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
const cfg = await getInfraKitConfig()
|
|
132
|
+
|
|
133
|
+
const ide = resolveConfiguredIdes(cfg)[0]
|
|
134
|
+
|
|
135
|
+
if (!ide) {
|
|
136
|
+
throw new Error('expected one configured ide')
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
expect(ide.provider).toBe('zed')
|
|
140
|
+
// The now-removed `mode` field is silently stripped, not rejected.
|
|
141
|
+
expect(ide.config).not.toHaveProperty('mode')
|
|
142
|
+
})
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
it('accepts an array of IDE providers (multi-editor)', async () => {
|
|
146
|
+
await withTmpRepo(async (tmp) => {
|
|
147
|
+
fs.writeFileSync(
|
|
148
|
+
path.join(tmp, 'infra-kit.json'),
|
|
149
|
+
JSON.stringify({
|
|
150
|
+
environments: ['dev'],
|
|
151
|
+
envManagement: { provider: 'doppler', config: { name: 'p' } },
|
|
152
|
+
ide: [
|
|
153
|
+
{ provider: 'cursor', config: { workspaceConfigPath: './ws.code-workspace' } },
|
|
154
|
+
{ provider: 'zed', config: {} },
|
|
155
|
+
],
|
|
156
|
+
}),
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
const cfg = await getInfraKitConfig()
|
|
160
|
+
|
|
161
|
+
expect(
|
|
162
|
+
resolveConfiguredIdes(cfg).map((ide) => {
|
|
163
|
+
return ide.provider
|
|
164
|
+
}),
|
|
165
|
+
).toEqual(['cursor', 'zed'])
|
|
166
|
+
})
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
it('rejects an array with a duplicate provider at parse time', async () => {
|
|
170
|
+
await withTmpRepo(async (tmp) => {
|
|
171
|
+
fs.writeFileSync(
|
|
172
|
+
path.join(tmp, 'infra-kit.json'),
|
|
173
|
+
JSON.stringify({
|
|
174
|
+
environments: ['dev'],
|
|
175
|
+
envManagement: { provider: 'doppler', config: { name: 'p' } },
|
|
176
|
+
ide: [
|
|
177
|
+
{ provider: 'cursor', config: { workspaceConfigPath: './a.code-workspace' } },
|
|
178
|
+
{ provider: 'cursor', config: { workspaceConfigPath: './b.code-workspace' } },
|
|
179
|
+
],
|
|
180
|
+
}),
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
await expect(getInfraKitConfig()).rejects.toThrow(/each IDE provider may appear at most once/)
|
|
184
|
+
})
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
it('rejects an empty IDE array', async () => {
|
|
188
|
+
await withTmpRepo(async (tmp) => {
|
|
189
|
+
fs.writeFileSync(
|
|
190
|
+
path.join(tmp, 'infra-kit.json'),
|
|
191
|
+
JSON.stringify({
|
|
192
|
+
environments: ['dev'],
|
|
193
|
+
envManagement: { provider: 'doppler', config: { name: 'p' } },
|
|
194
|
+
ide: [],
|
|
195
|
+
}),
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
await expect(getInfraKitConfig()).rejects.toThrow(/Invalid infra-kit\.json/)
|
|
199
|
+
})
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
it('rejects an unknown ide provider', async () => {
|
|
203
|
+
await withTmpRepo(async (tmp) => {
|
|
204
|
+
fs.writeFileSync(
|
|
205
|
+
path.join(tmp, 'infra-kit.json'),
|
|
206
|
+
JSON.stringify({
|
|
207
|
+
environments: ['dev'],
|
|
208
|
+
envManagement: { provider: 'doppler', config: { name: 'p' } },
|
|
209
|
+
ide: { provider: 'vscode', config: {} },
|
|
210
|
+
}),
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
await expect(getInfraKitConfig()).rejects.toThrow(/Invalid infra-kit\.json/)
|
|
214
|
+
})
|
|
215
|
+
})
|
|
216
|
+
|
|
97
217
|
it('accepts a worktrees prompt-defaults block', async () => {
|
|
98
218
|
await withTmpRepo(async (tmp) => {
|
|
99
219
|
fs.writeFileSync(
|
|
@@ -154,18 +274,21 @@ describe('getInfraKitConfig', () => {
|
|
|
154
274
|
})
|
|
155
275
|
})
|
|
156
276
|
|
|
157
|
-
it('rejects
|
|
277
|
+
it('rejects cursor without workspaceConfigPath', async () => {
|
|
158
278
|
await withTmpRepo(async (tmp) => {
|
|
159
279
|
fs.writeFileSync(
|
|
160
280
|
path.join(tmp, 'infra-kit.json'),
|
|
161
281
|
JSON.stringify({
|
|
162
282
|
environments: ['dev'],
|
|
163
283
|
envManagement: { provider: 'doppler', config: { name: 'p' } },
|
|
164
|
-
ide: { provider: 'cursor', config: {
|
|
284
|
+
ide: { provider: 'cursor', config: {} },
|
|
165
285
|
}),
|
|
166
286
|
)
|
|
167
287
|
|
|
168
|
-
|
|
288
|
+
// Cursor requires workspaceConfigPath; with the single|array union the
|
|
289
|
+
// inner message collapses to the union-level "Invalid input → at ide",
|
|
290
|
+
// but the config is still rejected (which is what matters).
|
|
291
|
+
await expect(getInfraKitConfig()).rejects.toThrow(/Invalid infra-kit\.json/)
|
|
169
292
|
})
|
|
170
293
|
})
|
|
171
294
|
|
|
@@ -268,3 +391,43 @@ describe('getInfraKitConfig', () => {
|
|
|
268
391
|
})
|
|
269
392
|
})
|
|
270
393
|
})
|
|
394
|
+
|
|
395
|
+
describe('resolveConfiguredIdes', () => {
|
|
396
|
+
const base = {
|
|
397
|
+
environments: ['dev'],
|
|
398
|
+
envManagement: { provider: 'doppler', config: { name: 'p' } },
|
|
399
|
+
} as InfraKitConfig
|
|
400
|
+
|
|
401
|
+
it('wraps a single ide object in an array', () => {
|
|
402
|
+
const cfg = {
|
|
403
|
+
...base,
|
|
404
|
+
ide: { provider: 'cursor', config: { workspaceConfigPath: 'ws' } },
|
|
405
|
+
} as InfraKitConfig
|
|
406
|
+
|
|
407
|
+
expect(
|
|
408
|
+
resolveConfiguredIdes(cfg).map((ide) => {
|
|
409
|
+
return ide.provider
|
|
410
|
+
}),
|
|
411
|
+
).toEqual(['cursor'])
|
|
412
|
+
})
|
|
413
|
+
|
|
414
|
+
it('returns an ide array as-is', () => {
|
|
415
|
+
const cfg = {
|
|
416
|
+
...base,
|
|
417
|
+
ide: [
|
|
418
|
+
{ provider: 'cursor', config: { workspaceConfigPath: 'ws' } },
|
|
419
|
+
{ provider: 'zed', config: {} },
|
|
420
|
+
],
|
|
421
|
+
} as InfraKitConfig
|
|
422
|
+
|
|
423
|
+
expect(
|
|
424
|
+
resolveConfiguredIdes(cfg).map((ide) => {
|
|
425
|
+
return ide.provider
|
|
426
|
+
}),
|
|
427
|
+
).toEqual(['cursor', 'zed'])
|
|
428
|
+
})
|
|
429
|
+
|
|
430
|
+
it('returns an empty array when ide is unset', () => {
|
|
431
|
+
expect(resolveConfiguredIdes(base)).toEqual([])
|
|
432
|
+
})
|
|
433
|
+
})
|
|
@@ -4,5 +4,6 @@ export {
|
|
|
4
4
|
infraKitConfigSchema,
|
|
5
5
|
infraKitOverrideConfigSchema,
|
|
6
6
|
resetInfraKitConfigCache,
|
|
7
|
+
resolveConfiguredIdes,
|
|
7
8
|
} from './infra-kit-config'
|
|
8
|
-
export type { InfraKitConfig, InfraKitConfigPaths } from './infra-kit-config'
|
|
9
|
+
export type { ConfiguredIde, InfraKitConfig, InfraKitConfigPaths } from './infra-kit-config'
|
|
@@ -22,27 +22,36 @@ const dopplerEnvManagementSchema = z.object({
|
|
|
22
22
|
const envManagementSchema = z.discriminatedUnion('provider', [dopplerEnvManagementSchema])
|
|
23
23
|
|
|
24
24
|
// ide
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
(v) => {
|
|
32
|
-
return v.mode !== 'workspace' || !!v.workspaceConfigPath
|
|
33
|
-
},
|
|
34
|
-
{
|
|
35
|
-
message: 'workspaceConfigPath is required when mode is "workspace"',
|
|
36
|
-
path: ['workspaceConfigPath'],
|
|
37
|
-
},
|
|
38
|
-
)
|
|
25
|
+
// There is one attach style: each worktree is added to the configured editor's
|
|
26
|
+
// workspace and opened (no per-window mode). Cursor needs a `.code-workspace`
|
|
27
|
+
// path to reconcile its `folders` array against.
|
|
28
|
+
const cursorIdeConfigSchema = z.object({
|
|
29
|
+
workspaceConfigPath: z.string().min(1),
|
|
30
|
+
})
|
|
39
31
|
|
|
40
32
|
const cursorIdeSchema = z.object({
|
|
41
33
|
provider: z.literal('cursor'),
|
|
42
34
|
config: cursorIdeConfigSchema,
|
|
43
35
|
})
|
|
44
36
|
|
|
45
|
-
|
|
37
|
+
// Zed has no portable workspace file (no `.code-workspace`) and no folder-remove
|
|
38
|
+
// CLI: a multi-worktree workspace is realized by a single `zed <root> <wt...>`
|
|
39
|
+
// invocation. So `config` carries no settings — there's no path to point at.
|
|
40
|
+
const zedIdeConfigSchema = z.object({})
|
|
41
|
+
|
|
42
|
+
const zedIdeSchema = z.object({
|
|
43
|
+
provider: z.literal('zed'),
|
|
44
|
+
config: zedIdeConfigSchema,
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
const ideSchema = z.discriminatedUnion('provider', [cursorIdeSchema, zedIdeSchema])
|
|
48
|
+
|
|
49
|
+
// `ide` accepts a single provider (back-compat) OR an array to drive multiple
|
|
50
|
+
// editors at once (e.g. Cursor + Zed). Normalized to an array everywhere via
|
|
51
|
+
// `resolveConfiguredIdes`. Uniqueness-by-provider is enforced at parse time by a
|
|
52
|
+
// `.superRefine` on the full config schema (see below) — not here, so the message
|
|
53
|
+
// survives `z.union` error aggregation.
|
|
54
|
+
const idesSchema = z.union([ideSchema, z.array(ideSchema).min(1)])
|
|
46
55
|
|
|
47
56
|
// taskManager
|
|
48
57
|
const jiraTaskManagerSchema = z.object({
|
|
@@ -61,18 +70,67 @@ const worktreesConfigSchema = z.object({
|
|
|
61
70
|
openInCmux: z.boolean().optional(),
|
|
62
71
|
})
|
|
63
72
|
|
|
64
|
-
|
|
73
|
+
// Base object shape, kept separate so `.partial()` (which only works on a plain
|
|
74
|
+
// ZodObject, not the `.superRefine`-wrapped full schema) can derive the override
|
|
75
|
+
// schema from it.
|
|
76
|
+
const infraKitConfigObject = z.object({
|
|
65
77
|
environments: z.array(z.string().min(1)).min(1),
|
|
66
78
|
envManagement: envManagementSchema,
|
|
67
|
-
ide:
|
|
79
|
+
ide: idesSchema.optional(),
|
|
68
80
|
taskManager: taskManagerSchema.optional(),
|
|
69
81
|
worktrees: worktreesConfigSchema.optional(),
|
|
70
82
|
})
|
|
71
83
|
|
|
72
|
-
|
|
84
|
+
// Full schema = base object + a parse-time uniqueness check on the `ide` array.
|
|
85
|
+
// This runs inside the *merged* `safeParse` in getInfraKitConfig, so it's the
|
|
86
|
+
// gate for the final config. (The override layers use the `.partial()` form
|
|
87
|
+
// below, which drops this object-level refinement — acceptable, the merged
|
|
88
|
+
// parse is authoritative.)
|
|
89
|
+
export const infraKitConfigSchema = infraKitConfigObject.superRefine((cfg, ctx) => {
|
|
90
|
+
if (!Array.isArray(cfg.ide)) return
|
|
91
|
+
|
|
92
|
+
const seen = new Set<string>()
|
|
93
|
+
|
|
94
|
+
for (const entry of cfg.ide) {
|
|
95
|
+
if (seen.has(entry.provider)) {
|
|
96
|
+
ctx.addIssue({
|
|
97
|
+
code: 'custom',
|
|
98
|
+
message: 'each IDE provider may appear at most once',
|
|
99
|
+
path: ['ide'],
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
return
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
seen.add(entry.provider)
|
|
106
|
+
}
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
export const infraKitOverrideConfigSchema = infraKitConfigObject.partial()
|
|
73
110
|
|
|
74
111
|
export type InfraKitConfig = z.infer<typeof infraKitConfigSchema>
|
|
75
112
|
|
|
113
|
+
/** A single resolved IDE entry (`{ provider, config }`). */
|
|
114
|
+
export type ConfiguredIde = z.infer<typeof ideSchema>
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Normalize the `ide` config (single object, array, or unset) into a flat list.
|
|
118
|
+
* Validation-free: assumes already-parsed input (uniqueness is enforced by the
|
|
119
|
+
* schema). The one source of truth for "which editors are configured."
|
|
120
|
+
*
|
|
121
|
+
* @example
|
|
122
|
+
* resolveConfiguredIdes({ ide: { provider: 'cursor', config: {...} } }) // => [cursor]
|
|
123
|
+
* resolveConfiguredIdes({ ide: [cursor, zed] }) // => [cursor, zed]
|
|
124
|
+
* resolveConfiguredIdes({}) // => []
|
|
125
|
+
*/
|
|
126
|
+
export const resolveConfiguredIdes = (config: InfraKitConfig): ConfiguredIde[] => {
|
|
127
|
+
const ide = config.ide
|
|
128
|
+
|
|
129
|
+
if (!ide) return []
|
|
130
|
+
|
|
131
|
+
return Array.isArray(ide) ? ide : [ide]
|
|
132
|
+
}
|
|
133
|
+
|
|
76
134
|
export interface InfraKitConfigPaths {
|
|
77
135
|
/** Committed project config (required). */
|
|
78
136
|
main: string
|
|
@@ -131,9 +189,9 @@ export const getInfraKitConfigPaths = async (): Promise<InfraKitConfigPaths> =>
|
|
|
131
189
|
*
|
|
132
190
|
* @example
|
|
133
191
|
* // infra-kit.json: { "environments": ["dev"], "envManagement": { "provider": "doppler", "config": { "name": "p" } } }
|
|
134
|
-
* // ~/.infra-kit/config.json: { "ide": { "provider": "cursor", "config": { "
|
|
192
|
+
* // ~/.infra-kit/config.json: { "ide": { "provider": "cursor", "config": { "workspaceConfigPath": "./ws.code-workspace" } } }
|
|
135
193
|
* const cfg = await getInfraKitConfig()
|
|
136
|
-
* // => { environments: ['dev'], envManagement: {...}, ide: { provider: 'cursor', config: {
|
|
194
|
+
* // => { environments: ['dev'], envManagement: {...}, ide: { provider: 'cursor', config: { workspaceConfigPath: './ws.code-workspace' } } }
|
|
137
195
|
*/
|
|
138
196
|
export const getInfraKitConfig = async (): Promise<InfraKitConfig> => {
|
|
139
197
|
const paths = await getInfraKitConfigPaths()
|
|
@@ -281,9 +339,9 @@ interface ConfigLayer {
|
|
|
281
339
|
* // => null
|
|
282
340
|
*
|
|
283
341
|
* @example
|
|
284
|
-
* // /home/me/.infra-kit/config.json: '{ "ide": { "provider": "cursor", "config": { "
|
|
342
|
+
* // /home/me/.infra-kit/config.json: '{ "ide": { "provider": "cursor", "config": { "workspaceConfigPath": "./ws.code-workspace" } } }'
|
|
285
343
|
* await loadLayer({ label: '~/.infra-kit/config.json', path: '/home/me/.infra-kit/config.json', required: false })
|
|
286
|
-
* // => { ide: { provider: 'cursor', config: {
|
|
344
|
+
* // => { ide: { provider: 'cursor', config: { workspaceConfigPath: './ws.code-workspace' } } }
|
|
287
345
|
*/
|
|
288
346
|
const loadLayer = async (layer: ConfigLayer): Promise<Record<string, unknown> | null> => {
|
|
289
347
|
const raw = await readIfExists(layer.path)
|