infra-kit 0.1.98 → 0.1.99

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.
@@ -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(
@@ -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()
@@ -4,8 +4,8 @@ import {
4
4
  NoPriorVersionsError,
5
5
  collectKnownVersions,
6
6
  computeNextVersion,
7
- resolveVersionTokens,
8
- splitVersionInput,
7
+ parseReleaseSpec,
8
+ resolveReleaseEntries,
9
9
  } from '../next-version'
10
10
 
11
11
  describe('collectKnownVersions', () => {
@@ -70,43 +70,148 @@ describe('computeNextVersion', () => {
70
70
  })
71
71
  })
72
72
 
73
- describe('resolveVersionTokens', () => {
74
- const known = collectKnownVersions({ remoteBranches: ['release/v1.63.0'] })
73
+ describe('parseReleaseSpec', () => {
74
+ it('parses bare version as regular with no description', () => {
75
+ expect(parseReleaseSpec('1.2.5')).toEqual({ version: '1.2.5', type: 'regular' })
76
+ })
77
+
78
+ it('parses version:type', () => {
79
+ expect(parseReleaseSpec('1.2.5:hotfix')).toEqual({ version: '1.2.5', type: 'hotfix' })
80
+ })
81
+
82
+ it('parses version:type:description', () => {
83
+ expect(parseReleaseSpec('1.2.5:regular:Holiday backend')).toEqual({
84
+ version: '1.2.5',
85
+ type: 'regular',
86
+ description: 'Holiday backend',
87
+ })
88
+ })
75
89
 
76
- it('resolves "next,next" sequentially for regular', () => {
77
- expect(resolveVersionTokens(['next', 'next'], 'regular', known)).toEqual(['1.64.0', '1.65.0'])
90
+ it('preserves colons inside the description', () => {
91
+ expect(parseReleaseSpec('1.2.5:regular:Fixes: A and B')).toEqual({
92
+ version: '1.2.5',
93
+ type: 'regular',
94
+ description: 'Fixes: A and B',
95
+ })
78
96
  })
79
97
 
80
- it('mixes literals and next, advancing running max', () => {
81
- expect(resolveVersionTokens(['next', '1.70.0', 'next'], 'regular', known)).toEqual(['1.64.0', '1.70.0', '1.71.0'])
98
+ it('accepts the literal "next" token', () => {
99
+ expect(parseReleaseSpec('next:hotfix')).toEqual({ version: 'next', type: 'hotfix' })
82
100
  })
83
101
 
84
- it('accepts NEXT and " next " (case + whitespace insensitive)', () => {
85
- expect(resolveVersionTokens(['NEXT', ' next '], 'regular', known)).toEqual(['1.64.0', '1.65.0'])
102
+ it('lowercases the type', () => {
103
+ expect(parseReleaseSpec('1.2.5:HOTFIX')).toEqual({ version: '1.2.5', type: 'hotfix' })
86
104
  })
87
105
 
88
- it('strips leading v on explicit versions', () => {
89
- expect(resolveVersionTokens(['v1.70.0'], 'regular', known)).toEqual(['1.70.0'])
106
+ it('drops empty description', () => {
107
+ expect(parseReleaseSpec('1.2.5:regular:')).toEqual({ version: '1.2.5', type: 'regular' })
90
108
  })
91
109
 
92
- it('throws on invalid token', () => {
110
+ it('throws on unknown type', () => {
93
111
  expect(() => {
94
- return resolveVersionTokens(['nope'], 'regular', known)
95
- }).toThrow(/Invalid version/)
112
+ return parseReleaseSpec('1.2.5:major')
113
+ }).toThrow(/Invalid release type/)
96
114
  })
97
115
 
98
- it('hotfix sequence advances patch each step', () => {
99
- expect(resolveVersionTokens(['next', 'next'], 'hotfix', known)).toEqual(['1.63.1', '1.63.2'])
116
+ it('throws on empty spec', () => {
117
+ expect(() => {
118
+ return parseReleaseSpec(' ')
119
+ }).toThrow(/empty/)
100
120
  })
101
121
  })
102
122
 
103
- describe('splitVersionInput', () => {
104
- it('splits comma-separated input and trims', () => {
105
- expect(splitVersionInput(' 1.2.3 , next, ,1.2.4 ')).toEqual(['1.2.3', 'next', '1.2.4'])
123
+ describe('resolveReleaseEntries', () => {
124
+ const known = collectKnownVersions({ remoteBranches: ['release/v1.63.0'] })
125
+
126
+ it('passes through explicit semver entries unchanged', () => {
127
+ expect(
128
+ resolveReleaseEntries(
129
+ [
130
+ { version: '1.70.0', type: 'regular' },
131
+ { version: 'v1.70.1', type: 'hotfix' },
132
+ ],
133
+ known,
134
+ ),
135
+ ).toEqual([
136
+ { version: '1.70.0', type: 'regular' },
137
+ { version: '1.70.1', type: 'hotfix' },
138
+ ])
139
+ })
140
+
141
+ it('resolves a single "next" using the entry type', () => {
142
+ expect(resolveReleaseEntries([{ version: 'next', type: 'regular' }], known)).toEqual([
143
+ { version: '1.64.0', type: 'regular' },
144
+ ])
145
+ })
146
+
147
+ it('advances sequential "next" tokens of the same type', () => {
148
+ expect(
149
+ resolveReleaseEntries(
150
+ [
151
+ { version: 'next', type: 'regular' },
152
+ { version: 'next', type: 'regular' },
153
+ ],
154
+ known,
155
+ ),
156
+ ).toEqual([
157
+ { version: '1.64.0', type: 'regular' },
158
+ { version: '1.65.0', type: 'regular' },
159
+ ])
106
160
  })
107
161
 
108
- it('returns empty array for empty input', () => {
109
- expect(splitVersionInput('')).toEqual([])
110
- expect(splitVersionInput(' ')).toEqual([])
162
+ it('advances sequential "next" tokens across mixed types', () => {
163
+ expect(
164
+ resolveReleaseEntries(
165
+ [
166
+ { version: 'next', type: 'regular' },
167
+ { version: 'next', type: 'hotfix' },
168
+ ],
169
+ known,
170
+ ),
171
+ ).toEqual([
172
+ { version: '1.64.0', type: 'regular' },
173
+ { version: '1.64.1', type: 'hotfix' },
174
+ ])
175
+ })
176
+
177
+ it('mixes literals and "next", advancing the running max', () => {
178
+ expect(
179
+ resolveReleaseEntries(
180
+ [
181
+ { version: 'next', type: 'regular' },
182
+ { version: '1.70.0', type: 'regular' },
183
+ { version: 'next', type: 'regular' },
184
+ ],
185
+ known,
186
+ ),
187
+ ).toEqual([
188
+ { version: '1.64.0', type: 'regular' },
189
+ { version: '1.70.0', type: 'regular' },
190
+ { version: '1.71.0', type: 'regular' },
191
+ ])
192
+ })
193
+
194
+ it('preserves description through resolution', () => {
195
+ expect(resolveReleaseEntries([{ version: 'next', type: 'regular', description: 'Holiday' }], known)).toEqual([
196
+ { version: '1.64.0', type: 'regular', description: 'Holiday' },
197
+ ])
198
+ })
199
+
200
+ it('accepts case-insensitive "next"', () => {
201
+ expect(resolveReleaseEntries([{ version: 'NEXT', type: 'regular' }], known)).toEqual([
202
+ { version: '1.64.0', type: 'regular' },
203
+ ])
204
+ })
205
+
206
+ it('throws on invalid version', () => {
207
+ expect(() => {
208
+ return resolveReleaseEntries([{ version: 'nope', type: 'regular' }], known)
209
+ }).toThrow(/Invalid version/)
210
+ })
211
+
212
+ it('throws NoPriorVersionsError when "next" with no known versions', () => {
213
+ expect(() => {
214
+ return resolveReleaseEntries([{ version: 'next', type: 'regular' }], [])
215
+ }).toThrow(NoPriorVersionsError)
111
216
  })
112
217
  })
@@ -3,10 +3,12 @@ export {
3
3
  collectKnownVersions,
4
4
  computeNextVersion,
5
5
  type ExistingVersionsSources,
6
+ hasNextToken,
6
7
  NEXT_TOKEN,
7
8
  NoPriorVersionsError,
8
- resolveVersionTokens,
9
+ parseReleaseSpec,
10
+ type ReleaseEntry,
11
+ resolveReleaseEntries,
9
12
  type SemVer,
10
- splitVersionInput,
11
13
  } from './next-version'
12
14
  export { parseVersion, sortVersions } from './version-utils'
@@ -97,26 +97,73 @@ const isNextToken = (token: string): boolean => {
97
97
  return token.trim().toLowerCase() === NEXT_TOKEN
98
98
  }
99
99
 
100
+ export interface ReleaseEntry {
101
+ version: string
102
+ type: ReleaseType
103
+ description?: string
104
+ }
105
+
106
+ const isReleaseType = (value: string): value is ReleaseType => {
107
+ return value === 'regular' || value === 'hotfix'
108
+ }
109
+
110
+ /**
111
+ * Parse a CLI release spec of the form `version[:type[:description]]`.
112
+ * Type defaults to "regular". Description is everything after the second
113
+ * colon, so colons inside descriptions are preserved.
114
+ */
115
+ export const parseReleaseSpec = (raw: string): ReleaseEntry => {
116
+ const spec = raw.trim()
117
+
118
+ if (spec === '') throw new Error('Release spec is empty')
119
+
120
+ const firstColon = spec.indexOf(':')
121
+
122
+ if (firstColon === -1) {
123
+ return { version: spec, type: 'regular' }
124
+ }
125
+
126
+ const version = spec.slice(0, firstColon).trim()
127
+ const rest = spec.slice(firstColon + 1)
128
+ const secondColon = rest.indexOf(':')
129
+
130
+ const typeRaw = secondColon === -1 ? rest.trim() : rest.slice(0, secondColon).trim()
131
+ const description = secondColon === -1 ? '' : rest.slice(secondColon + 1).trim()
132
+ const typeLower = typeRaw.toLowerCase()
133
+
134
+ if (!isReleaseType(typeLower)) {
135
+ throw new Error(`Invalid release type "${typeRaw}". Expected "regular" or "hotfix".`)
136
+ }
137
+
138
+ const entry: ReleaseEntry = { version, type: typeLower }
139
+
140
+ if (description !== '') entry.description = description
141
+
142
+ return entry
143
+ }
144
+
100
145
  /**
101
- * Resolve a list of input tokens (mix of "next" and explicit semver strings)
102
- * into concrete version strings. Each "next" advances based on the running
103
- * max so "next,next" produces sequential versions.
146
+ * Resolve a list of release entries (each with its own type and optional
147
+ * "next" version token) into entries with concrete versions. Each "next"
148
+ * advances based on the running max so successive "next" tokens produce
149
+ * sequential versions, even across mixed types.
104
150
  */
105
- export const resolveVersionTokens = (tokens: string[], type: ReleaseType, known: SemVer[]): string[] => {
151
+ export const resolveReleaseEntries = (entries: ReleaseEntry[], known: SemVer[]): ReleaseEntry[] => {
106
152
  const running: SemVer[] = [...known]
107
- const resolved: string[] = []
108
153
 
109
- for (const token of tokens) {
110
- const trimmed = token.trim()
154
+ return entries.map((entry) => {
155
+ const trimmed = entry.version.trim()
111
156
 
112
- if (trimmed === '') continue
157
+ if (trimmed === '') {
158
+ throw new Error('Release entry has an empty version')
159
+ }
113
160
 
114
161
  if (isNextToken(trimmed)) {
115
- const next = computeNextVersion(running, type)
162
+ const next = computeNextVersion(running, entry.type)
116
163
 
117
- resolved.push(next)
118
164
  running.push(parseVersion(`v${next}`))
119
- continue
165
+
166
+ return { ...entry, version: next }
120
167
  }
121
168
 
122
169
  const parsed = tryParse(trimmed)
@@ -127,22 +174,14 @@ export const resolveVersionTokens = (tokens: string[], type: ReleaseType, known:
127
174
 
128
175
  const explicit = `${parsed[0]}.${parsed[1]}.${parsed[2]}`
129
176
 
130
- resolved.push(explicit)
131
177
  running.push(parsed)
132
- }
133
178
 
134
- return resolved
179
+ return { ...entry, version: explicit }
180
+ })
135
181
  }
136
182
 
137
- /**
138
- * Split a raw user input into tokens, trimming and removing empties.
139
- * Accepts both whitespace-separated and comma-separated lists.
140
- */
141
- export const splitVersionInput = (input: string): string[] => {
142
- return input
143
- .split(',')
144
- .map((t) => {
145
- return t.trim()
146
- })
147
- .filter(Boolean)
183
+ export const hasNextToken = (entries: ReleaseEntry[]): boolean => {
184
+ return entries.some((e) => {
185
+ return isNextToken(e.version)
186
+ })
148
187
  }
@@ -10,7 +10,6 @@ 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 { releaseCreateBatchMcpTool } from 'src/commands/release-create-batch'
14
13
  import { versionMcpTool } from 'src/commands/version'
15
14
  import { worktreesAddMcpTool } from 'src/commands/worktrees-add'
16
15
  import { worktreesListMcpTool } from 'src/commands/worktrees-list'
@@ -26,7 +25,6 @@ const tools = [
26
25
  envClearMcpTool,
27
26
  ghMergeDevMcpTool,
28
27
  releaseCreateMcpTool,
29
- releaseCreateBatchMcpTool,
30
28
  ghReleaseDeliverMcpTool,
31
29
  ghReleaseDeployAllMcpTool,
32
30
  ghReleaseDeploySelectedMcpTool,