this repo has no description
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}