this repo has no description
at main 258 lines 6.4 kB view raw
1import { readdir } from 'node:fs/promises' 2import { extname, join } from 'node:path' 3import { fileURLToPath } from 'node:url' 4import { parse } from '@bomb.sh/args' 5 6export type FlagType = 'string' | 'number' | 'boolean' 7 8export interface FlagDefinition { 9 type: FlagType 10 description: string 11 default?: string | number | boolean 12 required?: boolean 13} 14 15export interface ArgDefinition { 16 description: string 17 required: boolean 18 default?: string 19} 20 21export interface CommandConfig { 22 description: string 23 flags: Record<string, FlagDefinition> 24 args: Record<string, ArgDefinition> 25} 26 27export interface ParsedFlags { 28 [key: string]: string | number | boolean 29} 30 31export interface ParsedArgs { 32 [key: string]: string 33} 34 35export interface Command { 36 config: CommandConfig 37 handler: (params: { flags: ParsedFlags; args: ParsedArgs }) => Promise<void> 38} 39 40export interface CommandPath { 41 path: string 42 command: Command 43} 44 45export interface ParsedCommand { 46 command: string[] 47 flags: ParsedFlags 48 args: ParsedArgs 49 target: Command | null 50} 51 52export interface ShellCasingOptions { 53 commandsDir: string 54 baseDir?: string 55} 56 57const pathCache = new Map< 58 string, 59 { parts: string[]; depth: number; name: string } 60>() 61 62function getPathInfo(path: string) { 63 if (!pathCache.has(path)) { 64 const parts = path.split('/') 65 pathCache.set(path, { 66 parts, 67 depth: parts.length - 1, 68 name: parts[parts.length - 1] || path, 69 }) 70 } 71 const cached = pathCache.get(path) 72 if (!cached) { 73 throw new Error(`Failed to get path info for: ${path}`) 74 } 75 return cached 76} 77 78export async function loadCommands( 79 dir: string, 80 basePath: string = '', 81): Promise<CommandPath[]> { 82 const commands: CommandPath[] = [] 83 84 try { 85 const entries = await readdir(dir, { withFileTypes: true }) 86 87 for (const entry of entries) { 88 const fullPath = join(dir, entry.name) 89 90 if (entry.isDirectory()) { 91 // For directories, we need to track the full path from root 92 const newBasePath = basePath ? `${basePath}/${entry.name}` : entry.name 93 const subCommands = await loadCommands(fullPath, newBasePath) 94 commands.push(...subCommands) 95 } else if ( 96 entry.isFile() && 97 (extname(entry.name) === '.ts' || entry.name === '.js') 98 ) { 99 const commandName = entry.name.replace(/\.(ts|js)$/, '') 100 const commandModule = await import(fullPath) 101 102 if (commandModule.config && commandModule.handler) { 103 // The command path should include the full directory structure 104 const commandPath = basePath 105 ? `${basePath}/${commandName}` 106 : commandName 107 commands.push({ 108 path: commandPath, 109 command: { 110 config: commandModule.config, 111 handler: commandModule.handler, 112 }, 113 }) 114 } 115 } 116 } 117 } catch { 118 // carry on 119 } 120 121 return commands 122} 123 124export function findCommand( 125 commands: CommandPath[], 126 path: string[], 127): Command | null { 128 if (path.length === 0) return null 129 130 const commandPath = path.join('/') 131 return commands.find((cmd) => cmd.path === commandPath)?.command || null 132} 133 134export function parseArgs( 135 commands: CommandPath[], 136 argv: string[], 137): ParsedCommand { 138 const { _: allArgs, ...rawFlags } = parse(argv) 139 const stringArgs = allArgs.map(String) 140 141 // find the longest valid command path 142 let command: string[] = [] 143 let rawArgs: string[] = [] 144 let target: Command | null = null 145 146 // start from the end to find the longest matching command path 147 for (let i = stringArgs.length; i >= 0; i--) { 148 const potentialCommand = stringArgs.slice(0, i) 149 150 const foundTarget = findCommand(commands, potentialCommand) 151 152 if (foundTarget) { 153 // We found a valid command 154 command = stringArgs.slice(0, i) 155 rawArgs = stringArgs.slice(i) 156 target = foundTarget 157 break 158 } 159 } 160 161 if (command.length === 0 && stringArgs.length > 0) { 162 // no command was found, set command to the full stringArgs 163 command = stringArgs 164 } 165 166 // narrow flags to desired types 167 const flags: ParsedFlags = {} 168 for (const [key, value] of Object.entries(rawFlags)) { 169 if ( 170 typeof value === 'string' || 171 typeof value === 'number' || 172 typeof value === 'boolean' 173 ) { 174 flags[key] = value 175 } 176 } 177 178 // convert args array to object based on command's expected args 179 const args: ParsedArgs = {} 180 if (target) { 181 const argNames = Object.keys(target.config.args) 182 rawArgs.forEach((arg, index) => { 183 if (index < argNames.length) { 184 const argName = argNames[index] 185 if (typeof argName === 'string') { 186 args[argName] = arg 187 } 188 } 189 }) 190 } 191 192 return { 193 command, 194 flags, 195 args, 196 target, 197 } 198} 199 200export function showHelp(commands: CommandPath[]) { 201 for (const cmd of commands) { 202 const { depth } = getPathInfo(cmd.path) 203 const indent = ' '.repeat(depth) 204 console.log(`${indent}${cmd.path} - ${cmd.command.config.description}`) 205 } 206} 207 208export async function createCLI( 209 options: ShellCasingOptions, 210): Promise<CommandPath[]> { 211 const baseDir = 212 options.baseDir || fileURLToPath(new URL('.', import.meta.url)) 213 return await loadCommands(join(baseDir, options.commandsDir)) 214} 215 216export async function runCLI( 217 commands: CommandPath[], 218 argv: string[] = process.argv.slice(2), 219): Promise<void> { 220 if (!commands.length) { 221 throw new Error('No commands available. Make sure to call createCLI first.') 222 } 223 224 const { command, flags, args, target } = parseArgs(commands, argv) 225 226 if (command.length === 0) { 227 console.log('Available commands:') 228 showHelp(commands) 229 return 230 } 231 232 if (target) { 233 // do the thing 234 try { 235 await target.handler({ flags, args }) 236 } catch (error) { 237 console.error('Error executing command:', error) 238 throw error 239 } 240 } else { 241 // check if this is a directory path and show subcommands 242 const commandPath = command.join('/') 243 244 const subCommands = commands.filter((cmd) => 245 cmd.path.startsWith(`${commandPath}/`), 246 ) 247 248 if (subCommands.length > 0) { 249 console.log(`Available ${command.join(' ')} commands:`) 250 showHelp(subCommands) 251 } else { 252 console.error(`Command not found: ${command.join(' ')}`) 253 console.log('\nAvailable commands:') 254 showHelp(commands) 255 throw new Error(`Command not found: ${command.join(' ')}`) 256 } 257 } 258}