infra-kit 0.1.101 → 0.1.105

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.
Files changed (129) hide show
  1. package/.eslintcache +1 -1
  2. package/.omc/state/agent-replay-0a58307d-2a37-4c69-851c-83a646502d62.jsonl +1 -0
  3. package/.omc/state/agent-replay-11c41aa0-51fa-49e1-a1dc-26dcd6ac26cc.jsonl +16 -0
  4. package/.omc/state/agent-replay-4cf1c186-81b2-497c-b002-d7f84e7839f3.jsonl +9 -0
  5. package/.omc/state/agent-replay-5c4ab554-64f1-42ae-83e3-21e0237e955c.jsonl +11 -0
  6. package/.omc/state/agent-replay-a60ac2ec-afbd-449f-a540-6df287392fc2.jsonl +1 -0
  7. package/.omc/state/agent-replay-be37e426-6fc8-47f4-8178-221c8494551c.jsonl +3 -0
  8. package/.omc/state/agent-replay-c967c819-3d1c-447b-ab48-56a8448ef9f8.jsonl +2 -0
  9. package/.omc/state/idle-notif-cooldown.json +3 -0
  10. package/.omc/state/last-tool-error.json +4 -4
  11. package/.omc/state/mission-state.json +53 -0
  12. package/.omc/state/sessions/0a58307d-2a37-4c69-851c-83a646502d62/pre-tool-advisory-throttle.json +18 -0
  13. package/.omc/state/sessions/0a58307d-2a37-4c69-851c-83a646502d62/subagent-tracking-state.json +7 -0
  14. package/.omc/state/sessions/11c41aa0-51fa-49e1-a1dc-26dcd6ac26cc/last-tool-error-state.json +7 -0
  15. package/.omc/state/sessions/11c41aa0-51fa-49e1-a1dc-26dcd6ac26cc/mission-state.json +117 -0
  16. package/.omc/state/sessions/11c41aa0-51fa-49e1-a1dc-26dcd6ac26cc/pre-tool-advisory-throttle.json +42 -0
  17. package/.omc/state/sessions/11c41aa0-51fa-49e1-a1dc-26dcd6ac26cc/subagent-tracking-state.json +53 -0
  18. package/.omc/state/sessions/4cf1c186-81b2-497c-b002-d7f84e7839f3/last-tool-error-state.json +7 -0
  19. package/.omc/state/sessions/4cf1c186-81b2-497c-b002-d7f84e7839f3/pre-tool-advisory-throttle.json +18 -0
  20. package/.omc/state/sessions/4cf1c186-81b2-497c-b002-d7f84e7839f3/subagent-tracking-state.json +7 -0
  21. package/.omc/state/sessions/5c4ab554-64f1-42ae-83e3-21e0237e955c/mission-state.json +117 -0
  22. package/.omc/state/sessions/5c4ab554-64f1-42ae-83e3-21e0237e955c/pre-tool-advisory-throttle.json +18 -0
  23. package/.omc/state/sessions/5c4ab554-64f1-42ae-83e3-21e0237e955c/subagent-tracking-state.json +17 -0
  24. package/.omc/state/sessions/a60ac2ec-afbd-449f-a540-6df287392fc2/pre-tool-advisory-throttle.json +18 -0
  25. package/.omc/state/sessions/a60ac2ec-afbd-449f-a540-6df287392fc2/subagent-tracking-state.json +7 -0
  26. package/.omc/state/sessions/c967c819-3d1c-447b-ab48-56a8448ef9f8/pre-tool-advisory-throttle.json +10 -0
  27. package/.omc/state/sessions/c967c819-3d1c-447b-ab48-56a8448ef9f8/subagent-tracking-state.json +7 -0
  28. package/.omc/state/subagent-tracking.json +14 -4
  29. package/.turbo/turbo-build.log +7 -0
  30. package/.turbo/turbo-check.log +14 -0
  31. package/.turbo/turbo-prettier-fix.log +2 -1
  32. package/.turbo/turbo-test.log +28 -5
  33. package/.turbo/turbo-validate.log +14 -0
  34. package/dist/cli.js +81 -74
  35. package/dist/cli.js.map +4 -4
  36. package/dist/entry/index.d.ts +2 -0
  37. package/dist/index.js +2 -0
  38. package/dist/index.js.map +7 -0
  39. package/dist/lib/package-config/package-config.d.ts +71 -0
  40. package/dist/mcp.js +41 -39
  41. package/dist/mcp.js.map +4 -4
  42. package/eslint.config.js +1 -1
  43. package/infra-kit.config.ts +5 -0
  44. package/package.json +20 -13
  45. package/scripts/build.js +32 -3
  46. package/src/.omc/state/sessions/0a58307d-2a37-4c69-851c-83a646502d62/pre-tool-advisory-throttle.json +18 -0
  47. package/src/commands/.omc/state/sessions/c967c819-3d1c-447b-ab48-56a8448ef9f8/pre-tool-advisory-throttle.json +18 -0
  48. package/src/commands/audit/__tests__/audit.test.ts +59 -0
  49. package/src/commands/audit/audit.ts +177 -0
  50. package/src/commands/audit/index.ts +1 -0
  51. package/src/commands/config/config.ts +49 -7
  52. package/src/commands/doctor/doctor.ts +3 -3
  53. package/src/commands/env-clear/env-clear.ts +1 -1
  54. package/src/commands/env-list/env-list.ts +3 -3
  55. package/src/commands/env-load/env-load.ts +1 -1
  56. package/src/commands/env-status/env-status.ts +1 -1
  57. package/src/commands/gh-merge-dev/gh-merge-dev.ts +3 -8
  58. package/src/commands/gh-release-deliver/gh-release-deliver.ts +77 -21
  59. package/src/commands/gh-release-deploy-all/gh-release-deploy-all.ts +13 -7
  60. package/src/commands/gh-release-deploy-selected/gh-release-deploy-selected.ts +12 -6
  61. package/src/commands/gh-release-list/gh-release-list.ts +19 -8
  62. package/src/commands/init/__tests__/migrate-config.test.ts +160 -0
  63. package/src/commands/init/init.ts +48 -35
  64. package/src/commands/init/migrate-config.ts +146 -0
  65. package/src/commands/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
  66. package/src/commands/release-create/__tests__/release-create.test.ts +55 -0
  67. package/src/commands/release-create/release-create.ts +142 -38
  68. package/src/commands/release-desc-edit/release-desc-edit.ts +28 -8
  69. package/src/commands/version/version.ts +1 -1
  70. package/src/commands/worktrees-add/worktrees-add.ts +7 -12
  71. package/src/commands/worktrees-list/worktrees-list.ts +13 -5
  72. package/src/commands/worktrees-open/worktrees-open.ts +1 -1
  73. package/src/commands/worktrees-remove/worktrees-remove.ts +8 -67
  74. package/src/commands/worktrees-sync/worktrees-sync.ts +3 -5
  75. package/src/entry/cli.ts +49 -6
  76. package/src/entry/index.ts +5 -0
  77. package/src/integrations/cmux/open-workspace-with-layout.ts +4 -4
  78. package/src/integrations/cmux/workspace-title.ts +10 -4
  79. package/src/integrations/doppler/doppler-project.ts +1 -1
  80. package/src/integrations/gh/gh-release-prs/__tests__/gh-release-prs.test.ts +115 -0
  81. package/src/integrations/gh/gh-release-prs/gh-release-prs.ts +49 -32
  82. package/src/lib/.omc/state/sessions/a60ac2ec-afbd-449f-a540-6df287392fc2/pre-tool-advisory-throttle.json +14 -0
  83. package/src/lib/constants/index.ts +15 -0
  84. package/src/lib/git-utils/__tests__/git-utils.test.ts +49 -0
  85. package/src/lib/git-utils/git-utils.ts +3 -1
  86. package/src/lib/infra-kit-config/__tests__/infra-kit-config.test.ts +270 -0
  87. package/src/lib/infra-kit-config/index.ts +7 -1
  88. package/src/lib/infra-kit-config/infra-kit-config.ts +46 -28
  89. package/src/lib/package-config/__tests__/package-config.test.ts +95 -0
  90. package/src/lib/package-config/index.ts +3 -0
  91. package/src/lib/package-config/package-config-schema.ts +19 -0
  92. package/src/lib/package-config/package-config.ts +99 -0
  93. package/src/lib/package-validator/__tests__/package-validator.test.ts +263 -0
  94. package/src/lib/package-validator/checks/__tests__/checks.test.ts +130 -0
  95. package/src/lib/package-validator/checks/config-check.ts +30 -0
  96. package/src/lib/package-validator/checks/files-check.ts +29 -0
  97. package/src/lib/package-validator/checks/index.ts +4 -0
  98. package/src/lib/package-validator/checks/scripts-check.ts +23 -0
  99. package/src/lib/package-validator/checks/turbo-check.ts +47 -0
  100. package/src/lib/package-validator/fs-utils.ts +18 -0
  101. package/src/lib/package-validator/index.ts +3 -0
  102. package/src/lib/package-validator/loader/config-loader.ts +77 -0
  103. package/src/lib/package-validator/loader/index.ts +2 -0
  104. package/src/lib/package-validator/loader/package-discovery.ts +98 -0
  105. package/src/lib/package-validator/package-validator.ts +48 -0
  106. package/src/lib/package-validator/types.ts +15 -0
  107. package/src/lib/release-id/__tests__/release-id.test.ts +351 -0
  108. package/src/lib/release-id/__tests__/versioned-regression.test.ts +69 -0
  109. package/src/lib/release-id/index.ts +15 -0
  110. package/src/lib/release-id/release-id.ts +257 -0
  111. package/src/lib/release-utils/__tests__/release-utils.test.ts +122 -0
  112. package/src/lib/release-utils/index.ts +4 -0
  113. package/src/lib/release-utils/release-utils.ts +85 -17
  114. package/src/lib/version-utils/__tests__/load-existing-versions.test.ts +37 -0
  115. package/src/lib/version-utils/__tests__/next-version.test.ts +119 -13
  116. package/src/lib/version-utils/index.ts +3 -0
  117. package/src/lib/version-utils/load-existing-versions.ts +29 -10
  118. package/src/lib/version-utils/next-version.ts +67 -12
  119. package/src/lib/version-utils/version-utils.ts +13 -4
  120. package/src/lib/worktrees/index.ts +1 -0
  121. package/src/lib/worktrees/remove-worktrees.ts +65 -0
  122. package/src/mcp/tools/index.ts +2 -0
  123. package/src/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
  124. package/src/types.ts +1 -1
  125. package/tsconfig.tsbuildinfo +1 -1
  126. package/src/lib/__tests__/infra-kit-config.test.ts +0 -231
  127. /package/src/integrations/{clickup → linear}/.gitkeep +0 -0
  128. /package/src/lib/{__tests__ → constants/__tests__}/constants.test.ts +0 -0
  129. /package/src/lib/{constants.ts → constants/constants.ts} +0 -0
@@ -4,49 +4,53 @@ import path from 'node:path'
4
4
 
5
5
  import { logger } from 'src/lib/logger'
6
6
 
7
+ import { migrateLegacyConfig } from './migrate-config'
8
+
7
9
  export const MARKER_START = '# -- infra-kit:begin --'
8
10
  export const MARKER_END = '# -- infra-kit:end --'
9
11
 
10
12
  const LEGACY_PAIRED: [start: string, end: string][] = [['# region infra-kit', '# endregion infra-kit']]
11
13
  const LEGACY_SINGLE = '# infra-kit shell functions'
12
14
 
13
- const USER_GLOBAL_CONFIG_STUB = `# infra-kit user-global config ~/.infra-kit/config.yml
14
- #
15
- # Merge chain (later layers override earlier ones at top-level keys):
16
- # 1. <repo>/infra-kit.yml — committed project config (required)
17
- # 2. ~/.infra-kit/config.yml — this file (user-global)
18
- # 3. ~/.infra-kit/projects/<repo-name>/infra-kit.yml — user-scope per-project override
19
- #
20
- # Merge is shallow: setting a top-level key here replaces that whole section
21
- # from layer 1. Arrays do not concatenate. Top-level keys recognized:
22
- # environments, envManagement, ide, taskManager, worktrees.
23
- #
24
- # Uncomment the blocks you want to apply globally across every project on this
25
- # machine. Per-project tweaks belong in layer 3 run \`infra-kit config edit\`.
26
-
27
- # Per-developer IDE config
28
- # ide:
29
- # provider: cursor
30
- # config:
31
- # mode: workspace
32
- # workspaceConfigPath: /path/to/your.code-workspace
33
-
34
- # Worktree prompt defaults — silences the follow-up prompts in \`worktrees-add\`
35
- # worktrees:
36
- # openInGithubDesktop: false
37
- # openInCmux: true
15
+ // JSON can't carry comments, so the real config is an empty-but-valid object…
16
+ const USER_GLOBAL_CONFIG_STUB = '{}\n'
17
+
18
+ // …and the annotated guidance lives next to it in a non-loaded .example.jsonc
19
+ // (the loader only reads the three exact `infra-kit.json` / `config.json` files).
20
+ const USER_GLOBAL_CONFIG_EXAMPLE = `// infra-kit user-global config — ~/.infra-kit/config.json
21
+ //
22
+ // Merge chain (later layers override earlier ones at top-level keys):
23
+ // 1. <repo>/infra-kit.json — committed project config (required)
24
+ // 2. ~/.infra-kit/config.json — user-global (the sibling of this file)
25
+ // 3. ~/.infra-kit/projects/<repo-name>/infra-kit.json — user-scope per-project override
26
+ //
27
+ // Merge is shallow: setting a top-level key replaces that whole section from
28
+ // layer 1. Arrays do not concatenate. Top-level keys recognized:
29
+ // environments, envManagement, ide, taskManager, worktrees.
30
+ //
31
+ // This .example.jsonc is reference only — it is NOT loaded. Put real global
32
+ // overrides in the sibling config.json (strict JSON: no comments, double-quoted
33
+ // keys). Per-project tweaks belong in layer 3 — run \`infra-kit config edit\`.
34
+ {
35
+ // "ide": {
36
+ // "provider": "cursor",
37
+ // "config": { "mode": "workspace", "workspaceConfigPath": "/path/to/your.code-workspace" }
38
+ // },
39
+ // "worktrees": { "openInGithubDesktop": false, "openInCmux": true }
40
+ }
38
41
  `
39
42
 
40
43
  /**
41
- * Append infra-kit shell functions to .zshrc and seed the user-global
42
- * config stub at ~/.infra-kit/config.yml on first run. Idempotent: a
43
- * subsequent run replaces the existing zshrc block in place and leaves
44
+ * Append infra-kit shell functions to .zshrc, migrate any legacy
45
+ * `infra-kit.yml` config layers to JSON, and seed the user-global config at
46
+ * ~/.infra-kit/config.json on first run. Idempotent: a subsequent run replaces
47
+ * the existing zshrc block in place, has nothing left to migrate, and leaves
44
48
  * the user-global config untouched.
45
49
  *
46
50
  * @example
47
51
  * // CLI: `infra-kit init` (or via the `pnpm dx-init` alias)
48
52
  * // INFO: Added infra-kit shell functions to /Users/me/.zshrc
49
- * // INFO: Wrote user-global config stub to /Users/me/.infra-kit/config.yml
53
+ * // INFO: Wrote user-global config to /Users/me/.infra-kit/config.json (see …/config.example.jsonc …)
50
54
  * // INFO: Run `source ~/.zshrc` or open a new terminal to activate.
51
55
  */
52
56
  export const init = async (): Promise<void> => {
@@ -63,23 +67,29 @@ export const init = async (): Promise<void> => {
63
67
  fs.appendFileSync(zshrcPath, `\n${shellBlock}\n`)
64
68
  logger.info(`Added infra-kit shell functions to ${zshrcPath}`)
65
69
 
70
+ // Convert any legacy infra-kit.yml config layers to JSON before seeding, so a
71
+ // migrated config.json is not re-seeded as an empty stub.
72
+ await migrateLegacyConfig()
73
+
66
74
  seedUserGlobalConfig()
67
75
 
68
76
  logger.info('Run `source ~/.zshrc` or open a new terminal to activate.')
69
77
  }
70
78
 
71
79
  /**
72
- * Create `~/.infra-kit/config.yml` with the documented stub when absent.
73
- * Skips silently if the file already exists so user edits are preserved.
80
+ * Create `~/.infra-kit/config.json` (empty `{}`) plus an annotated
81
+ * `~/.infra-kit/config.example.jsonc` reference when absent. Skips silently if
82
+ * the config already exists so user edits are preserved.
74
83
  *
75
84
  * @example
76
85
  * seedUserGlobalConfig()
77
- * // first call: writes ~/.infra-kit/config.yml from USER_GLOBAL_CONFIG_STUB
78
- * // later calls: leaves the file alone, logs that it is already present
86
+ * // first call: writes ~/.infra-kit/config.json ({}) + config.example.jsonc
87
+ * // later calls: leaves the config alone, logs that it is already present
79
88
  */
80
89
  const seedUserGlobalConfig = (): void => {
81
90
  const userConfigDir = path.join(os.homedir(), '.infra-kit')
82
- const userConfigPath = path.join(userConfigDir, 'config.yml')
91
+ const userConfigPath = path.join(userConfigDir, 'config.json')
92
+ const userConfigExamplePath = path.join(userConfigDir, 'config.example.jsonc')
83
93
 
84
94
  if (fs.existsSync(userConfigPath)) {
85
95
  logger.info(`User-global config already present at ${userConfigPath}`)
@@ -89,8 +99,11 @@ const seedUserGlobalConfig = (): void => {
89
99
 
90
100
  fs.mkdirSync(userConfigDir, { recursive: true })
91
101
  fs.writeFileSync(userConfigPath, USER_GLOBAL_CONFIG_STUB, 'utf-8')
102
+ fs.writeFileSync(userConfigExamplePath, USER_GLOBAL_CONFIG_EXAMPLE, 'utf-8')
92
103
 
93
- logger.info(`Wrote user-global config stub to ${userConfigPath}`)
104
+ logger.info(
105
+ `Wrote user-global config to ${userConfigPath} (see ${userConfigExamplePath} for the annotated reference)`,
106
+ )
94
107
  }
95
108
 
96
109
  const isBlockLine = (line: string): boolean => {
@@ -0,0 +1,146 @@
1
+ import fs from 'node:fs/promises'
2
+ import os from 'node:os'
3
+ import path from 'node:path'
4
+ import yaml from 'yaml'
5
+ import { z } from 'zod'
6
+
7
+ import {
8
+ getInfraKitConfigPaths,
9
+ infraKitConfigSchema,
10
+ infraKitOverrideConfigSchema,
11
+ resetInfraKitConfigCache,
12
+ } from 'src/lib/infra-kit-config'
13
+ import { logger } from 'src/lib/logger'
14
+
15
+ interface MigrateLayer {
16
+ label: string
17
+ /** Legacy YAML source path. */
18
+ yml: string
19
+ /** Target JSON path. */
20
+ json: string
21
+ /** Validates the parsed object before any write (main vs override schema). */
22
+ schema: z.ZodType
23
+ }
24
+
25
+ /**
26
+ * Replace the user's home prefix with `~` so logged paths stay short.
27
+ *
28
+ * @example
29
+ * // os.homedir() === '/Users/arthur'
30
+ * tildify('/Users/arthur/.infra-kit/config.yml') // => '~/.infra-kit/config.yml'
31
+ */
32
+ const tildify = (filePath: string): string => {
33
+ const home = os.homedir()
34
+
35
+ return filePath.startsWith(home) ? `~${filePath.slice(home.length)}` : filePath
36
+ }
37
+
38
+ /**
39
+ * `fs.access` reduced to a boolean, swallowing ENOENT.
40
+ *
41
+ * @example
42
+ * await fileExists('/etc/hosts') // => true
43
+ */
44
+ const fileExists = async (filePath: string): Promise<boolean> => {
45
+ try {
46
+ await fs.access(filePath)
47
+
48
+ return true
49
+ } catch {
50
+ return false
51
+ }
52
+ }
53
+
54
+ /**
55
+ * Swap a resolved `.json` config path back to its legacy `.yml` sibling.
56
+ *
57
+ * @example
58
+ * legacyYmlPath('/repo/infra-kit.json') // => '/repo/infra-kit.yml'
59
+ */
60
+ const legacyYmlPath = (jsonPath: string): string => {
61
+ return jsonPath.replace(/\.json$/, '.yml')
62
+ }
63
+
64
+ /**
65
+ * Convert any legacy `infra-kit.yml` config layers to `infra-kit.json` as part
66
+ * of `infra-kit init`. Best-effort and non-fatal: each merge-chain layer
67
+ * (project, user-global, user-project) is migrated independently, and a
68
+ * conflict (both `.yml` and `.json` present) or an invalid `.yml` warns and
69
+ * skips that layer rather than aborting init or touching the other layers.
70
+ * Idempotent — already-JSON layers are left untouched.
71
+ *
72
+ * @example
73
+ * await migrateLegacyConfig()
74
+ * // INFO: ✓ Migrated infra-kit.yml → infra-kit.json
75
+ * // (no output when there is nothing legacy to convert)
76
+ */
77
+ export const migrateLegacyConfig = async (): Promise<void> => {
78
+ let paths: Awaited<ReturnType<typeof getInfraKitConfigPaths>>
79
+
80
+ try {
81
+ paths = await getInfraKitConfigPaths()
82
+ } catch {
83
+ // No resolvable project (e.g. init run outside a repo) — nothing to migrate.
84
+ return
85
+ }
86
+
87
+ const layers: MigrateLayer[] = [
88
+ { label: 'infra-kit.json', yml: legacyYmlPath(paths.main), json: paths.main, schema: infraKitConfigSchema },
89
+ {
90
+ label: '~/.infra-kit/config.json',
91
+ yml: legacyYmlPath(paths.userGlobal),
92
+ json: paths.userGlobal,
93
+ schema: infraKitOverrideConfigSchema,
94
+ },
95
+ {
96
+ label: `~/.infra-kit/projects/${paths.projectName}/infra-kit.json`,
97
+ yml: legacyYmlPath(paths.userProject),
98
+ json: paths.userProject,
99
+ schema: infraKitOverrideConfigSchema,
100
+ },
101
+ ]
102
+
103
+ let migrated = 0
104
+
105
+ for (const layer of layers) {
106
+ const [ymlExists, jsonExists] = await Promise.all([fileExists(layer.yml), fileExists(layer.json)])
107
+
108
+ if (!ymlExists) continue
109
+
110
+ if (jsonExists) {
111
+ logger.info(
112
+ `⚠ Skipped ${tildify(layer.yml)} — ${tildify(layer.json)} already exists (remove the stale .yml manually)`,
113
+ )
114
+
115
+ continue
116
+ }
117
+
118
+ // Keep per-layer migration non-fatal even for malformed YAML or I/O errors
119
+ // (TOCTOU after the existence probe, EACCES, read-only FS): warn and skip
120
+ // so one bad layer never aborts `init` or the other layers.
121
+ try {
122
+ const raw = await fs.readFile(layer.yml, 'utf-8')
123
+ const parsed = (yaml.parse(raw) ?? {}) as unknown
124
+ const result = layer.schema.safeParse(parsed)
125
+
126
+ if (!result.success) {
127
+ logger.info(`⚠ Skipped ${tildify(layer.yml)} — invalid config: ${z.prettifyError(result.error)}`)
128
+
129
+ continue
130
+ }
131
+
132
+ await fs.mkdir(path.dirname(layer.json), { recursive: true })
133
+ await fs.writeFile(layer.json, `${JSON.stringify(result.data, null, 2)}\n`, 'utf-8')
134
+ await fs.rm(layer.yml, { force: true })
135
+
136
+ logger.info(`✓ Migrated ${tildify(layer.yml)} → ${tildify(layer.json)}`)
137
+ migrated++
138
+ } catch (err) {
139
+ logger.info(`⚠ Skipped ${tildify(layer.yml)} — ${(err as Error).message}`)
140
+ }
141
+ }
142
+
143
+ if (migrated > 0) {
144
+ resetInfraKitConfigCache()
145
+ }
146
+ }
@@ -0,0 +1 @@
1
+ {"version":"4.1.8","results":[[":audit/__tests__/audit.test.ts",{"duration":0,"failed":true}]]}
@@ -0,0 +1,55 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import type { z } from 'zod'
3
+
4
+ import { releaseCreateMcpTool } from '../release-create'
5
+
6
+ /**
7
+ * The MCP `releases[]` entry schema enforces that exactly one of `version` or
8
+ * `name` is supplied, and transforms each entry into the internal ReleaseInput
9
+ * shape. We exercise the schema directly (the array element) so we do not need
10
+ * to run the full handler (which performs git/Jira side effects).
11
+ */
12
+ const entrySchema = (releaseCreateMcpTool.inputSchema.releases as z.ZodArray<z.ZodTypeAny>).element
13
+
14
+ describe('release-create MCP releases[] entry schema', () => {
15
+ it('accepts a versioned entry and transforms it into a version ReleaseInput', () => {
16
+ expect(entrySchema.parse({ version: '1.2.5', type: 'hotfix' })).toEqual({
17
+ version: '1.2.5',
18
+ type: 'hotfix',
19
+ })
20
+ })
21
+
22
+ it('defaults type to regular', () => {
23
+ expect(entrySchema.parse({ version: '1.2.5' })).toEqual({
24
+ version: '1.2.5',
25
+ type: 'regular',
26
+ })
27
+ })
28
+
29
+ it('accepts a named entry and transforms it into a name ReleaseInput', () => {
30
+ expect(entrySchema.parse({ name: 'checkout-redesign', type: 'regular' })).toEqual({
31
+ name: 'checkout-redesign',
32
+ type: 'regular',
33
+ })
34
+ })
35
+
36
+ it('carries description through for named entries', () => {
37
+ expect(entrySchema.parse({ name: 'checkout-redesign', description: 'Q3' })).toEqual({
38
+ name: 'checkout-redesign',
39
+ type: 'regular',
40
+ description: 'Q3',
41
+ })
42
+ })
43
+
44
+ it('rejects an entry with both version and name (mutually exclusive)', () => {
45
+ expect(() => {
46
+ return entrySchema.parse({ version: '1.2.5', name: 'checkout-redesign' })
47
+ }).toThrow(/exactly one of/)
48
+ })
49
+
50
+ it('rejects an entry with neither version nor name', () => {
51
+ expect(() => {
52
+ return entrySchema.parse({ type: 'regular' })
53
+ }).toThrow(/exactly one of/)
54
+ })
55
+ })
@@ -1,13 +1,14 @@
1
1
  import confirm from '@inquirer/confirm'
2
2
  import select from '@inquirer/select'
3
3
  import process from 'node:process'
4
- import { z } from 'zod/v4'
4
+ import { z } from 'zod'
5
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 { OperationError } from 'src/lib/errors/operation-error'
10
10
  import { logger } from 'src/lib/logger'
11
+ import { InvalidReleaseNameError, displayLabel, validateName } from 'src/lib/release-id'
11
12
  import { createSingleRelease, prepareGitForRelease } from 'src/lib/release-utils'
12
13
  import type { ReleaseCreationResult, ReleaseType } from 'src/lib/release-utils'
13
14
  import {
@@ -18,12 +19,12 @@ import {
18
19
  parseVersion,
19
20
  resolveReleaseEntries,
20
21
  } from 'src/lib/version-utils'
21
- import type { ReleaseEntry, SemVer } from 'src/lib/version-utils'
22
+ import type { ReleaseEntry, ReleaseInput, SemVer } from 'src/lib/version-utils'
22
23
  import { defineMcpTool, textContent } from 'src/types'
23
24
  import type { RequiredConfirmedOptionArg } from 'src/types'
24
25
 
25
26
  interface ReleaseCreateArgs extends RequiredConfirmedOptionArg {
26
- releases?: ReleaseEntry[]
27
+ releases?: ReleaseInput[]
27
28
  }
28
29
 
29
30
  const VERSION_PROMPT_HINT = '"1.2.5" or "next"'
@@ -38,7 +39,7 @@ const trySuggestNext = (known: SemVer[], type: ReleaseType): string | null => {
38
39
  }
39
40
  }
40
41
 
41
- const resolveOrExit = (entries: ReleaseEntry[], known: SemVer[]): ReleaseEntry[] => {
42
+ const resolveOrExit = (entries: ReleaseInput[], known: SemVer[]): ReleaseEntry[] => {
42
43
  try {
43
44
  return resolveReleaseEntries(entries, known)
44
45
  } catch (err) {
@@ -49,20 +50,80 @@ const resolveOrExit = (entries: ReleaseEntry[], known: SemVer[]): ReleaseEntry[]
49
50
  })
50
51
  }
51
52
 
53
+ if (err instanceof InvalidReleaseNameError) {
54
+ throw new OperationError(err, {
55
+ operation: 'validate release name',
56
+ remediation:
57
+ 'use a kebab-case name like "checkout-redesign" (lowercase, digits, single hyphens, not a reserved word)',
58
+ })
59
+ }
60
+
52
61
  throw err
53
62
  }
54
63
  }
55
64
 
65
+ const promptForVersionInput = async (running: SemVer[], type: ReleaseType): Promise<string> => {
66
+ const suggestion = trySuggestNext(running, type)
67
+ const defaultHint = suggestion ? ` [${suggestion}]` : ''
68
+ const versionAnswer = (await question(` Version (e.g. ${VERSION_PROMPT_HINT})${defaultHint}: `)).trim()
69
+ const versionInput = versionAnswer === '' ? (suggestion ?? '') : versionAnswer
70
+
71
+ if (versionInput === '') {
72
+ logger.error('No version provided. Exiting...')
73
+ process.exit(1)
74
+ }
75
+
76
+ return versionInput
77
+ }
78
+
79
+ const promptForNameInput = async (): Promise<string> => {
80
+ const name = (await question(' Name (kebab-case, e.g. "checkout-redesign"): ')).trim()
81
+
82
+ if (name === '') {
83
+ logger.error('No name provided. Exiting...')
84
+ process.exit(1)
85
+ }
86
+
87
+ try {
88
+ validateName(name)
89
+ } catch (err) {
90
+ const reason = err instanceof Error ? err.message : String(err)
91
+
92
+ logger.error(`${reason} Exiting...`)
93
+ process.exit(1)
94
+ }
95
+
96
+ return name
97
+ }
98
+
56
99
  const promptForReleasesInteractive = async (ensureKnown: () => Promise<SemVer[]>): Promise<ReleaseEntry[]> => {
57
100
  commandEcho.setInteractive()
58
101
 
59
- const baseKnown = await ensureKnown()
60
- const running: SemVer[] = [...baseKnown]
102
+ let baseKnown: SemVer[] | null = null
103
+ const running: SemVer[] = []
104
+ const ensureRunning = async (): Promise<SemVer[]> => {
105
+ if (baseKnown === null) {
106
+ baseKnown = await ensureKnown()
107
+ running.push(...baseKnown)
108
+ }
109
+
110
+ return running
111
+ }
112
+
61
113
  const entries: ReleaseEntry[] = []
62
114
  let addAnother = true
63
115
 
64
116
  while (addAnother) {
65
117
  const ordinal = entries.length + 1
118
+ const kind = await select<'version' | 'name'>({
119
+ message: `Release #${ordinal} — version or name?`,
120
+ choices: [
121
+ { name: 'version (semver / next)', value: 'version' },
122
+ { name: 'name (free-form)', value: 'name' },
123
+ ],
124
+ default: 'version',
125
+ })
126
+
66
127
  const type = await select<ReleaseType>({
67
128
  message: `Release #${ordinal} — select type:`,
68
129
  choices: [
@@ -72,19 +133,22 @@ const promptForReleasesInteractive = async (ensureKnown: () => Promise<SemVer[]>
72
133
  default: 'regular',
73
134
  })
74
135
 
75
- const suggestion = trySuggestNext(running, type)
76
- const defaultHint = suggestion ? ` [${suggestion}]` : ''
77
- const versionAnswer = (await question(` Version (e.g. ${VERSION_PROMPT_HINT})${defaultHint}: `)).trim()
78
- const versionInput = versionAnswer === '' ? (suggestion ?? '') : versionAnswer
136
+ let resolved: ReleaseEntry
79
137
 
80
- if (versionInput === '') {
81
- logger.error('No version provided. Exiting...')
82
- process.exit(1)
83
- }
138
+ if (kind === 'name') {
139
+ const name = await promptForNameInput()
140
+
141
+ resolved = resolveOrExit([{ name, type }], [])[0] as ReleaseEntry
142
+ } else {
143
+ // Versions may need prior versions for "next"; load lazily.
144
+ const versionInput = await promptForVersionInput(await ensureRunning(), type)
84
145
 
85
- const resolved = resolveOrExit([{ version: versionInput, type }], running)[0] as ReleaseEntry
146
+ resolved = resolveOrExit([{ version: versionInput, type }], running)[0] as ReleaseEntry
86
147
 
87
- running.push(parseVersion(`v${resolved.version}`))
148
+ if (resolved.id.kind === 'version') {
149
+ running.push(parseVersion(`v${resolved.id.raw}`))
150
+ }
151
+ }
88
152
 
89
153
  const description = (await question(' Description (optional, press Enter to skip): ')).trim()
90
154
 
@@ -97,7 +161,8 @@ const promptForReleasesInteractive = async (ensureKnown: () => Promise<SemVer[]>
97
161
  }
98
162
 
99
163
  const formatReleaseSummary = (entry: ReleaseEntry): string => {
100
- const parts = [`v${entry.version}`, entry.type]
164
+ const label = entry.id.kind === 'version' ? `v${entry.id.raw}` : entry.id.name
165
+ const parts = [label, entry.type]
101
166
 
102
167
  if (entry.description) parts.push(entry.description)
103
168
 
@@ -106,9 +171,17 @@ const formatReleaseSummary = (entry: ReleaseEntry): string => {
106
171
 
107
172
  const echoReleases = (entries: ReleaseEntry[]): void => {
108
173
  for (const entry of entries) {
174
+ if (entry.id.kind === 'name') {
175
+ // Named releases echo the --name form; description is not part of the
176
+ // flag and is set later via release-desc-edit / interactively.
177
+ commandEcho.addOption('--name', entry.id.name)
178
+
179
+ continue
180
+ }
181
+
109
182
  const spec = entry.description
110
- ? `${entry.version}:${entry.type}:${entry.description}`
111
- : `${entry.version}:${entry.type}`
183
+ ? `${entry.id.raw}:${entry.type}:${entry.description}`
184
+ : `${entry.id.raw}:${entry.type}`
112
185
 
113
186
  commandEcho.addOption('--release', spec)
114
187
  }
@@ -120,7 +193,7 @@ interface FailedRelease {
120
193
  }
121
194
 
122
195
  const collectEntries = async (
123
- inputReleases: ReleaseEntry[] | undefined,
196
+ inputReleases: ReleaseInput[] | undefined,
124
197
  ensureKnown: () => Promise<SemVer[]>,
125
198
  ): Promise<ReleaseEntry[]> => {
126
199
  if (inputReleases && inputReleases.length > 0) {
@@ -168,31 +241,33 @@ const executeOne = async (
168
241
  args: ExecuteOneArgs,
169
242
  ): Promise<{ result?: ReleaseCreationResult; failure?: FailedRelease }> => {
170
243
  const { entry, jiraConfig } = args
244
+ const label = displayLabel(entry.id)
245
+ const prTitleLabel = entry.id.kind === 'version' ? `v${entry.id.raw}` : entry.id.name
171
246
 
172
247
  try {
173
248
  await prepareGitForRelease(entry.type)
174
249
 
175
250
  const result = await createSingleRelease({
176
- version: entry.version,
251
+ id: entry.id,
177
252
  jiraConfig,
178
253
  description: entry.description,
179
254
  type: entry.type,
180
255
  })
181
256
 
182
- logger.info(`✅ Successfully created release: v${entry.version} (${entry.type})`)
257
+ logger.info(`✅ Successfully created release: ${prTitleLabel} (${entry.type})`)
183
258
  logger.info(`🔗 GitHub PR: ${result.prUrl}`)
184
259
  logger.info(`🔗 Jira Version: ${result.jiraVersionUrl}\n`)
185
260
 
186
261
  return { result }
187
262
  } catch (error) {
188
263
  const err = new OperationError(error, {
189
- operation: `create release v${entry.version} (${entry.type})`,
190
- remediation: 'verify the version is unique and the base branch is clean',
264
+ operation: `create release ${prTitleLabel} (${entry.type})`,
265
+ remediation: 'verify the version or name is unique and the base branch is clean',
191
266
  })
192
267
 
193
268
  logger.error(`❌ ${err.message}\n`)
194
269
 
195
- return { failure: { version: entry.version, error: err.message } }
270
+ return { failure: { version: label, error: err.message } }
196
271
  }
197
272
  }
198
273
 
@@ -272,24 +347,53 @@ export const releaseCreate = async (args: ReleaseCreateArgs) => {
272
347
  export const releaseCreateMcpTool = defineMcpTool({
273
348
  name: 'release-create',
274
349
  description:
275
- 'Create one or more releases in a single call. Each entry in "releases" carries its own version, type (regular|hotfix, default regular), and optional description, so regular and hotfix releases can be mixed in the same invocation. For each release this tool switches to the appropriate base branch (dev for regular, main for hotfix), cuts the release branch, opens a GitHub release PR, and creates the matching Jira fix version. The literal token "next" auto-increments from the union of remote release branches and Jira fix versions (regular bumps minor + resets patch; hotfix bumps patch on the highest minor); multiple "next" tokens advance sequentially across mixed types. Confirmation is auto-skipped for MCP calls, so the caller is responsible for gating. Continues on per-release failure and reports successes/failures.',
350
+ 'Create one or more releases in a single call. Each entry in "releases" carries EITHER a "version" (semver or the literal token "next") OR a "name" (free-form kebab-case identifier) — exactly one is required and they are mutually exclusive. Each entry also has its own type (regular|hotfix, default regular) and optional description, so regular and hotfix releases can be mixed in the same invocation. For each release this tool switches to the appropriate base branch (dev for regular, main for hotfix), cuts the release branch (release/v<semver> for versions, release/n/<name> for names), opens a GitHub release PR, and creates the matching Jira fix version (v<semver> for versions, <name> for names). The literal token "next" auto-increments from the union of remote release branches and Jira fix versions (regular bumps minor + resets patch; hotfix bumps patch on the highest minor); multiple "next" tokens advance sequentially across mixed types. Named releases never auto-bump and "next" is version-only. Confirmation is auto-skipped for MCP calls, so the caller is responsible for gating. Continues on per-release failure and reports successes/failures.',
276
351
  inputSchema: {
277
352
  releases: z
278
353
  .array(
279
- z.object({
280
- version: z
281
- .string()
282
- .describe('Version to create (e.g., "1.2.5") or the literal token "next" for auto-increment.'),
283
- type: z
284
- .enum(['regular', 'hotfix'])
285
- .optional()
286
- .default('regular')
287
- .describe('Release type: "regular" (branches off dev) or "hotfix" (branches off main).'),
288
- description: z.string().optional().describe('Optional description for the Jira version.'),
289
- }),
354
+ z
355
+ .object({
356
+ version: z
357
+ .string()
358
+ .optional()
359
+ .describe(
360
+ 'Version to create (e.g., "1.2.5") or the literal token "next" for auto-increment. Mutually exclusive with "name".',
361
+ ),
362
+ name: z
363
+ .string()
364
+ .optional()
365
+ .describe(
366
+ 'Free-form kebab-case release name (e.g., "checkout-redesign"). Mutually exclusive with "version". Named releases never auto-bump.',
367
+ ),
368
+ type: z
369
+ .enum(['regular', 'hotfix'])
370
+ .optional()
371
+ .default('regular')
372
+ .describe('Release type: "regular" (branches off dev) or "hotfix" (branches off main).'),
373
+ description: z.string().optional().describe('Optional description for the Jira version.'),
374
+ })
375
+ .refine(
376
+ (entry) => {
377
+ return (entry.version === undefined) !== (entry.name === undefined)
378
+ },
379
+ {
380
+ message: 'Each release entry must have exactly one of "version" or "name" (they are mutually exclusive).',
381
+ },
382
+ )
383
+ .transform((entry): ReleaseInput => {
384
+ return entry.name !== undefined
385
+ ? { name: entry.name, type: entry.type, ...(entry.description ? { description: entry.description } : {}) }
386
+ : {
387
+ version: entry.version as string,
388
+ type: entry.type,
389
+ ...(entry.description ? { description: entry.description } : {}),
390
+ }
391
+ }),
290
392
  )
291
393
  .min(1)
292
- .describe('One or more releases to create. Each entry has its own version, type, and optional description.'),
394
+ .describe(
395
+ 'One or more releases to create. Each entry has exactly one of "version" or "name", plus its own type and optional description.',
396
+ ),
293
397
  },
294
398
  outputSchema: {
295
399
  createdBranches: z.array(z.string()).describe('List of created release branch names'),