infra-kit 0.1.97 → 0.1.98

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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "infra-kit",
3
3
  "type": "module",
4
- "version": "0.1.97",
4
+ "version": "0.1.98",
5
5
  "description": "infra-kit",
6
6
  "main": "dist/cli.js",
7
7
  "module": "dist/cli.js",
@@ -0,0 +1,125 @@
1
+ import fs from 'node:fs/promises'
2
+ import os from 'node:os'
3
+ import path from 'node:path'
4
+ import process from 'node:process'
5
+ import { $ } from 'zx'
6
+
7
+ import { getInfraKitConfigPaths, resetInfraKitConfigCache } from 'src/lib/infra-kit-config'
8
+ import { logger } from 'src/lib/logger'
9
+ import type { ToolsExecutionResult } from 'src/types'
10
+
11
+ /**
12
+ * Resolve whether a file is reachable, suppressing ENOENT into a boolean.
13
+ *
14
+ * @example
15
+ * await fileExists('/etc/hosts') // => true
16
+ * await fileExists('/nope.txt') // => false
17
+ */
18
+ const fileExists = async (filePath: string): Promise<boolean> => {
19
+ try {
20
+ await fs.access(filePath)
21
+
22
+ return true
23
+ } catch {
24
+ return false
25
+ }
26
+ }
27
+
28
+ /**
29
+ * Replace the user's home prefix with `~` so logged paths stay short and
30
+ * portable across machines. Leaves non-home paths untouched.
31
+ *
32
+ * @example
33
+ * // os.homedir() === '/Users/arthur'
34
+ * tildify('/Users/arthur/.infra-kit/config.yml') // => '~/.infra-kit/config.yml'
35
+ * tildify('/etc/hosts') // => '/etc/hosts'
36
+ */
37
+ const tildify = (filePath: string): string => {
38
+ const home = os.homedir()
39
+
40
+ return filePath.startsWith(home) ? `~${filePath.slice(home.length)}` : filePath
41
+ }
42
+
43
+ /**
44
+ * Print the file paths that participate in the config merge chain along with
45
+ * existence markers, so the user can see at a glance which override layers
46
+ * are active.
47
+ *
48
+ * @example
49
+ * // CLI: `infra-kit config path`
50
+ * // INFO: Project name: api
51
+ * // INFO: Config merge chain (later overrides earlier):
52
+ * // INFO: [✓] project (committed) ~/projects/api/infra-kit.yml
53
+ * // INFO: [ ] user global ~/.infra-kit/config.yml
54
+ * // INFO: [✓] user project ~/.infra-kit/projects/api/infra-kit.yml
55
+ */
56
+ export const configPath = async (): Promise<ToolsExecutionResult> => {
57
+ const paths = await getInfraKitConfigPaths()
58
+
59
+ const rows: { label: string; path: string; exists: boolean }[] = await Promise.all(
60
+ [
61
+ { label: 'project (committed)', path: paths.main },
62
+ { label: 'user global', path: paths.userGlobal },
63
+ { label: 'user project', path: paths.userProject },
64
+ ].map(async (row) => {
65
+ return { ...row, exists: await fileExists(row.path) }
66
+ }),
67
+ )
68
+
69
+ logger.info(`Project name: ${paths.projectName}\n`)
70
+ logger.info('Config merge chain (later overrides earlier):\n')
71
+
72
+ for (const row of rows) {
73
+ const marker = row.exists ? ' [✓]' : ' [ ]'
74
+
75
+ logger.info(`${marker} ${row.label.padEnd(22)} ${tildify(row.path)}`)
76
+ }
77
+
78
+ const structuredContent = {
79
+ projectName: paths.projectName,
80
+ layers: rows.map((r) => {
81
+ return { label: r.label, path: r.path, exists: r.exists }
82
+ }),
83
+ }
84
+
85
+ return {
86
+ content: [{ type: 'text', text: JSON.stringify(structuredContent, null, 2) }],
87
+ structuredContent,
88
+ }
89
+ }
90
+
91
+ /**
92
+ * Open the user-scope per-project override file in $EDITOR, creating the
93
+ * parent directory and a stub file on first use. Resets the config cache
94
+ * after the editor exits so subsequent reads pick up edits without a restart.
95
+ *
96
+ * @example
97
+ * // CLI: `infra-kit config edit`
98
+ * // first run — creates ~/.infra-kit/projects/api/infra-kit.yml from a stub, then $EDITOR opens it
99
+ * // subsequent runs — opens the existing file as-is
100
+ */
101
+ export const configEdit = async (): Promise<ToolsExecutionResult> => {
102
+ const paths = await getInfraKitConfigPaths()
103
+ const editor = process.env.EDITOR || process.env.VISUAL || 'vi'
104
+
105
+ await fs.mkdir(path.dirname(paths.userProject), { recursive: true })
106
+
107
+ if (!(await fileExists(paths.userProject))) {
108
+ const stub = `# infra-kit user override for ${paths.projectName}\n# This file is shallow-merged on top of project infra-kit.yml.\n# Top-level keys (envManagement, ide, taskManager, environments) replace wholesale.\n`
109
+
110
+ await fs.writeFile(paths.userProject, stub, 'utf-8')
111
+ }
112
+
113
+ logger.info(`Opening ${tildify(paths.userProject)} in ${editor}`)
114
+
115
+ await $({ stdio: 'inherit' })`${editor} ${paths.userProject}`
116
+
117
+ resetInfraKitConfigCache()
118
+
119
+ const structuredContent = { path: paths.userProject, editor }
120
+
121
+ return {
122
+ content: [{ type: 'text', text: JSON.stringify(structuredContent, null, 2) }],
123
+ structuredContent,
124
+ }
125
+ }
@@ -0,0 +1 @@
1
+ export { configEdit, configPath } from './config'
@@ -6,12 +6,10 @@ import { $ } from 'zx'
6
6
 
7
7
  import { MARKER_END, MARKER_START, buildShellBlock } from 'src/commands/init/init'
8
8
  import { getProjectRoot } from 'src/lib/git-utils/git-utils'
9
- import { getInfraKitConfig, resetInfraKitConfigCache } from 'src/lib/infra-kit-config'
9
+ import { getInfraKitConfig, getInfraKitConfigPaths, resetInfraKitConfigCache } from 'src/lib/infra-kit-config'
10
10
  import { logger } from 'src/lib/logger'
11
11
  import type { ToolsExecutionResult } from 'src/types'
12
12
 
13
- const LOCAL_CONFIG_FILE = 'infra-kit.local.yml'
14
-
15
13
  interface CheckResult {
16
14
  name: string
17
15
  status: 'pass' | 'fail'
@@ -110,32 +108,43 @@ const checkInfraKitConfigValid = async (): Promise<CheckResult> => {
110
108
  return {
111
109
  name,
112
110
  status: 'pass',
113
- message: 'infra-kit.yml is valid (infra-kit.local.yml overrides applied if present)',
111
+ message: 'infra-kit.yml is valid (user overrides applied if present)',
114
112
  }
115
113
  } catch (err) {
116
114
  return { name, status: 'fail', message: (err as Error).message }
117
115
  }
118
116
  }
119
117
 
120
- const checkLocalConfigGitignored = async (): Promise<CheckResult> => {
121
- const name = 'infra-kit.local.yml gitignored'
118
+ /**
119
+ * Surface where this developer's user-scope override file would live and
120
+ * whether it has been created. Always passes — informational only — so the
121
+ * user knows the resolved project name and target path at a glance.
122
+ *
123
+ * @example
124
+ * await checkUserOverridePath()
125
+ * // {
126
+ * // name: 'user override path',
127
+ * // status: 'pass',
128
+ * // message: '~/.infra-kit/projects/api/infra-kit.yml (not yet created) — project: api',
129
+ * // }
130
+ */
131
+ const checkUserOverridePath = async (): Promise<CheckResult> => {
132
+ const name = 'user override path'
122
133
 
123
134
  try {
124
- const root = await getProjectRoot()
125
-
126
- await $({ cwd: root, nothrow: true })`git check-ignore -q ${LOCAL_CONFIG_FILE}`.then((result) => {
127
- if (result.exitCode !== 0) {
128
- throw new Error('not ignored')
129
- }
130
- })
135
+ const paths = await getInfraKitConfigPaths()
136
+ const home = os.homedir()
137
+ const display = paths.userProject.startsWith(home) ? `~${paths.userProject.slice(home.length)}` : paths.userProject
138
+ const exists = fs.existsSync(paths.userProject)
139
+ const suffix = exists ? '(exists)' : '(not yet created)'
131
140
 
132
- return { name, status: 'pass', message: `${LOCAL_CONFIG_FILE} is covered by .gitignore` }
133
- } catch {
134
141
  return {
135
142
  name,
136
- status: 'fail',
137
- message: `${LOCAL_CONFIG_FILE} is not gitignored. Add "${LOCAL_CONFIG_FILE}" to .gitignore.`,
143
+ status: 'pass',
144
+ message: `${display} ${suffix} project: ${paths.projectName}`,
138
145
  }
146
+ } catch (err) {
147
+ return { name, status: 'fail', message: (err as Error).message }
139
148
  }
140
149
  }
141
150
 
@@ -184,7 +193,7 @@ export const doctor = async (): Promise<ToolsExecutionResult> => {
184
193
  Promise.resolve(checkZshrcInitialized()),
185
194
  checkPnpmWorkspaceVirtualStore(),
186
195
  checkInfraKitConfigValid(),
187
- checkLocalConfigGitignored(),
196
+ checkUserOverridePath(),
188
197
  ])
189
198
 
190
199
  logger.info('Doctor check results:\n')
@@ -10,8 +10,33 @@ export const MARKER_END = '# -- infra-kit:end --'
10
10
  const LEGACY_PAIRED: [start: string, end: string][] = [['# region infra-kit', '# endregion infra-kit']]
11
11
  const LEGACY_SINGLE = '# infra-kit shell functions'
12
12
 
13
+ const USER_GLOBAL_CONFIG_STUB = `# infra-kit user-global config
14
+ #
15
+ # Shared across every project on this machine. Loaded after each project's
16
+ # infra-kit.yml and before ~/.infra-kit/projects/<repo-name>/infra-kit.yml.
17
+ #
18
+ # Top-level keys (envManagement, ide, taskManager, environments) replace
19
+ # wholesale when set here. Uncomment values you want to apply globally.
20
+
21
+ # Per-developer IDE config
22
+ # ide:
23
+ # provider: cursor
24
+ # config:
25
+ # mode: workspace
26
+ # workspaceConfigPath: ../Main.code-workspace
27
+ `
28
+
13
29
  /**
14
- * Append infra-kit shell functions directly to .zshrc.
30
+ * Append infra-kit shell functions to .zshrc and seed the user-global
31
+ * config stub at ~/.infra-kit/config.yml on first run. Idempotent: a
32
+ * subsequent run replaces the existing zshrc block in place and leaves
33
+ * the user-global config untouched.
34
+ *
35
+ * @example
36
+ * // CLI: `infra-kit init` (or via the `pnpm dx-init` alias)
37
+ * // INFO: Added infra-kit shell functions to /Users/me/.zshrc
38
+ * // INFO: Wrote user-global config stub to /Users/me/.infra-kit/config.yml
39
+ * // INFO: Run `source ~/.zshrc` or open a new terminal to activate.
15
40
  */
16
41
  export const init = async (): Promise<void> => {
17
42
  const zshrcPath = path.join(os.homedir(), '.zshrc')
@@ -26,9 +51,37 @@ export const init = async (): Promise<void> => {
26
51
 
27
52
  fs.appendFileSync(zshrcPath, `\n${shellBlock}\n`)
28
53
  logger.info(`Added infra-kit shell functions to ${zshrcPath}`)
54
+
55
+ seedUserGlobalConfig()
56
+
29
57
  logger.info('Run `source ~/.zshrc` or open a new terminal to activate.')
30
58
  }
31
59
 
60
+ /**
61
+ * Create `~/.infra-kit/config.yml` with the documented stub when absent.
62
+ * Skips silently if the file already exists so user edits are preserved.
63
+ *
64
+ * @example
65
+ * seedUserGlobalConfig()
66
+ * // first call: writes ~/.infra-kit/config.yml from USER_GLOBAL_CONFIG_STUB
67
+ * // later calls: leaves the file alone, logs that it is already present
68
+ */
69
+ const seedUserGlobalConfig = (): void => {
70
+ const userConfigDir = path.join(os.homedir(), '.infra-kit')
71
+ const userConfigPath = path.join(userConfigDir, 'config.yml')
72
+
73
+ if (fs.existsSync(userConfigPath)) {
74
+ logger.info(`User-global config already present at ${userConfigPath}`)
75
+
76
+ return
77
+ }
78
+
79
+ fs.mkdirSync(userConfigDir, { recursive: true })
80
+ fs.writeFileSync(userConfigPath, USER_GLOBAL_CONFIG_STUB, 'utf-8')
81
+
82
+ logger.info(`Wrote user-global config stub to ${userConfigPath}`)
83
+ }
84
+
32
85
  const isBlockLine = (line: string): boolean => {
33
86
  return (
34
87
  line.startsWith('#') ||
@@ -2,20 +2,116 @@ import confirm from '@inquirer/confirm'
2
2
  import select from '@inquirer/select'
3
3
  import process from 'node:process'
4
4
  import { z } from 'zod/v4'
5
- import { $, question } from 'zx'
5
+ import { question } from 'zx'
6
6
 
7
7
  import { loadJiraConfig } from 'src/integrations/jira'
8
8
  import { commandEcho } from 'src/lib/command-echo'
9
9
  import { logger } from 'src/lib/logger'
10
10
  import { createSingleRelease, prepareGitForRelease } from 'src/lib/release-utils'
11
11
  import type { ReleaseType } from 'src/lib/release-utils'
12
+ import {
13
+ NEXT_TOKEN,
14
+ NoPriorVersionsError,
15
+ computeNextVersion,
16
+ loadExistingVersions,
17
+ resolveVersionTokens,
18
+ splitVersionInput,
19
+ } from 'src/lib/version-utils'
20
+ import type { SemVer } from 'src/lib/version-utils'
12
21
  import type { RequiredConfirmedOptionArg, ToolsExecutionResult } from 'src/types'
13
22
 
23
+ import { releaseCreateBatch } from '../release-create-batch/release-create-batch'
24
+
14
25
  interface ReleaseCreateArgs extends RequiredConfirmedOptionArg {
15
26
  version?: string
16
27
  description?: string
17
28
  type?: ReleaseType
18
- checkout?: boolean
29
+ }
30
+
31
+ const VERSION_PROMPT_HINT = '"1.2.5", "next", or "next,next,1.2.7"'
32
+
33
+ const promptForType = async (): Promise<ReleaseType> => {
34
+ commandEcho.setInteractive()
35
+
36
+ return select<ReleaseType>({
37
+ message: 'Select release type:',
38
+ choices: [
39
+ { name: 'regular', value: 'regular' },
40
+ { name: 'hotfix', value: 'hotfix' },
41
+ ],
42
+ default: 'regular',
43
+ })
44
+ }
45
+
46
+ const trySuggestNext = (known: SemVer[], type: ReleaseType): string | null => {
47
+ try {
48
+ return computeNextVersion(known, type)
49
+ } catch (err) {
50
+ if (err instanceof NoPriorVersionsError) return null
51
+
52
+ throw err
53
+ }
54
+ }
55
+
56
+ const exitOnNoPrior = (err: unknown): never => {
57
+ if (err instanceof NoPriorVersionsError) {
58
+ logger.error(err.message)
59
+ process.exit(1)
60
+ }
61
+
62
+ throw err
63
+ }
64
+
65
+ interface ResolveTokensArgs {
66
+ inputVersion: string | undefined
67
+ type: ReleaseType
68
+ ensureKnown: () => Promise<SemVer[]>
69
+ }
70
+
71
+ const resolveRawTokens = async (args: ResolveTokensArgs): Promise<string[]> => {
72
+ const { inputVersion, type, ensureKnown } = args
73
+
74
+ if (inputVersion && inputVersion.trim() !== '') {
75
+ return splitVersionInput(inputVersion)
76
+ }
77
+
78
+ commandEcho.setInteractive()
79
+
80
+ const suggestion = trySuggestNext(await ensureKnown(), type)
81
+ const defaultHint = suggestion ? ` [${suggestion}]` : ''
82
+ const answer = (await question(`Enter version(s) (e.g. ${VERSION_PROMPT_HINT})${defaultHint}: `)).trim()
83
+
84
+ if (answer === '') return suggestion ? [suggestion] : []
85
+
86
+ return splitVersionInput(answer)
87
+ }
88
+
89
+ const resolveVersionList = async (args: ResolveTokensArgs): Promise<string[]> => {
90
+ const rawTokens = await resolveRawTokens(args)
91
+
92
+ if (rawTokens.length === 0) {
93
+ logger.error('No version provided. Exiting...')
94
+ process.exit(1)
95
+ }
96
+
97
+ const needsKnown = rawTokens.some((t) => {
98
+ return t.toLowerCase() === NEXT_TOKEN
99
+ })
100
+
101
+ try {
102
+ return resolveVersionTokens(rawTokens, args.type, needsKnown ? await args.ensureKnown() : [])
103
+ } catch (err) {
104
+ return exitOnNoPrior(err)
105
+ }
106
+ }
107
+
108
+ const resolveDescription = async (input: string | undefined): Promise<string> => {
109
+ if (input !== undefined) return input
110
+
111
+ commandEcho.setInteractive()
112
+ const answer = await question('Enter description (optional, press Enter to skip): ')
113
+
114
+ return answer.trim()
19
115
  }
20
116
 
21
117
  /**
@@ -23,62 +119,41 @@ interface ReleaseCreateArgs extends RequiredConfirmedOptionArg {
23
119
  * Includes Jira version creation and GitHub release branch creation
24
120
  */
25
121
  export const releaseCreate = async (args: ReleaseCreateArgs): Promise<ToolsExecutionResult> => {
26
- const {
27
- version: inputVersion,
28
- description: inputDescription,
29
- type: inputType,
30
- confirmedCommand,
31
- checkout: inputCheckout,
32
- } = args
122
+ const { version: inputVersion, description: inputDescription, type: inputType, confirmedCommand } = args
33
123
 
34
124
  commandEcho.start('release-create')
35
125
 
36
- let version = inputVersion
37
- let description = inputDescription
38
- let type: ReleaseType = inputType || 'regular'
39
- let checkout = inputCheckout
40
-
41
126
  // Load Jira config - it is now mandatory
42
127
  const jiraConfig = await loadJiraConfig()
43
128
 
44
- if (!version) {
45
- commandEcho.setInteractive()
46
- version = await question('Enter version (e.g. 1.2.5): ')
47
- }
129
+ const type: ReleaseType = inputType ?? (await promptForType())
48
130
 
49
- // Validate input (validate the version is a valid semver)
50
- if (!version || version.trim() === '') {
51
- logger.error('No version provided. Exiting...')
52
- process.exit(1)
53
- }
131
+ commandEcho.addOption('--type', type)
54
132
 
55
- const trimmedVersion = version.trim()
133
+ let known: SemVer[] | null = null
134
+ const ensureKnown = async (): Promise<SemVer[]> => {
135
+ if (known === null) known = await loadExistingVersions()
56
136
 
57
- commandEcho.addOption('--version', trimmedVersion)
137
+ return known
138
+ }
58
139
 
59
- if (!inputType) {
60
- commandEcho.setInteractive()
140
+ const resolvedVersions = await resolveVersionList({ inputVersion, type, ensureKnown })
61
141
 
62
- type = await select<ReleaseType>({
63
- message: 'Select release type:',
64
- choices: [
65
- { name: 'regular', value: 'regular' },
66
- { name: 'hotfix', value: 'hotfix' },
67
- ],
68
- default: 'regular',
142
+ if (resolvedVersions.length > 1) {
143
+ logger.info(`Detected ${resolvedVersions.length} versions, routing to release-create-batch...`)
144
+
145
+ return releaseCreateBatch({
146
+ versions: resolvedVersions.join(','),
147
+ type,
148
+ confirmedCommand,
69
149
  })
70
150
  }
71
151
 
72
- commandEcho.addOption('--type', type)
152
+ const trimmedVersion = resolvedVersions[0] as string
73
153
 
74
- if (description === undefined) {
75
- commandEcho.setInteractive()
76
- description = await question('Enter description (optional, press Enter to skip): ')
154
+ commandEcho.addOption('--version', trimmedVersion)
77
155
 
78
- if (description.trim() === '') {
79
- description = ''
80
- }
81
- }
156
+ const description = await resolveDescription(inputDescription)
82
157
 
83
158
  if (description) {
84
159
  commandEcho.addOption('--description', description)
@@ -104,37 +179,12 @@ export const releaseCreate = async (args: ReleaseCreateArgs): Promise<ToolsExecu
104
179
 
105
180
  await prepareGitForRelease(type)
106
181
 
107
- // Create the release
108
182
  const release = await createSingleRelease({ version: trimmedVersion, jiraConfig, description, type })
109
183
 
110
184
  logger.info(`✅ Successfully created release: v${trimmedVersion}`)
111
185
  logger.info(`🔗 GitHub PR: ${release.prUrl}`)
112
186
  logger.info(`🔗 Jira Version: ${release.jiraVersionUrl}`)
113
187
 
114
- // Ask about checkout if not specified
115
- if (checkout === undefined) {
116
- commandEcho.setInteractive()
117
-
118
- checkout = await confirm({
119
- message: `Do you want to checkout to the created branch ${release.branchName}?`,
120
- default: true,
121
- })
122
- }
123
-
124
- // Track checkout option (--no-checkout if false)
125
- if (!checkout) {
126
- commandEcho.addOption('--no-checkout', true)
127
- }
128
-
129
- // Checkout to the created branch by default
130
- if (checkout) {
131
- $.quiet = true
132
- await $`git switch ${release.branchName}`
133
- $.quiet = false
134
-
135
- logger.info(`🔄 Switched to branch ${release.branchName}`)
136
- }
137
-
138
188
  commandEcho.print()
139
189
 
140
190
  const structuredContent = {
@@ -143,7 +193,6 @@ export const releaseCreate = async (args: ReleaseCreateArgs): Promise<ToolsExecu
143
193
  branchName: release.branchName,
144
194
  prUrl: release.prUrl,
145
195
  jiraVersionUrl: release.jiraVersionUrl,
146
- isCheckedOut: checkout,
147
196
  }
148
197
 
149
198
  return {
@@ -161,16 +210,19 @@ export const releaseCreate = async (args: ReleaseCreateArgs): Promise<ToolsExecu
161
210
  export const releaseCreateMcpTool = {
162
211
  name: 'release-create',
163
212
  description:
164
- 'Create a new release: cuts the release branch off the appropriate base (dev for regular releases, main for hotfixes), opens a GitHub release PR, creates the matching Jira fix version, and optionally checks out to the new branch. Confirmation is auto-skipped for MCP calls, so the caller is responsible for gating. "version" is required when invoked via MCP (the interactive input prompt is unreachable without a TTY); "type" / "description" / "checkout" default to regular / empty / true when omitted.',
213
+ 'Create a new release: cuts the release branch off the appropriate base (dev for regular releases, main for hotfixes), opens a GitHub release PR, and creates the matching Jira fix version. Does not switch the working tree to the new branch — the caller stays on the base branch. Confirmation is auto-skipped for MCP calls, so the caller is responsible for gating. "version" is required when invoked via MCP (the interactive input prompt is unreachable without a TTY); pass "next" to auto-compute the next version (regular bumps minor + resets patch; hotfix bumps patch on the highest minor) using the union of remote release branches and Jira fix versions. "type" / "description" default to regular / empty when omitted. For multiple versions in one call, prefer release-create-batch.',
165
214
  inputSchema: {
166
- version: z.string().describe('Version to create (e.g., "1.2.5"). Required for MCP calls.'),
215
+ version: z
216
+ .string()
217
+ .describe(
218
+ 'Version to create (e.g., "1.2.5") or the literal token "next" for auto-increment. Required for MCP calls.',
219
+ ),
167
220
  description: z.string().optional().describe('Optional description for the Jira version'),
168
221
  type: z
169
222
  .enum(['regular', 'hotfix'])
170
223
  .optional()
171
224
  .default('regular')
172
225
  .describe('Release type: "regular" or "hotfix" (default: "regular")'),
173
- checkout: z.boolean().optional().default(true).describe('Checkout to the created branch (default: true)'),
174
226
  },
175
227
  outputSchema: {
176
228
  version: z.string().describe('Version number'),
@@ -178,7 +230,6 @@ export const releaseCreateMcpTool = {
178
230
  branchName: z.string().describe('Release branch name'),
179
231
  prUrl: z.string().describe('GitHub PR URL'),
180
232
  jiraVersionUrl: z.string().describe('Jira version URL'),
181
- isCheckedOut: z.boolean().describe('Whether the branch was checked out'),
182
233
  },
183
234
  handler: releaseCreate,
184
235
  }
@@ -9,6 +9,13 @@ import { commandEcho } from 'src/lib/command-echo'
9
9
  import { logger } from 'src/lib/logger'
10
10
  import { createSingleRelease, prepareGitForRelease } from 'src/lib/release-utils'
11
11
  import type { ReleaseCreationResult, ReleaseType } from 'src/lib/release-utils'
12
+ import {
13
+ NEXT_TOKEN,
14
+ NoPriorVersionsError,
15
+ loadExistingVersions,
16
+ resolveVersionTokens,
17
+ splitVersionInput,
18
+ } from 'src/lib/version-utils'
12
19
  import type { RequiredConfirmedOptionArg, ToolsExecutionResult } from 'src/types'
13
20
 
14
21
  interface ReleaseCreateBatchArgs extends RequiredConfirmedOptionArg {
@@ -16,6 +23,8 @@ interface ReleaseCreateBatchArgs extends RequiredConfirmedOptionArg {
16
23
  type?: ReleaseType
17
24
  }
18
25
 
26
+ const VERSIONS_PROMPT_HINT = '"1.2.5, 1.2.6", "next,next", or "next,next,1.2.7"'
27
+
19
28
  /**
20
29
  * Gather and validate batch release inputs interactively if needed
21
30
  */
@@ -25,25 +34,6 @@ const resolveInputs = async (args: ReleaseCreateBatchArgs): Promise<{ versionsLi
25
34
  let versionInput = inputVersions
26
35
  let type: ReleaseType = inputType || 'regular'
27
36
 
28
- if (!versionInput) {
29
- commandEcho.setInteractive()
30
- versionInput = await question('Enter versions by comma (e.g. 1.2.5, 1.2.6): ')
31
- }
32
-
33
- const versionsList = versionInput
34
- .split(',')
35
- .map((version) => {
36
- return version.trim()
37
- })
38
- .filter(Boolean)
39
-
40
- commandEcho.addOption('--versions', versionsList.join(', '))
41
-
42
- if (versionsList.length === 0) {
43
- logger.error('No versions provided. Exiting...')
44
- process.exit(1)
45
- }
46
-
47
37
  if (!inputType) {
48
38
  commandEcho.setInteractive()
49
39
 
@@ -59,6 +49,38 @@ const resolveInputs = async (args: ReleaseCreateBatchArgs): Promise<{ versionsLi
59
49
 
60
50
  commandEcho.addOption('--type', type)
61
51
 
52
+ if (!versionInput) {
53
+ commandEcho.setInteractive()
54
+ versionInput = await question(`Enter versions by comma (e.g. ${VERSIONS_PROMPT_HINT}): `)
55
+ }
56
+
57
+ const rawTokens = splitVersionInput(versionInput)
58
+
59
+ if (rawTokens.length === 0) {
60
+ logger.error('No versions provided. Exiting...')
61
+ process.exit(1)
62
+ }
63
+
64
+ const needsKnown = rawTokens.some((t) => {
65
+ return t.toLowerCase() === NEXT_TOKEN
66
+ })
67
+ const known = needsKnown ? await loadExistingVersions() : []
68
+
69
+ let versionsList: string[]
70
+
71
+ try {
72
+ versionsList = resolveVersionTokens(rawTokens, type, known)
73
+ } catch (err) {
74
+ if (err instanceof NoPriorVersionsError) {
75
+ logger.error(err.message)
76
+ process.exit(1)
77
+ }
78
+
79
+ throw err
80
+ }
81
+
82
+ commandEcho.addOption('--versions', versionsList.join(', '))
83
+
62
84
  if (versionsList.length === 1) {
63
85
  logger.warn('💡 You are creating only one release. Consider using "create-release" command for single releases.')
64
86
  }
@@ -159,11 +181,13 @@ export const releaseCreateBatch = async (args: ReleaseCreateBatchArgs): Promise<
159
181
  export const releaseCreateBatchMcpTool = {
160
182
  name: 'release-create-batch',
161
183
  description:
162
- 'Create several releases in one pass: for each comma-separated version in "versions", cuts the release branch off the appropriate base (dev for regular releases, main for hotfixes), opens a GitHub PR, and creates the Jira fix version. Continues on per-version failure and reports which versions succeeded and which failed. Confirmation is auto-skipped for MCP calls, so the caller is responsible for gating. "versions" is required when invoked via MCP (the interactive input prompt is unreachable without a TTY). Use release-create for a single version with optional checkout.',
184
+ 'Create several releases in one pass: for each comma-separated version in "versions", cuts the release branch off the appropriate base (dev for regular releases, main for hotfixes), opens a GitHub PR, and creates the Jira fix version. The literal token "next" auto-increments from the latest known version (regular bumps minor + resets patch; hotfix bumps patch on the highest minor); multiple "next" tokens advance sequentially. Existing versions are unioned from remote release branches and Jira fix versions. Continues on per-version failure and reports which versions succeeded and which failed. Confirmation is auto-skipped for MCP calls, so the caller is responsible for gating. "versions" is required when invoked via MCP (the interactive input prompt is unreachable without a TTY). Use release-create for a single version with optional checkout.',
163
185
  inputSchema: {
164
186
  versions: z
165
187
  .string()
166
- .describe('Comma-separated list of versions to create (e.g., "1.2.5, 1.2.6"). Required for MCP calls.'),
188
+ .describe(
189
+ 'Comma-separated versions to create (e.g., "1.2.5, 1.2.6", "next,next", or "next,next,1.2.7"). Required for MCP calls.',
190
+ ),
167
191
  type: z
168
192
  .enum(['regular', 'hotfix'])
169
193
  .optional()