@kubb/cli
Version:
Command-line interface for Kubb, enabling easy generation of TypeScript, React-Query, Zod, and other code from OpenAPI specifications.
552 lines (436 loc) • 14.9 kB
text/typescript
import { relative } from 'node:path'
import process from 'node:process'
import { styleText } from 'node:util'
import * as clack from '@clack/prompts'
import { defineLogger, LogLevel } from '@kubb/core'
import { formatHrtime, formatMs } from '@kubb/core/utils'
import { type NonZeroExitError, x } from 'tinyexec'
import { formatMsWithColor } from '../utils/formatMsWithColor.ts'
import { getIntro } from '../utils/getIntro.ts'
import { getSummary } from '../utils/getSummary.ts'
import { ClackWritable } from '../utils/Writables.ts'
/**
* Clack adapter for local TTY environments
* Provides a beautiful CLI UI with flat structure inspired by Claude's CLI patterns
*/
export const clackLogger = defineLogger({
name: 'clack',
install(context, options) {
const logLevel = options?.logLevel || LogLevel.info
const state = {
totalPlugins: 0,
completedPlugins: 0,
failedPlugins: 0,
totalFiles: 0,
processedFiles: 0,
hrStart: process.hrtime(),
spinner: clack.spinner(),
isSpinning: false,
activeProgress: new Map<string, { interval?: NodeJS.Timeout; progressBar: clack.ProgressResult }>(),
}
function reset() {
for (const [_key, active] of state.activeProgress) {
if (active.interval) {
clearInterval(active.interval)
}
active.progressBar?.stop()
}
state.totalPlugins = 0
state.completedPlugins = 0
state.failedPlugins = 0
state.totalFiles = 0
state.processedFiles = 0
state.hrStart = process.hrtime()
state.spinner = clack.spinner()
state.isSpinning = false
state.activeProgress.clear()
}
function showProgressStep() {
if (logLevel <= LogLevel.silent) {
return
}
const parts: string[] = []
const duration = formatHrtime(state.hrStart)
if (state.totalPlugins > 0) {
const pluginStr =
state.failedPlugins > 0
? `Plugins ${styleText('green', state.completedPlugins.toString())}/${state.totalPlugins} ${styleText('red', `(${state.failedPlugins} failed)`)}`
: `Plugins ${styleText('green', state.completedPlugins.toString())}/${state.totalPlugins}`
parts.push(pluginStr)
}
if (state.totalFiles > 0) {
parts.push(`Files ${styleText('green', state.processedFiles.toString())}/${state.totalFiles}`)
}
if (parts.length > 0) {
parts.push(`${styleText('green', duration)} elapsed`)
clack.log.step(getMessage(parts.join(styleText('dim', ' | '))))
}
}
function getMessage(message: string): string {
if (logLevel >= LogLevel.verbose) {
const timestamp = new Date().toLocaleTimeString('en-US', {
hour12: false,
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
})
return [styleText('dim', `[${timestamp}]`), message].join(' ')
}
return message
}
function startSpinner(text?: string) {
state.spinner.start(text)
state.isSpinning = true
}
function stopSpinner(text?: string) {
state.spinner.stop(text)
state.isSpinning = false
}
context.on('info', (message, info = '') => {
if (logLevel <= LogLevel.silent) {
return
}
const text = getMessage([styleText('blue', 'ℹ'), message, styleText('dim', info)].join(' '))
if (state.isSpinning) {
state.spinner.message(text)
} else {
clack.log.info(text)
}
})
context.on('success', (message, info = '') => {
if (logLevel <= LogLevel.silent) {
return
}
const text = getMessage([styleText('blue', '✓'), message, logLevel >= LogLevel.info ? styleText('dim', info) : undefined].filter(Boolean).join(' '))
if (state.isSpinning) {
stopSpinner(text)
} else {
clack.log.success(text)
}
})
context.on('warn', (message, info) => {
if (logLevel < LogLevel.warn) {
return
}
const text = getMessage(
[styleText('yellow', '⚠'), message, logLevel >= LogLevel.info && info ? styleText('dim', info) : undefined].filter(Boolean).join(' '),
)
clack.log.warn(text)
})
context.on('error', (error) => {
const caused = error.cause as Error | undefined
const text = [styleText('red', '✗'), error.message].join(' ')
if (state.isSpinning) {
stopSpinner(getMessage(text))
} else {
clack.log.error(getMessage(text))
}
// Show stack trace in debug mode (first 3 frames)
if (logLevel >= LogLevel.debug && error.stack) {
const frames = error.stack.split('\n').slice(1, 4)
for (const frame of frames) {
clack.log.message(getMessage(styleText('dim', frame.trim())))
}
if (caused?.stack) {
clack.log.message(styleText('dim', `└─ caused by ${caused.message}`))
const frames = caused.stack.split('\n').slice(1, 4)
for (const frame of frames) {
clack.log.message(getMessage(` ${styleText('dim', frame.trim())}`))
}
}
}
})
context.on('version:new', (version, latestVersion) => {
if (logLevel <= LogLevel.silent) {
return
}
clack.box(
`\`v${version}\` → \`v${latestVersion}\`
Run \`npm install -g @kubb/cli\` to update`,
'Update available for `Kubb`',
{
width: 'auto',
formatBorder: (s: string) => styleText('yellow', s),
rounded: true,
withGuide: false,
contentAlign: 'center',
titleAlign: 'center',
},
)
})
context.on('lifecycle:start', async (version) => {
console.log(`\n${getIntro({ title: 'The ultimate toolkit for working with APIs', description: 'Ready to start', version, areEyesOpen: true })}\n`)
reset()
})
context.on('config:start', () => {
if (logLevel <= LogLevel.silent) {
return
}
const text = getMessage('Configuration started')
clack.intro(text)
startSpinner(getMessage('Configuration loading'))
})
context.on('config:end', (_configs) => {
if (logLevel <= LogLevel.silent) {
return
}
const text = getMessage('Configuration completed')
clack.outro(text)
})
context.on('generation:start', (config) => {
// Initialize progress tracking
state.totalPlugins = config.plugins?.length || 0
const text = getMessage(['Generation started', config.name ? `for ${styleText('dim', config.name)}` : undefined].filter(Boolean).join(' '))
clack.intro(text)
reset()
})
context.on('plugin:start', (plugin) => {
if (logLevel <= LogLevel.silent) {
return
}
stopSpinner()
const progressBar = clack.progress({
style: 'block',
max: 100,
size: 30,
})
const text = getMessage(`Generating ${styleText('bold', plugin.name)}`)
progressBar.start(text)
const interval = setInterval(() => {
progressBar.advance()
}, 100)
state.activeProgress.set(plugin.name, { progressBar, interval })
})
context.on('plugin:end', (plugin, { duration, success }) => {
stopSpinner()
const active = state.activeProgress.get(plugin.name)
if (!active || logLevel === LogLevel.silent) {
return
}
clearInterval(active.interval)
if (success) {
state.completedPlugins++
} else {
state.failedPlugins++
}
const durationStr = formatMsWithColor(duration)
const text = getMessage(
success
? `${styleText('bold', plugin.name)} completed in ${durationStr}`
: `${styleText('bold', plugin.name)} failed in ${styleText('red', formatMs(duration))}`,
)
active.progressBar.stop(text)
state.activeProgress.delete(plugin.name)
// Show progress step after each plugin
showProgressStep()
})
context.on('files:processing:start', (files) => {
if (logLevel <= LogLevel.silent) {
return
}
stopSpinner()
state.totalFiles = files.length
state.processedFiles = 0
const text = `Writing ${files.length} files`
const progressBar = clack.progress({
style: 'block',
max: files.length,
size: 30,
})
context.emit('info', text)
progressBar.start(getMessage(text))
state.activeProgress.set('files', { progressBar })
})
context.on('file:processing:update', ({ file, config }) => {
if (logLevel <= LogLevel.silent) {
return
}
stopSpinner()
state.processedFiles++
const text = `Writing ${relative(config.root, file.path)}`
const active = state.activeProgress.get('files')
if (!active) {
return
}
active.progressBar.advance(undefined, text)
})
context.on('files:processing:end', () => {
if (logLevel <= LogLevel.silent) {
return
}
stopSpinner()
const text = getMessage('Files written successfully')
const active = state.activeProgress.get('files')
if (!active) {
return
}
active.progressBar.stop(text)
state.activeProgress.delete('files')
// Show final progress step after files are written
showProgressStep()
})
context.on('generation:end', (config) => {
const text = getMessage(config.name ? `Generation completed for ${styleText('dim', config.name)}` : 'Generation completed')
clack.outro(text)
})
context.on('format:start', () => {
if (logLevel <= LogLevel.silent) {
return
}
const text = getMessage('Format started')
clack.intro(text)
})
context.on('format:end', () => {
if (logLevel <= LogLevel.silent) {
return
}
const text = getMessage('Format completed')
clack.outro(text)
})
context.on('lint:start', () => {
if (logLevel <= LogLevel.silent) {
return
}
const text = getMessage('Lint started')
clack.intro(text)
})
context.on('lint:end', () => {
if (logLevel <= LogLevel.silent) {
return
}
const text = getMessage('Lint completed')
clack.outro(text)
})
context.on('hook:start', async ({ id, command, args }) => {
const commandWithArgs = args?.length ? `${command} ${args.join(' ')}` : command
const text = getMessage(`Hook ${styleText('dim', commandWithArgs)} started`)
// Skip hook execution if no id is provided (e.g., during benchmarks or tests)
if (!id) {
return
}
if (logLevel <= LogLevel.silent) {
try {
const result = await x(command, [...(args ?? [])], {
nodeOptions: { detached: true },
throwOnError: true,
})
await context.emit('debug', {
date: new Date(),
logs: [result.stdout.trimEnd()],
})
await context.emit('hook:end', {
command,
args,
id,
success: true,
error: null,
})
} catch (err) {
const error = err as NonZeroExitError
const stderr = error.output?.stderr ?? ''
const stdout = error.output?.stdout ?? ''
await context.emit('debug', {
date: new Date(),
logs: [stdout, stderr].filter(Boolean),
})
if (stderr) {
console.error(stderr)
}
if (stdout) {
console.log(stdout)
}
const errorMessage = new Error(`Hook execute failed: ${commandWithArgs}`)
await context.emit('hook:end', {
command,
args,
id,
success: false,
error: errorMessage,
})
await context.emit('error', errorMessage)
}
return
}
clack.intro(text)
const logger = clack.taskLog({
title: getMessage(['Executing hook', logLevel >= LogLevel.info ? styleText('dim', commandWithArgs) : undefined].filter(Boolean).join(' ')),
})
const writable = new ClackWritable(logger)
try {
const proc = x(command, [...(args ?? [])], {
nodeOptions: { detached: true },
throwOnError: true,
})
for await (const line of proc) {
writable.write(line)
}
const result = await proc
await context.emit('debug', {
date: new Date(),
logs: [result.stdout.trimEnd()],
})
await context.emit('hook:end', { command, args, id, success: true, error: null })
} catch (err) {
const error = err as NonZeroExitError
const stderr = error.output?.stderr ?? ''
const stdout = error.output?.stdout ?? ''
await context.emit('debug', {
date: new Date(),
logs: [stdout, stderr].filter(Boolean),
})
if (stderr) {
logger.error(stderr)
}
if (stdout) {
logger.message(stdout)
}
const errorMessage = new Error(`Hook execute failed: ${commandWithArgs}`)
await context.emit('hook:end', { command, args, id, success: false, error: errorMessage })
await context.emit('error', errorMessage)
}
})
context.on('hook:end', ({ command, args }) => {
if (logLevel <= LogLevel.silent) {
return
}
const commandWithArgs = args?.length ? `${command} ${args.join(' ')}` : command
const text = getMessage(`Hook ${styleText('dim', commandWithArgs)} successfully executed`)
clack.outro(text)
})
context.on('generation:summary', (config, { pluginTimings, failedPlugins, filesCreated, status, hrStart }) => {
const summary = getSummary({
failedPlugins,
filesCreated,
config,
status,
hrStart,
pluginTimings: logLevel >= LogLevel.verbose ? pluginTimings : undefined,
})
const title = config.name || ''
summary.unshift('\n')
summary.push('\n')
if (status === 'success') {
clack.box(summary.join('\n'), getMessage(title), {
width: 'auto',
formatBorder: (s: string) => styleText('green', s),
rounded: true,
withGuide: false,
contentAlign: 'left',
titleAlign: 'center',
})
return
}
clack.box(summary.join('\n'), getMessage(title), {
width: 'auto',
formatBorder: (s: string) => styleText('red', s),
rounded: true,
withGuide: false,
contentAlign: 'left',
titleAlign: 'center',
})
})
context.on('lifecycle:end', () => {
reset()
})
},
})