@kubb/cli
Version:
Command-line interface for Kubb, enabling easy generation of TypeScript, React-Query, Zod, and other code from OpenAPI specifications.
448 lines (348 loc) • 12 kB
text/typescript
import { styleText } from 'node:util'
import { type Config, defineLogger, LogLevel } from '@kubb/core'
import { formatHrtime, formatMs } from '@kubb/core/utils'
import { type NonZeroExitError, x } from 'tinyexec'
import { formatMsWithColor } from '../utils/formatMsWithColor.ts'
/**
* GitHub Actions adapter for CI environments
* Uses Github group annotations for collapsible sections
*/
export const githubActionsLogger = defineLogger({
name: 'github-actions',
install(context, options) {
const logLevel = options?.logLevel || LogLevel.info
const state = {
totalPlugins: 0,
completedPlugins: 0,
failedPlugins: 0,
totalFiles: 0,
processedFiles: 0,
hrStart: process.hrtime(),
currentConfigs: [] as Array<Config>,
}
function reset() {
state.totalPlugins = 0
state.completedPlugins = 0
state.failedPlugins = 0
state.totalFiles = 0
state.processedFiles = 0
state.hrStart = process.hrtime()
}
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`)
console.log(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 openGroup(name: string) {
console.log(`::group::${name}`)
}
function closeGroup(_name: string) {
console.log('::endgroup::')
}
context.on('info', (message, info = '') => {
if (logLevel <= LogLevel.silent) {
return
}
const text = getMessage([styleText('blue', 'ℹ'), message, styleText('dim', info)].join(' '))
console.log(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(' '))
console.log(text)
})
context.on('warn', (message, info = '') => {
if (logLevel <= LogLevel.silent) {
return
}
const text = getMessage([styleText('yellow', '⚠'), message, logLevel >= LogLevel.info ? styleText('dim', info) : undefined].filter(Boolean).join(' '))
console.warn(`::warning::${text}`)
})
context.on('error', (error) => {
const caused = error.cause as Error | undefined
if (logLevel <= LogLevel.silent) {
return
}
const message = error.message || String(error)
console.error(`::error::${message}`)
// 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) {
console.log(getMessage(styleText('dim', frame.trim())))
}
if (caused?.stack) {
console.log(styleText('dim', `└─ caused by ${caused.message}`))
const frames = caused.stack.split('\n').slice(1, 4)
for (const frame of frames) {
console.log(getMessage(` ${styleText('dim', frame.trim())}`))
}
}
}
})
context.on('lifecycle:start', (version) => {
console.log(styleText('yellow', `Kubb ${version} 🧩`))
reset()
})
context.on('config:start', () => {
if (logLevel <= LogLevel.silent) {
return
}
const text = getMessage('Configuration started')
openGroup('Configuration')
console.log(text)
})
context.on('config:end', (configs) => {
state.currentConfigs = configs
if (logLevel <= LogLevel.silent) {
return
}
const text = getMessage('Configuration completed')
console.log(text)
closeGroup('Configuration')
})
context.on('generation:start', (config) => {
// Initialize progress tracking
state.totalPlugins = config.plugins?.length || 0
const text = config.name ? `Generation for ${styleText('bold', config.name)}` : 'Generation'
if (state.currentConfigs.length > 1) {
openGroup(text)
}
if (state.currentConfigs.length === 1) {
console.log(getMessage(text))
}
reset()
})
context.on('plugin:start', (plugin) => {
if (logLevel <= LogLevel.silent) {
return
}
const text = getMessage(`Generating ${styleText('bold', plugin.name)}`)
if (state.currentConfigs.length === 1) {
openGroup(`Plugin: ${plugin.name}`)
}
console.log(text)
})
context.on('plugin:end', (plugin, { duration, success }) => {
if (logLevel <= LogLevel.silent) {
return
}
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))}`,
)
console.log(text)
if (state.currentConfigs.length > 1) {
console.log(' ')
}
if (state.currentConfigs.length === 1) {
closeGroup(`Plugin: ${plugin.name}`)
}
// Show progress step after each plugin
showProgressStep()
})
context.on('files:processing:start', (files) => {
if (logLevel <= LogLevel.silent) {
return
}
state.totalFiles = files.length
state.processedFiles = 0
if (state.currentConfigs.length === 1) {
openGroup('File Generation')
}
const text = getMessage(`Writing ${files.length} files`)
console.log(text)
})
context.on('files:processing:end', () => {
if (logLevel <= LogLevel.silent) {
return
}
const text = getMessage('Files written successfully')
console.log(text)
if (state.currentConfigs.length === 1) {
closeGroup('File Generation')
}
})
context.on('file:processing:update', () => {
if (logLevel <= LogLevel.silent) {
return
}
state.processedFiles++
})
context.on('files:processing:end', () => {
if (logLevel <= LogLevel.silent) {
return
}
// Show final progress step after files are written
showProgressStep()
})
context.on('generation:end', (config) => {
const text = getMessage(
config.name ? `${styleText('blue', '✓')} Generation completed for ${styleText('dim', config.name)}` : `${styleText('blue', '✓')} Generation completed`,
)
console.log(text)
})
context.on('format:start', () => {
if (logLevel <= LogLevel.silent) {
return
}
const text = getMessage('Format started')
if (state.currentConfigs.length === 1) {
openGroup('Formatting')
}
console.log(text)
})
context.on('format:end', () => {
if (logLevel <= LogLevel.silent) {
return
}
const text = getMessage('Format completed')
console.log(text)
if (state.currentConfigs.length === 1) {
closeGroup('Formatting')
}
})
context.on('lint:start', () => {
if (logLevel <= LogLevel.silent) {
return
}
const text = getMessage('Lint started')
if (state.currentConfigs.length === 1) {
openGroup('Linting')
}
console.log(text)
})
context.on('lint:end', () => {
if (logLevel <= LogLevel.silent) {
return
}
const text = getMessage('Lint completed')
console.log(text)
if (state.currentConfigs.length === 1) {
closeGroup('Linting')
}
})
context.on('hook:start', async ({ id, command, args }) => {
const commandWithArgs = args?.length ? `${command} ${args.join(' ')}` : command
const text = getMessage(`Hook ${styleText('dim', commandWithArgs)} started`)
if (logLevel > LogLevel.silent) {
if (state.currentConfigs.length === 1) {
openGroup(`Hook ${commandWithArgs}`)
}
console.log(text)
}
// Skip hook execution if no id is provided (e.g., during benchmarks or tests)
if (!id) {
return
}
try {
const result = await x(command, [...(args ?? [])], {
nodeOptions: { detached: true },
throwOnError: true,
})
await context.emit('debug', {
date: new Date(),
logs: [result.stdout.trimEnd()],
})
if (logLevel > LogLevel.silent) {
console.log(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),
})
// Display stderr/stdout in GitHub Actions format
if (stderr) {
console.error(`::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)
}
})
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)} completed`)
console.log(text)
if (state.currentConfigs.length === 1) {
closeGroup(`Hook ${commandWithArgs}`)
}
})
context.on('generation:summary', (config, { status, hrStart, failedPlugins }) => {
const pluginsCount = config.plugins?.length || 0
const successCount = pluginsCount - failedPlugins.size
const duration = formatHrtime(hrStart)
if (state.currentConfigs.length > 1) {
console.log(' ')
}
console.log(
status === 'success'
? `Kubb Summary: ${styleText('blue', '✓')} ${`${successCount} successful`}, ${pluginsCount} total, ${styleText('green', duration)}`
: `Kubb Summary: ${styleText('blue', '✓')} ${`${successCount} successful`}, ✗ ${`${failedPlugins.size} failed`}, ${pluginsCount} total, ${styleText('green', duration)}`,
)
if (state.currentConfigs.length > 1) {
closeGroup(config.name ? `Generation for ${styleText('bold', config.name)}` : 'Generation')
}
})
},
})