Replace eslint and prettier with Biome

This commit is contained in:
Jesse Wierzbinski 2024-04-06 19:30:49 -10:00
parent 4a5a2ea590
commit af0d627f19
No known key found for this signature in database
199 changed files with 16493 additions and 16361 deletions

View file

@ -1,23 +1,23 @@
export interface CliParameter {
name: string;
/* Like -v for --version */
shortName?: string;
/**
* If not positioned, the argument will need to be called with --name value instead of just value
* @default true
*/
positioned?: boolean;
/* Whether the argument needs a value (requires positioned to be false) */
needsValue?: boolean;
optional?: true;
type: CliParameterType;
description?: string;
name: string;
/* Like -v for --version */
shortName?: string;
/**
* If not positioned, the argument will need to be called with --name value instead of just value
* @default true
*/
positioned?: boolean;
/* Whether the argument needs a value (requires positioned to be false) */
needsValue?: boolean;
optional?: true;
type: CliParameterType;
description?: string;
}
export enum CliParameterType {
STRING = "string",
NUMBER = "number",
BOOLEAN = "boolean",
ARRAY = "array",
EMPTY = "empty",
STRING = "string",
NUMBER = "number",
BOOLEAN = "boolean",
ARRAY = "array",
EMPTY = "empty",
}

View file

@ -1,18 +1,18 @@
import { CliParameterType, type CliParameter } from "./cli-builder.type";
import chalk from "chalk";
import strip from "strip-ansi";
import { type CliParameter, CliParameterType } from "./cli-builder.type";
export function startsWithArray(fullArray: any[], startArray: any[]) {
if (startArray.length > fullArray.length) {
return false;
}
return fullArray
.slice(0, startArray.length)
.every((value, index) => value === startArray[index]);
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;
[key: string]: CliCommand | TreeType;
}
/**
@ -20,178 +20,186 @@ interface TreeType {
* @param commands Array of commands to register
*/
export class CliBuilder {
constructor(public commands: CliCommand[] = []) {}
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 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);
}
/**
* 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 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)
);
}
/**
* 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]
)
);
}
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);
} else if (args[0].includes("bun")) {
// Formatted like bun cli.ts [command]
return args.slice(2);
} else {
return args;
}
}
/**
* 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);
/**
* 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;
}
}
// 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)
);
// 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;
}
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
);
// 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
);
const argsWithoutCategories = revelantArgs.slice(
command.categories.length,
);
return await command.run(argsWithoutCategories);
}
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 = {};
/**
* 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
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)
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
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] = {};
}
// 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)
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
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;
}
}
// Move down to the next level of the tree
currentLevel = currentLevel[part] as TreeType;
}
}
return tree;
}
return tree;
}
/**
* Display help for every command in a tree manner
*/
displayHelp() {
/*
/**
* Display help for every command in a tree manner
*/
displayHelp() {
/*
user
set
admin: List of admin commands
@ -204,217 +212,242 @@ export class CliBuilder {
verify
...
*/
const tree = this.getCommandTree(this.commands);
let writeBuffer = "";
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)
);
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`;
}
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);
}
}
};
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);
displayTree(tree);
// Replace all "|" with enough dots so that the text on the left + the dots = the same length
const optimal_length = Number(
// @ts-expect-error Slightly hacky but works
writeBuffer.split("\n").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), strip(left).length);
})
);
// 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 - strip(left).length);
console.log(`${left}${dots}${right}`);
}
}
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<T> = (
instance: CliCommand,
args: Partial<T>
// eslint-disable-next-line @typescript-eslint/no-invalid-void-type
instance: CliCommand,
args: Partial<T>,
// eslint-disable-next-line @typescript-eslint/no-invalid-void-type
) => Promise<number> | Promise<void> | 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`
*/
export class CliCommand<T = any> {
constructor(
public categories: string[],
public argTypes: CliParameter[],
private execute: ExecuteFunction<T>,
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 = `
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
export class CliCommand<T = any> {
constructor(
public categories: string[],
public argTypes: CliParameter[],
private execute: ExecuteFunction<T>,
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")}
.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)}` : ""}
.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);
}
console.log(helpMessage);
}
/**
* Parses string array arguments into a full JavaScript object
* @param argsWithoutCategories
* @returns
*/
private parseArgs(argsWithoutCategories: string[]): Record<string, any> {
const parsedArgs: Record<string, any> = {};
let currentParameter: CliParameter | null = null;
/**
* Parses string array arguments into a full JavaScript object
* @param argsWithoutCategories
* @returns
*/
private parseArgs(
argsWithoutCategories: string[],
): Record<string, string | number | boolean | string[]> {
const parsedArgs: Record<string, string | number | boolean | string[]> =
{};
let currentParameter: CliParameter | null = null;
for (let i = 0; i < argsWithoutCategories.length; i++) {
const arg = argsWithoutCategories[i];
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 && 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 && 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
);
}
}
}
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;
}
return parsedArgs;
}
private castArgValue(value: string, type: CliParameter["type"]): any {
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;
}
}
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 any);
}
/**
* 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);
}
}

View file

@ -1,6 +1,6 @@
{
"name": "cli-parser",
"version": "0.0.0",
"main": "index.ts",
"dependencies": { "chalk": "^5.3.0", "strip-ansi": "^7.1.0" }
}
"name": "cli-parser",
"version": "0.0.0",
"main": "index.ts",
"dependencies": { "chalk": "^5.3.0", "strip-ansi": "^7.1.0" }
}

View file

@ -1,485 +1,488 @@
// FILEPATH: /home/jessew/Dev/lysand/packages/cli-parser/index.test.ts
import { CliCommand, CliBuilder, startsWithArray } from "..";
import { describe, beforeEach, it, expect, jest, spyOn } from "bun:test";
import { beforeEach, describe, expect, it, jest, spyOn } from "bun:test";
import stripAnsi from "strip-ansi";
// FILEPATH: /home/jessew/Dev/lysand/packages/cli-parser/index.test.ts
import { CliBuilder, CliCommand, startsWithArray } from "..";
import { CliParameterType } from "../cli-builder.type";
describe("startsWithArray", () => {
it("should return true when fullArray starts with startArray", () => {
const fullArray = ["a", "b", "c", "d", "e"];
const startArray = ["a", "b", "c"];
expect(startsWithArray(fullArray, startArray)).toBe(true);
});
it("should return true when fullArray starts with startArray", () => {
const fullArray = ["a", "b", "c", "d", "e"];
const startArray = ["a", "b", "c"];
expect(startsWithArray(fullArray, startArray)).toBe(true);
});
it("should return false when fullArray does not start with startArray", () => {
const fullArray = ["a", "b", "c", "d", "e"];
const startArray = ["b", "c", "d"];
expect(startsWithArray(fullArray, startArray)).toBe(false);
});
it("should return false when fullArray does not start with startArray", () => {
const fullArray = ["a", "b", "c", "d", "e"];
const startArray = ["b", "c", "d"];
expect(startsWithArray(fullArray, startArray)).toBe(false);
});
it("should return true when startArray is empty", () => {
const fullArray = ["a", "b", "c", "d", "e"];
const startArray: any[] = [];
expect(startsWithArray(fullArray, startArray)).toBe(true);
});
it("should return true when startArray is empty", () => {
const fullArray = ["a", "b", "c", "d", "e"];
const startArray: string[] = [];
expect(startsWithArray(fullArray, startArray)).toBe(true);
});
it("should return false when fullArray is shorter than startArray", () => {
const fullArray = ["a", "b", "c"];
const startArray = ["a", "b", "c", "d", "e"];
expect(startsWithArray(fullArray, startArray)).toBe(false);
});
it("should return false when fullArray is shorter than startArray", () => {
const fullArray = ["a", "b", "c"];
const startArray = ["a", "b", "c", "d", "e"];
expect(startsWithArray(fullArray, startArray)).toBe(false);
});
});
describe("CliCommand", () => {
let cliCommand: CliCommand;
let cliCommand: CliCommand;
beforeEach(() => {
cliCommand = new CliCommand(
["category1", "category2"],
[
{
name: "arg1",
type: CliParameterType.STRING,
needsValue: true,
},
{
name: "arg2",
shortName: "a",
type: CliParameterType.NUMBER,
needsValue: true,
},
{
name: "arg3",
type: CliParameterType.BOOLEAN,
needsValue: false,
},
{
name: "arg4",
type: CliParameterType.ARRAY,
needsValue: true,
},
],
() => {
// Do nothing
}
);
});
beforeEach(() => {
cliCommand = new CliCommand(
["category1", "category2"],
[
{
name: "arg1",
type: CliParameterType.STRING,
needsValue: true,
},
{
name: "arg2",
shortName: "a",
type: CliParameterType.NUMBER,
needsValue: true,
},
{
name: "arg3",
type: CliParameterType.BOOLEAN,
needsValue: false,
},
{
name: "arg4",
type: CliParameterType.ARRAY,
needsValue: true,
},
],
() => {
// Do nothing
},
);
});
it("should parse string arguments correctly", () => {
const args = cliCommand["parseArgs"]([
"--arg1",
"value1",
"--arg2",
"42",
"--arg3",
"--arg4",
"value1,value2",
]);
expect(args).toEqual({
arg1: "value1",
arg2: 42,
arg3: true,
arg4: ["value1", "value2"],
});
});
it("should parse string arguments correctly", () => {
// @ts-expect-error Testing private method
const args = cliCommand.parseArgs([
"--arg1",
"value1",
"--arg2",
"42",
"--arg3",
"--arg4",
"value1,value2",
]);
expect(args).toEqual({
arg1: "value1",
arg2: 42,
arg3: true,
arg4: ["value1", "value2"],
});
});
it("should parse short names for arguments too", () => {
const args = cliCommand["parseArgs"]([
"--arg1",
"value1",
"-a",
"42",
"--arg3",
"--arg4",
"value1,value2",
]);
expect(args).toEqual({
arg1: "value1",
arg2: 42,
arg3: true,
arg4: ["value1", "value2"],
});
});
it("should parse short names for arguments too", () => {
// @ts-expect-error Testing private method
const args = cliCommand.parseArgs([
"--arg1",
"value1",
"-a",
"42",
"--arg3",
"--arg4",
"value1,value2",
]);
expect(args).toEqual({
arg1: "value1",
arg2: 42,
arg3: true,
arg4: ["value1", "value2"],
});
});
it("should cast argument values correctly", () => {
expect(cliCommand["castArgValue"]("42", CliParameterType.NUMBER)).toBe(
42
);
expect(
cliCommand["castArgValue"]("true", CliParameterType.BOOLEAN)
).toBe(true);
expect(
cliCommand["castArgValue"]("value1,value2", CliParameterType.ARRAY)
).toEqual(["value1", "value2"]);
});
it("should cast argument values correctly", () => {
// @ts-expect-error Testing private method
expect(cliCommand.castArgValue("42", CliParameterType.NUMBER)).toBe(42);
// @ts-expect-error Testing private method
expect(cliCommand.castArgValue("true", CliParameterType.BOOLEAN)).toBe(
true,
);
expect(
// @ts-expect-error Testing private method
cliCommand.castArgValue("value1,value2", CliParameterType.ARRAY),
).toEqual(["value1", "value2"]);
});
it("should run the execute function with the parsed parameters", async () => {
const mockExecute = jest.fn();
cliCommand = new CliCommand(
["category1", "category2"],
[
{
name: "arg1",
type: CliParameterType.STRING,
needsValue: true,
},
{
name: "arg2",
type: CliParameterType.NUMBER,
needsValue: true,
},
{
name: "arg3",
type: CliParameterType.BOOLEAN,
needsValue: false,
},
{
name: "arg4",
type: CliParameterType.ARRAY,
needsValue: true,
},
],
mockExecute
);
it("should run the execute function with the parsed parameters", async () => {
const mockExecute = jest.fn();
cliCommand = new CliCommand(
["category1", "category2"],
[
{
name: "arg1",
type: CliParameterType.STRING,
needsValue: true,
},
{
name: "arg2",
type: CliParameterType.NUMBER,
needsValue: true,
},
{
name: "arg3",
type: CliParameterType.BOOLEAN,
needsValue: false,
},
{
name: "arg4",
type: CliParameterType.ARRAY,
needsValue: true,
},
],
mockExecute,
);
await cliCommand.run([
"--arg1",
"value1",
"--arg2",
"42",
"--arg3",
"--arg4",
"value1,value2",
]);
expect(mockExecute).toHaveBeenCalledWith(cliCommand, {
arg1: "value1",
arg2: 42,
arg3: true,
arg4: ["value1", "value2"],
});
});
await cliCommand.run([
"--arg1",
"value1",
"--arg2",
"42",
"--arg3",
"--arg4",
"value1,value2",
]);
expect(mockExecute).toHaveBeenCalledWith(cliCommand, {
arg1: "value1",
arg2: 42,
arg3: true,
arg4: ["value1", "value2"],
});
});
it("should work with a mix of positioned and non-positioned arguments", async () => {
const mockExecute = jest.fn();
cliCommand = new CliCommand(
["category1", "category2"],
[
{
name: "arg1",
type: CliParameterType.STRING,
needsValue: true,
},
{
name: "arg2",
type: CliParameterType.NUMBER,
needsValue: true,
},
{
name: "arg3",
type: CliParameterType.BOOLEAN,
needsValue: false,
},
{
name: "arg4",
type: CliParameterType.ARRAY,
needsValue: true,
},
{
name: "arg5",
type: CliParameterType.STRING,
needsValue: true,
positioned: true,
},
],
mockExecute
);
it("should work with a mix of positioned and non-positioned arguments", async () => {
const mockExecute = jest.fn();
cliCommand = new CliCommand(
["category1", "category2"],
[
{
name: "arg1",
type: CliParameterType.STRING,
needsValue: true,
},
{
name: "arg2",
type: CliParameterType.NUMBER,
needsValue: true,
},
{
name: "arg3",
type: CliParameterType.BOOLEAN,
needsValue: false,
},
{
name: "arg4",
type: CliParameterType.ARRAY,
needsValue: true,
},
{
name: "arg5",
type: CliParameterType.STRING,
needsValue: true,
positioned: true,
},
],
mockExecute,
);
await cliCommand.run([
"--arg1",
"value1",
"--arg2",
"42",
"--arg3",
"--arg4",
"value1,value2",
"value5",
]);
await cliCommand.run([
"--arg1",
"value1",
"--arg2",
"42",
"--arg3",
"--arg4",
"value1,value2",
"value5",
]);
expect(mockExecute).toHaveBeenCalledWith(cliCommand, {
arg1: "value1",
arg2: 42,
arg3: true,
arg4: ["value1", "value2"],
arg5: "value5",
});
});
expect(mockExecute).toHaveBeenCalledWith(cliCommand, {
arg1: "value1",
arg2: 42,
arg3: true,
arg4: ["value1", "value2"],
arg5: "value5",
});
});
it("should display help message correctly", () => {
const consoleLogSpy = spyOn(console, "log").mockImplementation(() => {
// Do nothing
});
it("should display help message correctly", () => {
const consoleLogSpy = spyOn(console, "log").mockImplementation(() => {
// Do nothing
});
cliCommand = new CliCommand(
["category1", "category2"],
[
{
name: "arg1",
type: CliParameterType.STRING,
needsValue: true,
description: "Argument 1",
optional: true,
},
{
name: "arg2",
type: CliParameterType.NUMBER,
needsValue: true,
description: "Argument 2",
},
{
name: "arg3",
type: CliParameterType.BOOLEAN,
needsValue: false,
description: "Argument 3",
optional: true,
positioned: false,
},
{
name: "arg4",
type: CliParameterType.ARRAY,
needsValue: true,
description: "Argument 4",
positioned: false,
},
],
() => {
// Do nothing
},
"This is a test command",
"category1 category2 --arg1 value1 --arg2 42 arg3 --arg4 value1,value2"
);
cliCommand = new CliCommand(
["category1", "category2"],
[
{
name: "arg1",
type: CliParameterType.STRING,
needsValue: true,
description: "Argument 1",
optional: true,
},
{
name: "arg2",
type: CliParameterType.NUMBER,
needsValue: true,
description: "Argument 2",
},
{
name: "arg3",
type: CliParameterType.BOOLEAN,
needsValue: false,
description: "Argument 3",
optional: true,
positioned: false,
},
{
name: "arg4",
type: CliParameterType.ARRAY,
needsValue: true,
description: "Argument 4",
positioned: false,
},
],
() => {
// Do nothing
},
"This is a test command",
"category1 category2 --arg1 value1 --arg2 42 arg3 --arg4 value1,value2",
);
cliCommand.displayHelp();
cliCommand.displayHelp();
const loggedString = consoleLogSpy.mock.calls.map(call =>
stripAnsi(call[0])
)[0];
const loggedString = consoleLogSpy.mock.calls.map((call) =>
stripAnsi(call[0]),
)[0];
consoleLogSpy.mockRestore();
consoleLogSpy.mockRestore();
expect(loggedString).toContain("📚 Command: category1 category2");
expect(loggedString).toContain("🔧 Arguments:");
expect(loggedString).toContain("arg1: Argument 1 (optional)");
expect(loggedString).toContain("arg2: Argument 2");
expect(loggedString).toContain("--arg3: Argument 3 (optional)");
expect(loggedString).toContain("--arg4: Argument 4");
expect(loggedString).toContain("🚀 Example:");
expect(loggedString).toContain(
"category1 category2 --arg1 value1 --arg2 42 arg3 --arg4 value1,value2"
);
});
expect(loggedString).toContain("📚 Command: category1 category2");
expect(loggedString).toContain("🔧 Arguments:");
expect(loggedString).toContain("arg1: Argument 1 (optional)");
expect(loggedString).toContain("arg2: Argument 2");
expect(loggedString).toContain("--arg3: Argument 3 (optional)");
expect(loggedString).toContain("--arg4: Argument 4");
expect(loggedString).toContain("🚀 Example:");
expect(loggedString).toContain(
"category1 category2 --arg1 value1 --arg2 42 arg3 --arg4 value1,value2",
);
});
});
describe("CliBuilder", () => {
let cliBuilder: CliBuilder;
let mockCommand1: CliCommand;
let mockCommand2: CliCommand;
let cliBuilder: CliBuilder;
let mockCommand1: CliCommand;
let mockCommand2: CliCommand;
beforeEach(() => {
mockCommand1 = new CliCommand(["category1"], [], jest.fn());
mockCommand2 = new CliCommand(["category2"], [], jest.fn());
cliBuilder = new CliBuilder([mockCommand1]);
});
beforeEach(() => {
mockCommand1 = new CliCommand(["category1"], [], jest.fn());
mockCommand2 = new CliCommand(["category2"], [], jest.fn());
cliBuilder = new CliBuilder([mockCommand1]);
});
it("should register a command correctly", () => {
cliBuilder.registerCommand(mockCommand2);
expect(cliBuilder.commands).toContain(mockCommand2);
});
it("should register a command correctly", () => {
cliBuilder.registerCommand(mockCommand2);
expect(cliBuilder.commands).toContain(mockCommand2);
});
it("should register multiple commands correctly", () => {
const mockCommand3 = new CliCommand(["category3"], [], jest.fn());
cliBuilder.registerCommands([mockCommand2, mockCommand3]);
expect(cliBuilder.commands).toContain(mockCommand2);
expect(cliBuilder.commands).toContain(mockCommand3);
});
it("should register multiple commands correctly", () => {
const mockCommand3 = new CliCommand(["category3"], [], jest.fn());
cliBuilder.registerCommands([mockCommand2, mockCommand3]);
expect(cliBuilder.commands).toContain(mockCommand2);
expect(cliBuilder.commands).toContain(mockCommand3);
});
it("should error when adding duplicates", () => {
expect(() => {
cliBuilder.registerCommand(mockCommand1);
}).toThrow();
it("should error when adding duplicates", () => {
expect(() => {
cliBuilder.registerCommand(mockCommand1);
}).toThrow();
expect(() => {
cliBuilder.registerCommands([mockCommand1]);
}).toThrow();
});
expect(() => {
cliBuilder.registerCommands([mockCommand1]);
}).toThrow();
});
it("should deregister a command correctly", () => {
cliBuilder.deregisterCommand(mockCommand1);
expect(cliBuilder.commands).not.toContain(mockCommand1);
});
it("should deregister a command correctly", () => {
cliBuilder.deregisterCommand(mockCommand1);
expect(cliBuilder.commands).not.toContain(mockCommand1);
});
it("should deregister multiple commands correctly", () => {
cliBuilder.registerCommand(mockCommand2);
cliBuilder.deregisterCommands([mockCommand1, mockCommand2]);
expect(cliBuilder.commands).not.toContain(mockCommand1);
expect(cliBuilder.commands).not.toContain(mockCommand2);
});
it("should deregister multiple commands correctly", () => {
cliBuilder.registerCommand(mockCommand2);
cliBuilder.deregisterCommands([mockCommand1, mockCommand2]);
expect(cliBuilder.commands).not.toContain(mockCommand1);
expect(cliBuilder.commands).not.toContain(mockCommand2);
});
it("should process args correctly", async () => {
const mockExecute = jest.fn();
const mockCommand = new CliCommand(
["category1", "sub1"],
[
{
name: "arg1",
type: CliParameterType.STRING,
needsValue: true,
positioned: false,
},
],
mockExecute
);
cliBuilder.registerCommand(mockCommand);
await cliBuilder.processArgs([
"./cli.ts",
"category1",
"sub1",
"--arg1",
"value1",
]);
expect(mockExecute).toHaveBeenCalledWith(expect.anything(), {
arg1: "value1",
});
});
it("should process args correctly", async () => {
const mockExecute = jest.fn();
const mockCommand = new CliCommand(
["category1", "sub1"],
[
{
name: "arg1",
type: CliParameterType.STRING,
needsValue: true,
positioned: false,
},
],
mockExecute,
);
cliBuilder.registerCommand(mockCommand);
await cliBuilder.processArgs([
"./cli.ts",
"category1",
"sub1",
"--arg1",
"value1",
]);
expect(mockExecute).toHaveBeenCalledWith(expect.anything(), {
arg1: "value1",
});
});
describe("should build command tree", () => {
let cliBuilder: CliBuilder;
let mockCommand1: CliCommand;
let mockCommand2: CliCommand;
let mockCommand3: CliCommand;
let mockCommand4: CliCommand;
let mockCommand5: CliCommand;
describe("should build command tree", () => {
let cliBuilder: CliBuilder;
let mockCommand1: CliCommand;
let mockCommand2: CliCommand;
let mockCommand3: CliCommand;
let mockCommand4: CliCommand;
let mockCommand5: CliCommand;
beforeEach(() => {
mockCommand1 = new CliCommand(["user", "verify"], [], jest.fn());
mockCommand2 = new CliCommand(["user", "delete"], [], jest.fn());
mockCommand3 = new CliCommand(
["user", "new", "admin"],
[],
jest.fn()
);
mockCommand4 = new CliCommand(["user", "new"], [], jest.fn());
mockCommand5 = new CliCommand(["admin", "delete"], [], jest.fn());
cliBuilder = new CliBuilder([
mockCommand1,
mockCommand2,
mockCommand3,
mockCommand4,
mockCommand5,
]);
});
beforeEach(() => {
mockCommand1 = new CliCommand(["user", "verify"], [], jest.fn());
mockCommand2 = new CliCommand(["user", "delete"], [], jest.fn());
mockCommand3 = new CliCommand(
["user", "new", "admin"],
[],
jest.fn(),
);
mockCommand4 = new CliCommand(["user", "new"], [], jest.fn());
mockCommand5 = new CliCommand(["admin", "delete"], [], jest.fn());
cliBuilder = new CliBuilder([
mockCommand1,
mockCommand2,
mockCommand3,
mockCommand4,
mockCommand5,
]);
});
it("should build the command tree correctly", () => {
const tree = cliBuilder.getCommandTree(cliBuilder.commands);
expect(tree).toEqual({
user: {
verify: mockCommand1,
delete: mockCommand2,
new: {
admin: mockCommand3,
},
},
admin: {
delete: mockCommand5,
},
});
});
it("should build the command tree correctly", () => {
const tree = cliBuilder.getCommandTree(cliBuilder.commands);
expect(tree).toEqual({
user: {
verify: mockCommand1,
delete: mockCommand2,
new: {
admin: mockCommand3,
},
},
admin: {
delete: mockCommand5,
},
});
});
it("should build the command tree correctly when there are no commands", () => {
cliBuilder = new CliBuilder([]);
const tree = cliBuilder.getCommandTree(cliBuilder.commands);
expect(tree).toEqual({});
});
it("should build the command tree correctly when there are no commands", () => {
cliBuilder = new CliBuilder([]);
const tree = cliBuilder.getCommandTree(cliBuilder.commands);
expect(tree).toEqual({});
});
it("should build the command tree correctly when there is only one command", () => {
cliBuilder = new CliBuilder([mockCommand1]);
const tree = cliBuilder.getCommandTree(cliBuilder.commands);
expect(tree).toEqual({
user: {
verify: mockCommand1,
},
});
});
});
it("should build the command tree correctly when there is only one command", () => {
cliBuilder = new CliBuilder([mockCommand1]);
const tree = cliBuilder.getCommandTree(cliBuilder.commands);
expect(tree).toEqual({
user: {
verify: mockCommand1,
},
});
});
});
it("should show help menu", () => {
const consoleLogSpy = spyOn(console, "log").mockImplementation(() => {
// Do nothing
});
it("should show help menu", () => {
const consoleLogSpy = spyOn(console, "log").mockImplementation(() => {
// Do nothing
});
const cliBuilder = new CliBuilder();
const cliBuilder = new CliBuilder();
const cliCommand = new CliCommand(
["category1", "category2"],
[
{
name: "name",
type: CliParameterType.STRING,
needsValue: true,
description: "Name of new item",
},
{
name: "delete-previous",
type: CliParameterType.NUMBER,
needsValue: false,
positioned: false,
optional: true,
description: "Also delete the previous item",
},
{
name: "arg3",
type: CliParameterType.BOOLEAN,
needsValue: false,
},
{
name: "arg4",
type: CliParameterType.ARRAY,
needsValue: true,
},
],
() => {
// Do nothing
},
"I love sussy sauces",
"emoji add --url https://site.com/image.png"
);
const cliCommand = new CliCommand(
["category1", "category2"],
[
{
name: "name",
type: CliParameterType.STRING,
needsValue: true,
description: "Name of new item",
},
{
name: "delete-previous",
type: CliParameterType.NUMBER,
needsValue: false,
positioned: false,
optional: true,
description: "Also delete the previous item",
},
{
name: "arg3",
type: CliParameterType.BOOLEAN,
needsValue: false,
},
{
name: "arg4",
type: CliParameterType.ARRAY,
needsValue: true,
},
],
() => {
// Do nothing
},
"I love sussy sauces",
"emoji add --url https://site.com/image.png",
);
cliBuilder.registerCommand(cliCommand);
cliBuilder.displayHelp();
cliBuilder.registerCommand(cliCommand);
cliBuilder.displayHelp();
const loggedString = consoleLogSpy.mock.calls
.map(call => stripAnsi(call[0]))
.join("\n");
const loggedString = consoleLogSpy.mock.calls
.map((call) => stripAnsi(call[0]))
.join("\n");
consoleLogSpy.mockRestore();
consoleLogSpy.mockRestore();
expect(loggedString).toContain("category1");
expect(loggedString).toContain(
" category2.................I love sussy sauces"
);
expect(loggedString).toContain(
" name..................Name of new item"
);
expect(loggedString).toContain(
" arg3..................(no description)"
);
expect(loggedString).toContain(
" arg4..................(no description)"
);
expect(loggedString).toContain(
" --delete-previous.....Also delete the previous item (optional)"
);
expect(loggedString).toContain(
" Example: emoji add --url https://site.com/image.png"
);
});
expect(loggedString).toContain("category1");
expect(loggedString).toContain(
" category2.................I love sussy sauces",
);
expect(loggedString).toContain(
" name..................Name of new item",
);
expect(loggedString).toContain(
" arg3..................(no description)",
);
expect(loggedString).toContain(
" arg4..................(no description)",
);
expect(loggedString).toContain(
" --delete-previous.....Also delete the previous item (optional)",
);
expect(loggedString).toContain(
" Example: emoji add --url https://site.com/image.png",
);
});
});