UNPKG

tuix

Version:

A performant TUI framework for Bun with JSX and reactive state management

469 lines (415 loc) 14.2 kB
/** * CLI Configuration System * * Provides the defineConfig API for creating CLI applications */ import { z } from "zod" import type { CLIConfig, CommandConfig, LazyHandler, Handler } from "./types" // Re-export types for convenience export type { CLIConfig, CommandConfig, LazyHandler, Handler } from "./types" /** * Define a CLI configuration with commands, options, and plugins * * This function ensures that all required default properties are present * in the configuration object, making it safe to use throughout the CLI framework. * * @param config - The CLI configuration object * @returns A complete CLI configuration with all defaults applied * * @example * ```typescript * const config = defineConfig({ * name: "my-cli", * version: "1.0.0", * description: "My awesome CLI tool", * commands: { * hello: { * description: "Say hello", * handler: async () => console.log("Hello!") * } * } * }) * ``` */ export function defineConfig(config: CLIConfig): CLIConfig { return { ...config, // Ensure we have defaults options: config.options || {}, commands: config.commands || {}, plugins: config.plugins || [], hooks: config.hooks || {} } } /** * Create a lazy-loaded command handler */ export function lazyLoad(importFn: () => Promise<{ default: any }>): LazyHandler { const lazyHandler = async () => { const module = await importFn() return module.default } // Add metadata for testing/introspection ;(lazyHandler as any)._lazy = true ;(lazyHandler as any)._loader = importFn return lazyHandler } /** * Helper to create a command configuration */ export function defineCommand(config: Omit<CommandConfig, 'handler'> & { handler: Handler | LazyHandler | (() => Promise<{ default: Handler }>) }): CommandConfig { const { handler, ...rest } = config // If handler is a dynamic import function, wrap it with lazyLoad if (typeof handler === 'function' && handler.constructor.name === 'AsyncFunction' && !handler.length) { return { ...rest, handler: lazyLoad(handler as () => Promise<{ default: Handler }>) } } return { ...rest, handler: handler as Handler | LazyHandler } } /** * Create common option schemas */ export const commonOptions = { verbose: z.boolean().default(false).describe("Enable verbose output"), quiet: z.boolean().default(false).describe("Suppress output"), help: z.boolean().default(false).describe("Show help information"), version: z.boolean().default(false).describe("Show version information"), config: z.string().optional().describe("Path to config file"), output: z.string().optional().describe("Output file path"), force: z.boolean().default(false).describe("Force operation without confirmation"), yes: z.boolean().default(false).describe("Answer yes to all prompts"), no: z.boolean().default(false).describe("Answer no to all prompts"), // Common server options port: z.number().min(1).max(65535).default(3000).describe("Port number"), host: z.string().default("localhost").describe("Host to bind to"), // Common file options input: z.string().describe("Input file path"), watch: z.boolean().default(false).describe("Watch for file changes"), // Common logging options logLevel: z.enum(["debug", "info", "warn", "error"]).default("info").describe("Log level"), logFile: z.string().optional().describe("Log file path"), // Common format options format: z.enum(["json", "yaml", "table", "csv"]).default("table").describe("Output format"), pretty: z.boolean().default(false).describe("Pretty print output") } /** * Create common argument schemas */ export const commonArgs = { path: z.string().describe("File or directory path"), paths: z.array(z.string()).describe("Multiple file or directory paths"), file: z.string().describe("File path"), name: z.string().describe("Name"), names: z.array(z.string()).describe("Multiple names"), value: z.string().describe("Value"), values: z.array(z.string()).describe("Multiple values"), id: z.string().describe("Identifier"), url: z.string().url().describe("URL"), email: z.string().email().describe("Email address") } /** * Validation helpers */ export function validateConfig(config: CLIConfig): void { if (!config.name || config.name.trim() === "") { throw new Error("CLI config must have a name") } if (!config.version || config.version.trim() === "") { throw new Error("CLI config must have a version") } // Basic semver validation const semverRegex = /^\d+\.\d+\.\d+/ if (!semverRegex.test(config.version)) { throw new Error("CLI config version must be a valid semver format") } if (config.description !== undefined && config.description.trim() === "") { throw new Error("CLI config description cannot be empty string") } // Validate command names don't conflict with built-in options const reservedNames = ['help', 'version', 'h', 'v'] Object.keys(config.commands || {}).forEach(name => { if (reservedNames.includes(name)) { throw new Error(`Command name "${name}" is reserved`) } }) } /** * Merge multiple CLI configs (useful for plugins) * * This function combines multiple CLI configurations, with later configurations * taking precedence over earlier ones. Useful for plugin systems where * plugins need to extend or override base configuration. * * @param base - The base CLI configuration * @param configs - Additional configurations to merge (later configs override earlier ones) * @returns A merged CLI configuration * * @example * ```typescript * const baseConfig = defineConfig({ * name: "my-cli", * version: "1.0.0", * commands: { help: helpCommand } * }) * * const pluginConfig = { * commands: { deploy: deployCommand }, * options: { verbose: z.boolean() } * } * * const merged = mergeConfigs(baseConfig, pluginConfig) * // merged now has both help and deploy commands * ``` */ export function mergeConfigs(base: CLIConfig, ...configs: Partial<CLIConfig>[]): CLIConfig { const merged: CLIConfig = { ...base } for (const config of configs) { // Merge top-level properties if (config.name !== undefined) merged.name = config.name if (config.version !== undefined) merged.version = config.version if (config.description !== undefined) merged.description = config.description if (config.commands) { merged.commands = merged.commands || {} Object.entries(config.commands).forEach(([name, command]) => { if (merged.commands![name]) { // Deep merge command config including nested commands merged.commands![name] = { ...merged.commands![name], ...command, options: { ...merged.commands![name].options, ...command.options }, commands: command.commands ? { ...merged.commands![name].commands, ...command.commands } : merged.commands![name].commands } } else { merged.commands![name] = command } }) } if (config.options) { merged.options = { ...merged.options, ...config.options } } if (config.settings) { merged.settings = { ...merged.settings, ...config.settings } } if (config.plugins) { merged.plugins = [...(merged.plugins || []), ...config.plugins] } // Merge hooks if (config.hooks) { const hooks = merged.hooks || {} if (config.hooks.beforeCommand) { const existing = hooks.beforeCommand hooks.beforeCommand = existing ? async (cmd, args) => { await existing(cmd, args) await config.hooks!.beforeCommand!(cmd, args) } : config.hooks.beforeCommand } if (config.hooks.afterCommand) { const existing = hooks.afterCommand hooks.afterCommand = existing ? async (cmd, args, result) => { await existing(cmd, args, result) await config.hooks!.afterCommand!(cmd, args, result) } : config.hooks.afterCommand } if (config.hooks.onError) { const existing = hooks.onError hooks.onError = existing ? async (error, cmd, args) => { await existing(error, cmd, args) await config.hooks!.onError!(error, cmd, args) } : config.hooks.onError } merged.hooks = hooks } } return merged } /** * Load configuration from a file path */ export async function loadConfig(filePath: string): Promise<CLIConfig> { try { const config = await import(filePath) const configData = config.default || config validateConfig(configData) return configData } catch (error) { throw new Error(`Failed to load config from ${filePath}: ${error}`) } } /** * Parse environment variables with a given prefix */ export function parseEnvVars(env: Record<string, string> | NodeJS.ProcessEnv, prefix: string): any { const result: any = {} Object.entries(env).forEach(([key, value]) => { if (key.startsWith(prefix) && value) { const cleanKey = key.slice(prefix.length).toLowerCase() // Handle special mappings for common CLI env vars const settingsKeys = ['colors', 'interactive', 'output'] const globalOptionsKeys = ['verbose', 'debug', 'quiet'] let targetObj = result let finalKey = cleanKey if (settingsKeys.includes(cleanKey)) { if (!result.settings) result.settings = {} targetObj = result.settings } else if (globalOptionsKeys.includes(cleanKey)) { if (!result.globalOptions) result.globalOptions = {} targetObj = result.globalOptions } else if (cleanKey.includes('_')) { // Handle nested keys like CLI_SETTINGS_COLORS -> settings.colors const parts = cleanKey.split('_') for (let i = 0; i < parts.length - 1; i++) { const part = parts[i] if (!targetObj[part]) { targetObj[part] = {} } targetObj = targetObj[part] } finalKey = parts[parts.length - 1] } // Convert string values to appropriate types if (value === 'true') { targetObj[finalKey] = true } else if (value === 'false') { targetObj[finalKey] = false } else { // For settings keys, validate the value makes sense if (settingsKeys.includes(cleanKey) && !['true', 'false'].includes(value)) { // Skip invalid boolean values for settings return } targetObj[finalKey] = value } } }) return result } /** * Create configuration from environment variables */ export function createConfigFromEnv(prefix = "CLI"): Partial<CLIConfig> { const envVars = parseEnvVars(prefix) const config: Partial<CLIConfig> = {} if (envVars.name) config.name = envVars.name if (envVars.version) config.version = envVars.version if (envVars.description) config.description = envVars.description return config } /** * Get default configuration */ export function getDefaultConfig(): CLIConfig { return { name: "cli", version: "1.0.0", description: "A CLI application", commands: {}, options: {}, plugins: [], hooks: {} } } /** * Alias for getDefaultConfig - for backwards compatibility */ export function createDefaultConfig(name?: string): CLIConfig { return { name: name || "cli", version: "0.0.1", description: "A CLI application", commands: {}, options: {}, plugins: [], hooks: {}, settings: { colors: true, interactive: true } } } /** * Expand command aliases in configuration */ export function expandAliases(config: CLIConfig): CLIConfig { const expandedConfig = { ...config, commands: { ...config.commands } } function expandCommandAliases(commands: Record<string, any>) { const expanded = { ...commands } Object.entries(commands).forEach(([commandName, commandConfig]) => { // Expand aliases for this command if (commandConfig.aliases) { commandConfig.aliases.forEach((alias: string) => { expanded[alias] = { ...commandConfig } }) } // Recursively expand subcommands if (commandConfig.subcommands) { expanded[commandName] = { ...commandConfig, subcommands: expandCommandAliases(commandConfig.subcommands) } // Also expand for the alias commands if (commandConfig.aliases) { commandConfig.aliases.forEach((alias: string) => { expanded[alias] = { ...commandConfig, subcommands: expandCommandAliases(commandConfig.subcommands) } }) } } }) return expanded } expandedConfig.commands = expandCommandAliases(config.commands || {}) return expandedConfig } /** * Resolve configuration file path */ export function resolveConfigPath(path?: string): string { return path || "./cli.config.js" } /** * Normalize command configuration - overload for string input */ export function normalizeCommand(input: string): string export function normalizeCommand(command: CommandConfig): CommandConfig export function normalizeCommand(input: string | CommandConfig): string | CommandConfig { if (typeof input === 'string') { // Handle different naming conventions if (input.includes('_')) { // Snake_case to kebab-case return input.replace(/[_\s]+/g, '-').toLowerCase() } else if (/^[A-Z]/.test(input) || /[a-z][A-Z]/.test(input)) { // PascalCase or camelCase to lowercase return input.toLowerCase() } else { // Already normalized or kebab-case return input.toLowerCase() } } return { ...input, description: input.description || "", options: input.options || {}, arguments: input.arguments || [] } }