@kubb/cli 4.32.3 → 4.33.0

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 (137) hide show
  1. package/dist/agent-CJ69TqoO.js +87 -0
  2. package/dist/agent-CJ69TqoO.js.map +1 -0
  3. package/dist/agent-CduUX7Ye.cjs +91 -0
  4. package/dist/agent-CduUX7Ye.cjs.map +1 -0
  5. package/dist/agent-D0A3RQho.js +57 -0
  6. package/dist/agent-D0A3RQho.js.map +1 -0
  7. package/dist/agent-DrnwQBZf.cjs +60 -0
  8. package/dist/agent-DrnwQBZf.cjs.map +1 -0
  9. package/dist/constants-CEKRremI.js +79 -0
  10. package/dist/constants-CEKRremI.js.map +1 -0
  11. package/dist/constants-CnPOlsJq.cjs +126 -0
  12. package/dist/constants-CnPOlsJq.cjs.map +1 -0
  13. package/dist/errors-BUjJsNoe.cjs +44 -0
  14. package/dist/errors-BUjJsNoe.cjs.map +1 -0
  15. package/dist/errors-bSLTEh4e.js +27 -0
  16. package/dist/errors-bSLTEh4e.js.map +1 -0
  17. package/dist/{generate-DFdkL6Kp.cjs → generate-ByMgAV76.cjs} +423 -577
  18. package/dist/generate-ByMgAV76.cjs.map +1 -0
  19. package/dist/generate-CiUPO5ds.cjs +65 -0
  20. package/dist/generate-CiUPO5ds.cjs.map +1 -0
  21. package/dist/generate-DIIxtkWT.js +66 -0
  22. package/dist/generate-DIIxtkWT.js.map +1 -0
  23. package/dist/{generate-zZuxBP8z.js → generate-HP5ySfjV.js} +422 -577
  24. package/dist/generate-HP5ySfjV.js.map +1 -0
  25. package/dist/index.cjs +226 -35
  26. package/dist/index.cjs.map +1 -1
  27. package/dist/index.d.ts +1 -1
  28. package/dist/index.js +226 -35
  29. package/dist/index.js.map +1 -1
  30. package/dist/init-Cd1hCb7q.cjs +296 -0
  31. package/dist/init-Cd1hCb7q.cjs.map +1 -0
  32. package/dist/init-DLNrkDF4.js +25 -0
  33. package/dist/init-DLNrkDF4.js.map +1 -0
  34. package/dist/init-Df_aXezV.cjs +24 -0
  35. package/dist/init-Df_aXezV.cjs.map +1 -0
  36. package/dist/init-DyKK2fTp.js +291 -0
  37. package/dist/init-DyKK2fTp.js.map +1 -0
  38. package/dist/jiti-BdskUHhD.cjs +16 -0
  39. package/dist/jiti-BdskUHhD.cjs.map +1 -0
  40. package/dist/jiti-Cl7t20dO.js +11 -0
  41. package/dist/jiti-Cl7t20dO.js.map +1 -0
  42. package/dist/mcp-B73FC8dF.cjs +42 -0
  43. package/dist/mcp-B73FC8dF.cjs.map +1 -0
  44. package/dist/mcp-Bd9LITaI.js +16 -0
  45. package/dist/mcp-Bd9LITaI.js.map +1 -0
  46. package/dist/mcp-Cf-1dsB-.js +41 -0
  47. package/dist/mcp-Cf-1dsB-.js.map +1 -0
  48. package/dist/mcp-Clg-Qnkr.cjs +15 -0
  49. package/dist/mcp-Clg-Qnkr.cjs.map +1 -0
  50. package/dist/package-681jTtCk.js +6 -0
  51. package/dist/package-681jTtCk.js.map +1 -0
  52. package/dist/{package-C2pulzfz.cjs → package-aKgzEJtp.cjs} +2 -2
  53. package/dist/package-aKgzEJtp.cjs.map +1 -0
  54. package/dist/{telemetry-DYWvlxqs.js → telemetry-C4gOKX2x.js} +31 -10
  55. package/dist/telemetry-C4gOKX2x.js.map +1 -0
  56. package/dist/{telemetry-BDSSqUiG.cjs → telemetry-T5IA2dWA.cjs} +40 -7
  57. package/dist/telemetry-T5IA2dWA.cjs.map +1 -0
  58. package/dist/types-CLtz0jem.js +25 -0
  59. package/dist/types-CLtz0jem.js.map +1 -0
  60. package/dist/types-Ck2lzFON.cjs +36 -0
  61. package/dist/types-Ck2lzFON.cjs.map +1 -0
  62. package/dist/validate-Chjg23AE.js +41 -0
  63. package/dist/validate-Chjg23AE.js.map +1 -0
  64. package/dist/validate-Cr26q5xX.js +25 -0
  65. package/dist/validate-Cr26q5xX.js.map +1 -0
  66. package/dist/validate-DURmg-2Q.cjs +24 -0
  67. package/dist/validate-DURmg-2Q.cjs.map +1 -0
  68. package/dist/validate-Dqi9T_c4.cjs +42 -0
  69. package/dist/validate-Dqi9T_c4.cjs.map +1 -0
  70. package/package.json +5 -6
  71. package/src/cli/adapters/nodeAdapter.ts +159 -0
  72. package/src/cli/help.ts +36 -0
  73. package/src/cli/index.ts +16 -0
  74. package/src/cli/parse.ts +18 -0
  75. package/src/cli/schema.ts +38 -0
  76. package/src/cli/types.ts +95 -0
  77. package/src/commands/agent/start.ts +27 -136
  78. package/src/commands/agent.ts +6 -25
  79. package/src/commands/generate.ts +26 -158
  80. package/src/commands/init.ts +9 -360
  81. package/src/commands/mcp.ts +7 -52
  82. package/src/commands/validate.ts +9 -60
  83. package/src/constants.ts +77 -0
  84. package/src/index.ts +36 -42
  85. package/src/loggers/clackLogger.ts +42 -140
  86. package/src/loggers/fileSystemLogger.ts +1 -12
  87. package/src/loggers/githubActionsLogger.ts +36 -101
  88. package/src/loggers/plainLogger.ts +23 -70
  89. package/src/loggers/utils.ts +66 -2
  90. package/src/runners/agent.ts +100 -0
  91. package/src/runners/generate.ts +208 -100
  92. package/src/runners/init.ts +322 -0
  93. package/src/runners/mcp.ts +32 -0
  94. package/src/runners/validate.ts +35 -0
  95. package/src/utils/Writables.ts +2 -2
  96. package/src/utils/envDetection.ts +34 -0
  97. package/src/utils/errors.ts +23 -0
  98. package/src/utils/executeHooks.ts +18 -6
  99. package/src/utils/getCosmiConfig.ts +10 -11
  100. package/src/utils/getIntro.ts +17 -18
  101. package/src/utils/getSummary.ts +11 -15
  102. package/src/utils/jiti.ts +9 -0
  103. package/src/utils/packageManager.ts +3 -3
  104. package/src/utils/randomColor.ts +3 -12
  105. package/src/utils/runHook.ts +75 -0
  106. package/src/utils/spawnAsync.ts +47 -0
  107. package/src/utils/telemetry.ts +8 -25
  108. package/src/utils/watcher.ts +2 -4
  109. package/dist/agent-BuijLPSZ.cjs +0 -20
  110. package/dist/agent-BuijLPSZ.cjs.map +0 -1
  111. package/dist/agent-Dswt_kxP.js +0 -20
  112. package/dist/agent-Dswt_kxP.js.map +0 -1
  113. package/dist/generate-DFdkL6Kp.cjs.map +0 -1
  114. package/dist/generate-zZuxBP8z.js.map +0 -1
  115. package/dist/init-CNLk2fNd.js +0 -304
  116. package/dist/init-CNLk2fNd.js.map +0 -1
  117. package/dist/init-CSP6FGaW.cjs +0 -308
  118. package/dist/init-CSP6FGaW.cjs.map +0 -1
  119. package/dist/mcp-44Od-yig.cjs +0 -57
  120. package/dist/mcp-44Od-yig.cjs.map +0 -1
  121. package/dist/mcp-CgaHrkDs.js +0 -57
  122. package/dist/mcp-CgaHrkDs.js.map +0 -1
  123. package/dist/package--eaEMq2R.js +0 -6
  124. package/dist/package--eaEMq2R.js.map +0 -1
  125. package/dist/package-C2pulzfz.cjs.map +0 -1
  126. package/dist/start-CB8afXV6.cjs +0 -134
  127. package/dist/start-CB8afXV6.cjs.map +0 -1
  128. package/dist/start-DHPjtHJj.js +0 -131
  129. package/dist/start-DHPjtHJj.js.map +0 -1
  130. package/dist/telemetry-BDSSqUiG.cjs.map +0 -1
  131. package/dist/telemetry-DYWvlxqs.js.map +0 -1
  132. package/dist/validate-C7s0cFnp.cjs +0 -66
  133. package/dist/validate-C7s0cFnp.cjs.map +0 -1
  134. package/dist/validate-_7cmvjg_.js +0 -66
  135. package/dist/validate-_7cmvjg_.js.map +0 -1
  136. package/src/loggers/envDetection.ts +0 -28
  137. package/src/loggers/index.ts +0 -5
@@ -1,8 +1,11 @@
1
1
  import { relative } from 'node:path'
2
2
  import { defineLogger, LogLevel } from '@kubb/core'
3
3
  import { formatMs } from '@kubb/core/utils'
4
- import { type NonZeroExitError, x } from 'tinyexec'
4
+ import { SUMMARY_SEPARATOR } from '../constants.ts'
5
+ import { toCause } from '../utils/errors.ts'
5
6
  import { getSummary } from '../utils/getSummary.ts'
7
+ import { runHook } from '../utils/runHook.ts'
8
+ import { formatCommandWithArgs, formatMessage } from './utils.ts'
6
9
 
7
10
  /**
8
11
  * Plain console adapter for non-TTY environments
@@ -11,21 +14,10 @@ import { getSummary } from '../utils/getSummary.ts'
11
14
  export const plainLogger = defineLogger({
12
15
  name: 'plain',
13
16
  install(context, options) {
14
- const logLevel = options?.logLevel || 3
17
+ const logLevel = options?.logLevel ?? LogLevel.info
15
18
 
16
19
  function getMessage(message: string): string {
17
- if (logLevel >= LogLevel.verbose) {
18
- const timestamp = new Date().toLocaleTimeString('en-US', {
19
- hour12: false,
20
- hour: '2-digit',
21
- minute: '2-digit',
22
- second: '2-digit',
23
- })
24
-
25
- return [`[${timestamp}]`, message].join(' ')
26
- }
27
-
28
- return message
20
+ return formatMessage(message, logLevel)
29
21
  }
30
22
 
31
23
  context.on('info', (message, info) => {
@@ -59,7 +51,7 @@ export const plainLogger = defineLogger({
59
51
  })
60
52
 
61
53
  context.on('error', (error) => {
62
- const caused = error.cause as Error | undefined
54
+ const caused = toCause(error)
63
55
 
64
56
  const text = getMessage(['✗', error.message].join(' '))
65
57
 
@@ -108,7 +100,7 @@ export const plainLogger = defineLogger({
108
100
  })
109
101
 
110
102
  context.on('generation:start', () => {
111
- const text = getMessage('Configuration started')
103
+ const text = getMessage('Generation started')
112
104
 
113
105
  console.log(text)
114
106
  })
@@ -210,7 +202,7 @@ export const plainLogger = defineLogger({
210
202
  })
211
203
 
212
204
  context.on('hook:start', async ({ id, command, args }) => {
213
- const commandWithArgs = args?.length ? `${command} ${args.join(' ')}` : command
205
+ const commandWithArgs = formatCommandWithArgs(command, args)
214
206
  const text = getMessage(`Hook ${commandWithArgs} started`)
215
207
 
216
208
  if (logLevel > LogLevel.silent) {
@@ -222,56 +214,17 @@ export const plainLogger = defineLogger({
222
214
  return
223
215
  }
224
216
 
225
- try {
226
- const result = await x(command, [...(args ?? [])], {
227
- nodeOptions: { detached: true },
228
- throwOnError: true,
229
- })
230
-
231
- await context.emit('debug', {
232
- date: new Date(),
233
- logs: [result.stdout.trimEnd()],
234
- })
235
-
236
- if (logLevel > LogLevel.silent) {
237
- console.log(result.stdout.trimEnd())
238
- }
239
-
240
- await context.emit('hook:end', {
241
- command,
242
- args,
243
- id,
244
- success: true,
245
- error: null,
246
- })
247
- } catch (err) {
248
- const error = err as NonZeroExitError
249
- const stderr = error.output?.stderr ?? ''
250
- const stdout = error.output?.stdout ?? ''
251
-
252
- await context.emit('debug', {
253
- date: new Date(),
254
- logs: [stdout, stderr].filter(Boolean),
255
- })
256
-
257
- if (stderr) {
258
- console.error(stderr)
259
- }
260
- if (stdout) {
261
- console.log(stdout)
262
- }
263
-
264
- const errorMessage = new Error(`Hook execute failed: ${commandWithArgs}`)
265
-
266
- await context.emit('hook:end', {
267
- command,
268
- args,
269
- id,
270
- success: false,
271
- error: errorMessage,
272
- })
273
- await context.emit('error', errorMessage)
274
- }
217
+ await runHook({
218
+ id,
219
+ command,
220
+ args,
221
+ commandWithArgs,
222
+ context,
223
+ sink: {
224
+ onStdout: logLevel > LogLevel.silent ? (s) => console.log(s) : undefined,
225
+ onStderr: logLevel > LogLevel.silent ? (s) => console.error(s) : undefined,
226
+ },
227
+ })
275
228
  })
276
229
 
277
230
  context.on('hook:end', ({ command, args }) => {
@@ -279,7 +232,7 @@ export const plainLogger = defineLogger({
279
232
  return
280
233
  }
281
234
 
282
- const commandWithArgs = args?.length ? `${command} ${args.join(' ')}` : command
235
+ const commandWithArgs = formatCommandWithArgs(command, args)
283
236
  const text = getMessage(`Hook ${commandWithArgs} completed`)
284
237
 
285
238
  console.log(text)
@@ -295,9 +248,9 @@ export const plainLogger = defineLogger({
295
248
  pluginTimings: logLevel >= LogLevel.verbose ? pluginTimings : undefined,
296
249
  })
297
250
 
298
- console.log('---------------------------')
251
+ console.log(SUMMARY_SEPARATOR)
299
252
  console.log(summary.join('\n'))
300
- console.log('---------------------------')
253
+ console.log(SUMMARY_SEPARATOR)
301
254
  })
302
255
  },
303
256
  })
@@ -1,13 +1,77 @@
1
+ import { styleText } from 'node:util'
1
2
  import type { Logger, LoggerContext, LoggerOptions } from '@kubb/core'
2
3
  import { LogLevel } from '@kubb/core'
4
+ import { formatHrtime } from '@kubb/core/utils'
5
+ import { canUseTTY, isGitHubActions } from '../utils/envDetection.ts'
3
6
  import { clackLogger } from './clackLogger.ts'
4
- import { canUseTTY, isGitHubActions } from './envDetection.ts'
5
7
  import { fileSystemLogger } from './fileSystemLogger.ts'
6
8
  import { githubActionsLogger } from './githubActionsLogger.ts'
7
9
  import { plainLogger } from './plainLogger.ts'
8
10
  import type { LoggerType } from './types.ts'
9
11
 
10
- export function detectLogger(): LoggerType {
12
+ /**
13
+ * Optionally prefix a message with a [HH:MM:SS] timestamp when logLevel >= verbose.
14
+ * Shared across all logger adapters to avoid duplication.
15
+ */
16
+ export function formatMessage(message: string, logLevel: number): string {
17
+ if (logLevel >= LogLevel.verbose) {
18
+ const timestamp = new Date().toLocaleTimeString('en-US', {
19
+ hour12: false,
20
+ hour: '2-digit',
21
+ minute: '2-digit',
22
+ second: '2-digit',
23
+ })
24
+ return `${styleText('dim', `[${timestamp}]`)} ${message}`
25
+ }
26
+ return message
27
+ }
28
+
29
+ type ProgressState = {
30
+ totalPlugins: number
31
+ completedPlugins: number
32
+ failedPlugins: number
33
+ totalFiles: number
34
+ processedFiles: number
35
+ hrStart: [number, number]
36
+ }
37
+
38
+ /**
39
+ * Build the progress summary line shared by clack and GitHub Actions loggers.
40
+ * Returns null when there is nothing to display.
41
+ */
42
+ export function buildProgressLine(state: ProgressState): string | null {
43
+ const parts: string[] = []
44
+ const duration = formatHrtime(state.hrStart)
45
+
46
+ if (state.totalPlugins > 0) {
47
+ const pluginStr =
48
+ state.failedPlugins > 0
49
+ ? `Plugins ${styleText('green', state.completedPlugins.toString())}/${state.totalPlugins} ${styleText('red', `(${state.failedPlugins} failed)`)}`
50
+ : `Plugins ${styleText('green', state.completedPlugins.toString())}/${state.totalPlugins}`
51
+ parts.push(pluginStr)
52
+ }
53
+
54
+ if (state.totalFiles > 0) {
55
+ parts.push(`Files ${styleText('green', state.processedFiles.toString())}/${state.totalFiles}`)
56
+ }
57
+
58
+ if (parts.length === 0) {
59
+ return null
60
+ }
61
+
62
+ parts.push(`${styleText('green', duration)} elapsed`)
63
+ return parts.join(styleText('dim', ' | '))
64
+ }
65
+
66
+ /**
67
+ * Join a command and its optional args into a single display string.
68
+ * e.g. ("prettier", ["--write", "."]) → "prettier --write ."
69
+ */
70
+ export function formatCommandWithArgs(command: string, args?: readonly string[]): string {
71
+ return args?.length ? `${command} ${args.join(' ')}` : command
72
+ }
73
+
74
+ function detectLogger(): LoggerType {
11
75
  if (isGitHubActions()) {
12
76
  return 'github-actions'
13
77
  }
@@ -0,0 +1,100 @@
1
+ import net from 'node:net'
2
+ import path from 'node:path'
3
+ import * as process from 'node:process'
4
+ import { fileURLToPath } from 'node:url'
5
+ import { styleText } from 'node:util'
6
+ import * as clack from '@clack/prompts'
7
+ import { agentDefaults } from '../constants.ts'
8
+ import { spawnAsync } from '../utils/spawnAsync.ts'
9
+ import { buildTelemetryEvent, sendTelemetry } from '../utils/telemetry.ts'
10
+
11
+ type AgentStartOptions = {
12
+ port: string | undefined
13
+ host: string
14
+ configPath: string
15
+ allowWrite: boolean
16
+ allowAll: boolean
17
+ version: string
18
+ }
19
+
20
+ function isPortAvailable(port: number, host: string): Promise<boolean> {
21
+ return new Promise((resolve) => {
22
+ const server = net.createServer()
23
+ server.once('error', () => resolve(false))
24
+ server.once('listening', () => {
25
+ server.close()
26
+ resolve(true)
27
+ })
28
+ server.listen(port, host)
29
+ })
30
+ }
31
+
32
+ export async function runAgentStart({ port, host, configPath, allowWrite, allowAll, version }: AgentStartOptions): Promise<void> {
33
+ const hrStart = process.hrtime()
34
+
35
+ try {
36
+ // Load .env file into process.env using Node.js built-in (v20.12.0+)
37
+ try {
38
+ process.loadEnvFile()
39
+ } catch {
40
+ // .env file may not exist; ignore
41
+ }
42
+
43
+ // Resolve the @kubb/agent package path
44
+ const agentPkgUrl = import.meta.resolve('@kubb/agent/package.json')
45
+ const agentPkgPath = fileURLToPath(agentPkgUrl)
46
+ const agentDir = path.dirname(agentPkgPath)
47
+ const serverPath = path.join(agentDir, agentDefaults.serverEntryPath)
48
+
49
+ // CLI params take priority over process.env; process.env fills in what the CLI didn't specify;
50
+ // agentDefaults are the last resort. Build env as: defaults ← process.env ← CLI.
51
+ const PORT = port !== undefined ? port : (process.env.PORT ?? agentDefaults.port)
52
+ const HOST = host !== agentDefaults.host ? host : (process.env.HOST ?? agentDefaults.host)
53
+ const KUBB_AGENT_ROOT = process.env.KUBB_AGENT_ROOT ?? process.cwd()
54
+ const KUBB_AGENT_CONFIG = configPath !== agentDefaults.configFile ? configPath : (process.env.KUBB_AGENT_CONFIG ?? agentDefaults.configFile)
55
+ const KUBB_AGENT_ALLOW_WRITE = allowAll || allowWrite ? 'true' : (process.env.KUBB_AGENT_ALLOW_WRITE ?? 'false')
56
+ const KUBB_AGENT_ALLOW_ALL = allowAll ? 'true' : (process.env.KUBB_AGENT_ALLOW_ALL ?? 'false')
57
+ const KUBB_AGENT_TOKEN = process.env.KUBB_AGENT_TOKEN
58
+ const KUBB_AGENT_RETRY_TIMEOUT = process.env.KUBB_AGENT_RETRY_TIMEOUT ?? agentDefaults.retryTimeout
59
+ const KUBB_STUDIO_URL = process.env.KUBB_STUDIO_URL ?? agentDefaults.studioUrl
60
+
61
+ const env = {
62
+ ...process.env,
63
+ PORT,
64
+ HOST,
65
+ KUBB_AGENT_ROOT,
66
+ KUBB_AGENT_CONFIG,
67
+ KUBB_AGENT_ALLOW_WRITE,
68
+ KUBB_AGENT_ALLOW_ALL,
69
+ KUBB_AGENT_TOKEN,
70
+ KUBB_AGENT_RETRY_TIMEOUT,
71
+ KUBB_STUDIO_URL,
72
+ }
73
+
74
+ clack.log.step(styleText('cyan', 'Starting agent server...'))
75
+ clack.log.info(styleText('dim', `Config: ${KUBB_AGENT_CONFIG}`))
76
+ clack.log.info(styleText('dim', `Host: ${HOST}`))
77
+ clack.log.info(styleText('dim', `Port: ${PORT}`))
78
+ if (!KUBB_AGENT_ALLOW_WRITE && !KUBB_AGENT_ALLOW_ALL) {
79
+ clack.log.warn(styleText('yellow', 'Filesystem writes disabled. Use --allow-write or --allow-all to enable.'))
80
+ }
81
+
82
+ if (!(await isPortAvailable(Number(PORT), HOST))) {
83
+ clack.log.error(styleText('red', `Port ${PORT} is already in use. Stop the existing process or choose a different port with --port.`))
84
+ process.exit(1)
85
+ }
86
+
87
+ // Spawns the server as a detached background process so the CLI can exit independently.
88
+ await spawnAsync('node', [serverPath], {
89
+ env,
90
+ cwd: process.cwd(),
91
+ })
92
+
93
+ await sendTelemetry(buildTelemetryEvent({ command: 'agent', kubbVersion: version, hrStart, status: 'success' }))
94
+ } catch (error) {
95
+ await sendTelemetry(buildTelemetryEvent({ command: 'agent', kubbVersion: version, hrStart, status: 'failed' }))
96
+ clack.log.error(styleText('red', 'Failed to start agent server'))
97
+ console.error(error)
98
+ process.exit(1)
99
+ }
100
+ }
@@ -2,12 +2,18 @@ import { createHash } from 'node:crypto'
2
2
  import path from 'node:path'
3
3
  import process from 'node:process'
4
4
  import { styleText } from 'node:util'
5
- import { type Config, type KubbEvents, LogLevel, safeBuild, setup } from '@kubb/core'
5
+ import * as clack from '@clack/prompts'
6
+ import { type CLIOptions, type Config, isInputPath, type KubbEvents, LogLevel, PromiseManager, safeBuild, setup } from '@kubb/core'
6
7
  import type { AsyncEventEmitter } from '@kubb/core/utils'
7
- import { detectFormatter, detectLinter, formatters, linters } from '@kubb/core/utils'
8
+ import { AsyncEventEmitter as AsyncEventEmitterClass, detectFormatter, detectLinter, executeIfOnline, formatters, getConfigs, linters } from '@kubb/core/utils'
8
9
  import { version } from '../../package.json'
10
+ import { KUBB_NPM_PACKAGE_URL } from '../constants.ts'
11
+ import { setupLogger } from '../loggers/utils.ts'
12
+ import { toError } from '../utils/errors.ts'
9
13
  import { executeHooks } from '../utils/executeHooks.ts'
14
+ import { getCosmiConfig } from '../utils/getCosmiConfig.ts'
10
15
  import { buildTelemetryEvent, sendTelemetry } from '../utils/telemetry.ts'
16
+ import { startWatcher } from '../utils/watcher.ts'
11
17
 
12
18
  type GenerateProps = {
13
19
  input?: string
@@ -16,7 +22,103 @@ type GenerateProps = {
16
22
  logLevel: number
17
23
  }
18
24
 
19
- export async function generate({ input, config: userConfig, events, logLevel }: GenerateProps): Promise<void> {
25
+ type ToolMap = typeof formatters | typeof linters
26
+
27
+ type RunToolPassOptions = {
28
+ toolValue: string
29
+ detect: () => Promise<string | undefined>
30
+ toolMap: ToolMap
31
+ /** Short noun used in "Auto-detected <toolLabel>:" message, e.g. "formatter" or "linter". */
32
+ toolLabel: string
33
+ /** Verb prefix for the success message, e.g. "Formatting" or "Linting". */
34
+ successPrefix: string
35
+ noToolMessage: string
36
+ configName: string | undefined
37
+ outputPath: string
38
+ logLevel: number
39
+ events: AsyncEventEmitter<KubbEvents>
40
+ onStart: () => Promise<void>
41
+ onEnd: () => Promise<void>
42
+ }
43
+
44
+ async function runToolPass({
45
+ toolValue,
46
+ detect,
47
+ toolMap,
48
+ toolLabel,
49
+ successPrefix,
50
+ noToolMessage,
51
+ configName,
52
+ outputPath,
53
+ logLevel,
54
+ events,
55
+ onStart,
56
+ onEnd,
57
+ }: RunToolPassOptions) {
58
+ await onStart()
59
+
60
+ let resolvedTool = toolValue
61
+ if (resolvedTool === 'auto') {
62
+ const detected = await detect()
63
+ if (!detected) {
64
+ await events.emit('warn', noToolMessage)
65
+ } else {
66
+ resolvedTool = detected
67
+ await events.emit('info', `Auto-detected ${toolLabel}: ${styleText('dim', resolvedTool)}`)
68
+ }
69
+ }
70
+
71
+ if (resolvedTool && resolvedTool !== 'auto' && resolvedTool in toolMap) {
72
+ const toolConfig = toolMap[resolvedTool as keyof ToolMap]
73
+
74
+ try {
75
+ const hookId = createHash('sha256').update([configName, resolvedTool].filter(Boolean).join('-')).digest('hex')
76
+
77
+ // Wire up the hook:end listener BEFORE emitting hook:start to avoid the race condition
78
+ // where hook:end fires synchronously inside emit('hook:start') before the listener is registered.
79
+ const hookEndPromise = new Promise<void>((resolve, reject) => {
80
+ const handler = ({ id, success, error }: { id?: string; command: string; args?: readonly string[]; success: boolean; error: Error | null }) => {
81
+ if (id !== hookId) return
82
+ events.off('hook:end', handler)
83
+ if (!success) {
84
+ reject(error ?? new Error(`${toolConfig.errorMessage}`))
85
+ return
86
+ }
87
+ events
88
+ .emit(
89
+ 'success',
90
+ [
91
+ `${successPrefix} with ${styleText('dim', resolvedTool)}`,
92
+ logLevel >= LogLevel.info ? `on ${styleText('dim', outputPath)}` : undefined,
93
+ 'successfully',
94
+ ]
95
+ .filter(Boolean)
96
+ .join(' '),
97
+ )
98
+ .then(resolve)
99
+ .catch(reject)
100
+ }
101
+ events.on('hook:end', handler)
102
+ })
103
+
104
+ await events.emit('hook:start', {
105
+ id: hookId,
106
+ command: toolConfig.command,
107
+ args: toolConfig.args(outputPath),
108
+ })
109
+
110
+ await hookEndPromise
111
+ } catch (caughtError) {
112
+ const err = new Error(toolConfig.errorMessage)
113
+ err.cause = caughtError
114
+ await events.emit('error', err)
115
+ }
116
+ }
117
+
118
+ await onEnd()
119
+ }
120
+
121
+ async function generate({ input, config: userConfig, events, logLevel }: GenerateProps): Promise<void> {
20
122
  const inputPath = input ?? ('path' in userConfig.input ? userConfig.input.path : undefined)
21
123
  const hrStart = process.hrtime()
22
124
 
@@ -73,9 +175,9 @@ export async function generate({ input, config: userConfig, events, logLevel }:
73
175
  .map((it) => it.error),
74
176
  ].filter(Boolean)
75
177
 
76
- allErrors.forEach((err) => {
77
- events.emit('error', err)
78
- })
178
+ for (const err of allErrors) {
179
+ await events.emit('error', err)
180
+ }
79
181
 
80
182
  await events.emit('generation:end', config, files, sources)
81
183
 
@@ -104,100 +206,40 @@ export async function generate({ input, config: userConfig, events, logLevel }:
104
206
  await events.emit('success', 'Generation successfully', inputPath)
105
207
  await events.emit('generation:end', config, files, sources)
106
208
 
107
- // formatting
108
- if (config.output.format) {
109
- await events.emit('format:start')
110
-
111
- let formatter = config.output.format
112
- if (formatter === 'auto') {
113
- const detectedFormatter = await detectFormatter()
114
- if (!detectedFormatter) {
115
- await events.emit('warn', 'No formatter found (biome, prettier, or oxfmt). Skipping formatting.')
116
- } else {
117
- formatter = detectedFormatter
118
- await events.emit('info', `Auto-detected formatter: ${styleText('dim', formatter)}`)
119
- }
120
- }
121
-
122
- if (formatter && formatter !== 'auto' && formatter in formatters) {
123
- const formatterConfig = formatters[formatter as keyof typeof formatters]
124
- const outputPath = path.resolve(config.root, config.output.path)
125
-
126
- try {
127
- const hookId = createHash('sha256').update([config.name, formatter].filter(Boolean).join('-')).digest('hex')
128
- await events.emit('hook:start', {
129
- id: hookId,
130
- command: formatterConfig.command,
131
- args: formatterConfig.args(outputPath),
132
- })
133
-
134
- await events.onOnce('hook:end', async ({ success, error }) => {
135
- if (!success) throw error
136
-
137
- await events.emit(
138
- 'success',
139
- [`Formatting with ${styleText('dim', formatter)}`, logLevel >= LogLevel.info ? `on ${styleText('dim', outputPath)}` : undefined, 'successfully']
140
- .filter(Boolean)
141
- .join(' '),
142
- )
143
- })
144
- } catch (caughtError) {
145
- const error = new Error(formatterConfig.errorMessage)
146
- error.cause = caughtError
147
- await events.emit('error', error)
148
- }
149
- }
209
+ const outputPath = path.resolve(config.root, config.output.path)
150
210
 
151
- await events.emit('format:end')
211
+ if (config.output.format) {
212
+ await runToolPass({
213
+ toolValue: config.output.format,
214
+ detect: detectFormatter,
215
+ toolMap: formatters,
216
+ toolLabel: 'formatter',
217
+ successPrefix: 'Formatting',
218
+ noToolMessage: 'No formatter found (biome, prettier, or oxfmt). Skipping formatting.',
219
+ configName: config.name,
220
+ outputPath,
221
+ logLevel,
222
+ events,
223
+ onStart: () => events.emit('format:start'),
224
+ onEnd: () => events.emit('format:end'),
225
+ })
152
226
  }
153
227
 
154
- // linting
155
228
  if (config.output.lint) {
156
- await events.emit('lint:start')
157
-
158
- // Detect linter if set to 'auto'
159
- let linter = config.output.lint
160
- if (linter === 'auto') {
161
- const detectedLinter = await detectLinter()
162
- if (!detectedLinter) {
163
- await events.emit('warn', 'No linter found (biome, oxlint, or eslint). Skipping linting.')
164
- } else {
165
- linter = detectedLinter
166
- await events.emit('info', `Auto-detected linter: ${styleText('dim', linter)}`)
167
- }
168
- }
169
-
170
- // Only proceed with linting if we have a valid linter
171
- if (linter && linter !== 'auto' && linter in linters) {
172
- const linterConfig = linters[linter as keyof typeof linters]
173
- const outputPath = path.resolve(config.root, config.output.path)
174
-
175
- try {
176
- const hookId = createHash('sha256').update([config.name, linter].filter(Boolean).join('-')).digest('hex')
177
- await events.emit('hook:start', {
178
- id: hookId,
179
- command: linterConfig.command,
180
- args: linterConfig.args(outputPath),
181
- })
182
-
183
- await events.onOnce('hook:end', async ({ success, error }) => {
184
- if (!success) throw error
185
-
186
- await events.emit(
187
- 'success',
188
- [`Linting with ${styleText('dim', linter)}`, logLevel >= LogLevel.info ? `on ${styleText('dim', outputPath)}` : undefined, 'successfully']
189
- .filter(Boolean)
190
- .join(' '),
191
- )
192
- })
193
- } catch (caughtError) {
194
- const error = new Error(linterConfig.errorMessage)
195
- error.cause = caughtError
196
- await events.emit('error', error)
197
- }
198
- }
199
-
200
- await events.emit('lint:end')
229
+ await runToolPass({
230
+ toolValue: config.output.lint,
231
+ detect: detectLinter,
232
+ toolMap: linters,
233
+ toolLabel: 'linter',
234
+ successPrefix: 'Linting',
235
+ noToolMessage: 'No linter found (biome, oxlint, or eslint). Skipping linting.',
236
+ configName: config.name,
237
+ outputPath,
238
+ logLevel,
239
+ events,
240
+ onStart: () => events.emit('lint:start'),
241
+ onEnd: () => events.emit('lint:end'),
242
+ })
201
243
  }
202
244
 
203
245
  if (config.hooks) {
@@ -207,12 +249,11 @@ export async function generate({ input, config: userConfig, events, logLevel }:
207
249
  await events.emit('hooks:end')
208
250
  }
209
251
 
210
- const generationStatus = failedPlugins.size > 0 || error ? 'failed' : 'success'
211
-
252
+ // Only reached when there are no failures (process.exit(1) is called above otherwise)
212
253
  await events.emit('generation:summary', config, {
213
254
  failedPlugins,
214
255
  filesCreated: files.length,
215
- status: generationStatus,
256
+ status: 'success',
216
257
  hrStart,
217
258
  pluginTimings,
218
259
  })
@@ -223,8 +264,75 @@ export async function generate({ input, config: userConfig, events, logLevel }:
223
264
  plugins: pluginManager.plugins.map((p) => ({ name: p.name, options: p.options as Record<string, unknown> })),
224
265
  hrStart,
225
266
  filesCreated: files.length,
226
- status: generationStatus,
267
+ status: 'success',
227
268
  })
228
269
 
229
270
  await sendTelemetry(telemetryEvent)
230
271
  }
272
+
273
+ type GenerateCommandOptions = {
274
+ input?: string
275
+ configPath?: string
276
+ logLevel: string
277
+ watch: boolean
278
+ }
279
+
280
+ export async function runGenerateCommand({ input, configPath, logLevel: logLevelKey, watch }: GenerateCommandOptions): Promise<void> {
281
+ const logLevel = LogLevel[logLevelKey as keyof typeof LogLevel] ?? LogLevel.info
282
+ const events = new AsyncEventEmitterClass<KubbEvents>()
283
+ const promiseManager = new PromiseManager()
284
+
285
+ await setupLogger(events, { logLevel })
286
+
287
+ await executeIfOnline(async () => {
288
+ try {
289
+ const res = await fetch(KUBB_NPM_PACKAGE_URL)
290
+ const data = (await res.json()) as { version: string }
291
+ const latestVersion = data.version
292
+
293
+ if (latestVersion && version < latestVersion) {
294
+ await events.emit('version:new', version, latestVersion)
295
+ }
296
+ } catch {
297
+ // Ignore network errors for version check
298
+ }
299
+ })
300
+
301
+ try {
302
+ const result = await getCosmiConfig('kubb', configPath)
303
+ const configs = await getConfigs(result.config, { input } as CLIOptions)
304
+
305
+ await events.emit('config:start')
306
+ await events.emit('info', 'Config loaded', path.relative(process.cwd(), result.filepath))
307
+ await events.emit('success', 'Config loaded successfully', path.relative(process.cwd(), result.filepath))
308
+ await events.emit('config:end', configs)
309
+
310
+ await events.emit('lifecycle:start', version)
311
+
312
+ const promises = configs.map((config) => {
313
+ return async () => {
314
+ if (isInputPath(config) && watch) {
315
+ await startWatcher([input || config.input.path], async (paths) => {
316
+ // remove to avoid duplicate listeners after each change
317
+ events.removeAll()
318
+
319
+ await generate({ input, config, logLevel, events })
320
+
321
+ clack.log.step(styleText('yellow', `Watching for changes in ${paths.join(' and ')}`))
322
+ })
323
+
324
+ return
325
+ }
326
+
327
+ await generate({ input, config, logLevel, events })
328
+ }
329
+ })
330
+
331
+ await promiseManager.run('seq', promises)
332
+
333
+ await events.emit('lifecycle:end')
334
+ } catch (error) {
335
+ await events.emit('error', toError(error))
336
+ process.exit(1)
337
+ }
338
+ }