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/.eslintcache +1 -1
- package/.turbo/turbo-eslint-check.log +4 -5
- package/.turbo/turbo-prettier-check.log +4 -5
- package/.turbo/turbo-test.log +191 -18
- package/.turbo/turbo-ts-check.log +9 -5
- package/dist/cli.js +57 -37
- package/dist/cli.js.map +4 -4
- package/dist/mcp.js +29 -28
- package/dist/mcp.js.map +4 -4
- package/package.json +1 -1
- package/src/commands/config/config.ts +125 -0
- package/src/commands/config/index.ts +1 -0
- package/src/commands/doctor/doctor.ts +27 -18
- package/src/commands/init/init.ts +54 -1
- package/src/commands/release-create/release-create.ts +123 -72
- package/src/commands/release-create-batch/release-create-batch.ts +45 -21
- package/src/entry/cli.ts +26 -5
- package/src/lib/__tests__/infra-kit-config.test.ts +3 -1
- package/src/lib/infra-kit-config/index.ts +2 -2
- package/src/lib/infra-kit-config/infra-kit-config.ts +183 -37
- package/src/lib/version-utils/__tests__/next-version.test.ts +112 -0
- package/src/lib/version-utils/index.ts +11 -0
- package/src/lib/version-utils/load-existing-versions.ts +67 -0
- package/src/lib/version-utils/next-version.ts +148 -0
- package/tsconfig.tsbuildinfo +1 -1
package/package.json
CHANGED
|
@@ -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 (
|
|
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
|
-
|
|
121
|
-
|
|
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
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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: '
|
|
137
|
-
message: `${
|
|
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
|
-
|
|
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
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
133
|
+
let known: SemVer[] | null = null
|
|
134
|
+
const ensureKnown = async (): Promise<SemVer[]> => {
|
|
135
|
+
if (known === null) known = await loadExistingVersions()
|
|
56
136
|
|
|
57
|
-
|
|
137
|
+
return known
|
|
138
|
+
}
|
|
58
139
|
|
|
59
|
-
|
|
60
|
-
commandEcho.setInteractive()
|
|
140
|
+
const resolvedVersions = await resolveVersionList({ inputVersion, type, ensureKnown })
|
|
61
141
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
|
|
152
|
+
const trimmedVersion = resolvedVersions[0] as string
|
|
73
153
|
|
|
74
|
-
|
|
75
|
-
commandEcho.setInteractive()
|
|
76
|
-
description = await question('Enter description (optional, press Enter to skip): ')
|
|
154
|
+
commandEcho.addOption('--version', trimmedVersion)
|
|
77
155
|
|
|
78
|
-
|
|
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
|
|
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
|
|
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(
|
|
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()
|