import chalk from "chalk"; import strip from "strip-ansi"; import { type CliParameter, CliParameterType } from "./cli-builder.type"; export function startsWithArray(fullArray: string[], startArray: string[]) { if (startArray.length > fullArray.length) { return false; } return fullArray .slice(0, startArray.length) .every((value, index) => value === startArray[index]); } interface TreeType { [key: string]: CliCommand | TreeType; } /** * Builder for a CLI * @param commands Array of commands to register */ export class CliBuilder { constructor(public commands: CliCommand[] = []) {} /** * Add command to the CLI * @throws Error if command already exists * @param command Command to add */ registerCommand(command: CliCommand) { if (this.checkIfCommandAlreadyExists(command)) { throw new Error( `Command category '${command.categories.join( " ", )}' already exists`, ); } this.commands.push(command); } /** * Add multiple commands to the CLI * @throws Error if command already exists * @param commands Commands to add */ registerCommands(commands: CliCommand[]) { const existingCommand = commands.find((command) => this.checkIfCommandAlreadyExists(command), ); if (existingCommand) { throw new Error( `Command category '${existingCommand.categories.join( " ", )}' already exists`, ); } this.commands.push(...commands); } /** * Remove command from the CLI * @param command Command to remove */ deregisterCommand(command: CliCommand) { this.commands = this.commands.filter( (registeredCommand) => registeredCommand !== command, ); } /** * Remove multiple commands from the CLI * @param commands Commands to remove */ deregisterCommands(commands: CliCommand[]) { this.commands = this.commands.filter( (registeredCommand) => !commands.includes(registeredCommand), ); } checkIfCommandAlreadyExists(command: CliCommand) { return this.commands.some( (registeredCommand) => registeredCommand.categories.length === command.categories.length && registeredCommand.categories.every( (category, index) => category === command.categories[index], ), ); } /** * Get relevant args for the command (without executable or runtime) * @param args Arguments passed to the CLI */ private getRelevantArgs(args: string[]) { if (args[0].startsWith("./")) { // Formatted like ./cli.ts [command] return args.slice(1); } if (args[0].includes("bun")) { // Formatted like bun cli.ts [command] return args.slice(2); } return args; } /** * Turn raw system args into a CLI command and run it * @param args Args directly from process.argv */ async processArgs(args: string[]) { const revelantArgs = this.getRelevantArgs(args); // Handle "-h", "--help" and "help" commands as special cases if (revelantArgs.length === 1) { if (["-h", "--help", "help"].includes(revelantArgs[0])) { this.displayHelp(); return; } } // Find revelant command // Search for a command with as many categories matching args as possible const matchingCommands = this.commands.filter((command) => startsWithArray(revelantArgs, command.categories), ); if (matchingCommands.length === 0) { console.log( `Invalid command "${revelantArgs.join( " ", )}". Please use the ${chalk.bold( "help", )} command to see a list of commands`, ); return 0; } // Get command with largest category size const command = matchingCommands.reduce((prev, current) => prev.categories.length > current.categories.length ? prev : current, ); const argsWithoutCategories = revelantArgs.slice( command.categories.length, ); return await command.run(argsWithoutCategories); } /** * Recursively urns the commands into a tree where subcategories mark each sub-branch * @example * ```txt * user verify * user delete * user new admin * user new * -> * user * verify * delete * new * admin * "" * ``` */ getCommandTree(commands: CliCommand[]): TreeType { const tree: TreeType = {}; for (const command of commands) { let currentLevel = tree; // Start at the root // Split the command into parts and iterate over them for (const part of command.categories) { // If this part doesn't exist in the current level of the tree, add it (__proto__ check to prevent prototype pollution) if (!currentLevel[part] && part !== "__proto__") { // If this is the last part of the command, add the command itself if ( part === command.categories[command.categories.length - 1] ) { currentLevel[part] = command; break; } currentLevel[part] = {}; } // Move down to the next level of the tree currentLevel = currentLevel[part] as TreeType; } } return tree; } /** * Display help for every command in a tree manner */ displayHelp() { /* user set admin: List of admin commands --prod: Whether to run in production --dev: Whether to run in development username: Username of the admin Example: user set admin --prod --dev --username John delete ... verify ... */ const tree = this.getCommandTree(this.commands); let writeBuffer = ""; const displayTree = (tree: TreeType, depth = 0) => { for (const [key, value] of Object.entries(tree)) { if (value instanceof CliCommand) { writeBuffer += `${" ".repeat(depth)}${chalk.blue( key, )}|${chalk.underline(value.description)}\n`; const positionedArgs = value.argTypes.filter( (arg) => arg.positioned ?? true, ); const unpositionedArgs = value.argTypes.filter( (arg) => !(arg.positioned ?? true), ); for (const arg of positionedArgs) { writeBuffer += `${" ".repeat( depth + 1, )}${chalk.green(arg.name)}|${ arg.description ?? "(no description)" } ${arg.optional ? chalk.gray("(optional)") : ""}\n`; } for (const arg of unpositionedArgs) { writeBuffer += `${" ".repeat( depth + 1, )}${chalk.yellow(`--${arg.name}`)}${ arg.shortName ? `, ${chalk.yellow(`-${arg.shortName}`)}` : "" }|${arg.description ?? "(no description)"} ${ arg.optional ? chalk.gray("(optional)") : "" }\n`; } if (value.example) { writeBuffer += `${" ".repeat(depth + 1)}${chalk.bold( "Example:", )} ${chalk.bgGray(value.example)}\n`; } } else { writeBuffer += `${" ".repeat(depth)}${chalk.blue( key, )}\n`; displayTree(value, depth + 1); } } }; displayTree(tree); // Replace all "|" with enough dots so that the text on the left + the dots = the same length const optimal_length = Number( writeBuffer .split("\n") // @ts-expect-error I don't know how this works and I don't want to know .reduce((prev, current) => { // If previousValue is empty if (!prev) return current.includes("|") ? current.split("|")[0].length : 0; if (!current.includes("|")) return prev; const [left] = current.split("|"); // Strip ANSI color codes or they mess up the length return Math.max(Number(prev), Bun.stringWidth(left)); }), ); for (const line of writeBuffer.split("\n")) { const [left, right] = line.split("|"); if (!right) { console.log(left); continue; } // Strip ANSI color codes or they mess up the length const dots = ".".repeat(optimal_length + 5 - Bun.stringWidth(left)); console.log(`${left}${dots}${right}`); } } } type ExecuteFunction = ( instance: CliCommand, args: Partial, ) => Promise | Promise | number | void; /** * A command that can be executed from the command line * @param categories Example: `["user", "create"]` for the command `./cli user create --name John` */ // biome-ignore lint/suspicious/noExplicitAny: export class CliCommand { constructor( public categories: string[], public argTypes: CliParameter[], private execute: ExecuteFunction, public description?: string, public example?: string, ) {} /** * Display help message for the command * formatted with Chalk and with emojis */ displayHelp() { const positionedArgs = this.argTypes.filter( (arg) => arg.positioned ?? true, ); const unpositionedArgs = this.argTypes.filter( (arg) => !(arg.positioned ?? true), ); const helpMessage = ` ${chalk.green("📚 Command:")} ${chalk.yellow(this.categories.join(" "))} ${this.description ? `${chalk.cyan(this.description)}\n` : ""} ${chalk.magenta("🔧 Arguments:")} ${positionedArgs .map( (arg) => `${chalk.bold(arg.name)}: ${chalk.blue( arg.description ?? "(no description)", )} ${arg.optional ? chalk.gray("(optional)") : ""}`, ) .join("\n")} ${unpositionedArgs .map( (arg) => `--${chalk.bold(arg.name)}${ arg.shortName ? `, -${arg.shortName}` : "" }: ${chalk.blue(arg.description ?? "(no description)")} ${ arg.optional ? chalk.gray("(optional)") : "" }`, ) .join("\n")}${ this.example ? `\n${chalk.magenta("🚀 Example:")}\n${chalk.bgGray(this.example)}` : "" } `; console.log(helpMessage); } /** * Parses string array arguments into a full JavaScript object * @param argsWithoutCategories * @returns */ private parseArgs( argsWithoutCategories: string[], ): Record { const parsedArgs: Record = {}; let currentParameter: CliParameter | null = null; for (let i = 0; i < argsWithoutCategories.length; i++) { const arg = argsWithoutCategories[i]; if (arg.startsWith("--")) { const argName = arg.substring(2); currentParameter = this.argTypes.find((argType) => argType.name === argName) || null; if (currentParameter && !currentParameter.needsValue) { parsedArgs[argName] = true; currentParameter = null; } else if (currentParameter?.needsValue) { parsedArgs[argName] = this.castArgValue( argsWithoutCategories[i + 1], currentParameter.type, ); i++; currentParameter = null; } } else if (arg.startsWith("-")) { const shortName = arg.substring(1); const argType = this.argTypes.find( (argType) => argType.shortName === shortName, ); if (argType && !argType.needsValue) { parsedArgs[argType.name] = true; } else if (argType?.needsValue) { parsedArgs[argType.name] = this.castArgValue( argsWithoutCategories[i + 1], argType.type, ); i++; } } else if (currentParameter) { parsedArgs[currentParameter.name] = this.castArgValue( arg, currentParameter.type, ); currentParameter = null; } else { const positionedArgType = this.argTypes.find( (argType) => argType.positioned && !parsedArgs[argType.name], ); if (positionedArgType) { parsedArgs[positionedArgType.name] = this.castArgValue( arg, positionedArgType.type, ); } } } return parsedArgs; } private castArgValue( value: string, type: CliParameter["type"], ): string | number | boolean | string[] { switch (type) { case CliParameterType.STRING: return value; case CliParameterType.NUMBER: return Number(value); case CliParameterType.BOOLEAN: return value === "true"; case CliParameterType.ARRAY: return value.split(","); default: return value; } } /** * Runs the execute function with the parsed parameters as an argument */ async run(argsWithoutCategories: string[]) { const args = this.parseArgs(argsWithoutCategories); return await this.execute(this, args as T); } }