Add new CLI parser package

This commit is contained in:
Jesse Wierzbinski 2024-03-07 20:46:59 -10:00
parent 78f216092b
commit c7b36515b0
No known key found for this signature in database
5 changed files with 253 additions and 6 deletions

BIN
packages/cli-parser/bun.lockb Executable file

Binary file not shown.

View file

@ -4,5 +4,7 @@ export interface CliParameter {
positioned?: boolean; positioned?: boolean;
// Whether the argument needs a value (requires positioned to be false) // Whether the argument needs a value (requires positioned to be false)
needsValue?: boolean; needsValue?: boolean;
optional?: true;
type: "string" | "number" | "boolean" | "array"; type: "string" | "number" | "boolean" | "array";
description?: string;
} }

View file

@ -1,4 +1,5 @@
import type { CliParameter } from "./cli-builder.type"; import type { CliParameter } from "./cli-builder.type";
import chalk from "chalk";
export function startsWithArray(fullArray: any[], startArray: any[]) { export function startsWithArray(fullArray: any[], startArray: any[]) {
if (startArray.length > fullArray.length) { if (startArray.length > fullArray.length) {
@ -9,6 +10,10 @@ export function startsWithArray(fullArray: any[], startArray: any[]) {
.every((value, index) => value === startArray[index]); .every((value, index) => value === startArray[index]);
} }
interface TreeType {
[key: string]: CliCommand | TreeType;
}
/** /**
* Builder for a CLI * Builder for a CLI
* @param commands Array of commands to register * @param commands Array of commands to register
@ -115,6 +120,53 @@ export class CliBuilder {
command.run(argsWithoutCategories); 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
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (!currentLevel[part]) {
// 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;
}
} }
/** /**
@ -125,9 +177,45 @@ export class CliCommand {
constructor( constructor(
public categories: string[], public categories: string[],
public argTypes: CliParameter[], public argTypes: CliParameter[],
private execute: (args: Record<string, any>) => void private execute: (args: Record<string, any>) => void,
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);
const unpositionedArgs = this.argTypes.filter(arg => !arg.positioned);
const helpMessage = `
${chalk.green("📚 Command:")} ${chalk.yellow(this.categories.join(" "))}
${this.description ? `${chalk.cyan(this.description)}\n` : ""}
${chalk.magenta("🔧 Arguments:")}
${unpositionedArgs
.map(
arg =>
`${chalk.bold(arg.name)}: ${chalk.blue(arg.description ?? "(no description)")} ${
arg.optional ? chalk.gray("(optional)") : ""
}`
)
.join("\n")}
${positionedArgs
.map(
arg =>
`--${chalk.bold(arg.name)}: ${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 * Parses string array arguments into a full JavaScript object
* @param argsWithoutCategories * @param argsWithoutCategories
@ -201,3 +289,35 @@ export class CliCommand {
this.execute(args); this.execute(args);
} }
} }
const cliBuilder = new CliBuilder();
const cliCommand = new CliCommand(
["category1", "category2"],
[
{
name: "name",
type: "string",
needsValue: true,
description: "Name of new item",
},
{
name: "delete-previous",
type: "number",
needsValue: false,
positioned: true,
optional: true,
description: "Also delete the previous item",
},
{ name: "arg3", type: "boolean", needsValue: false },
{ name: "arg4", type: "array", needsValue: true },
],
() => {
// Do nothing
},
"I love sussy sauces",
"emoji add --url https://site.com/image.png"
);
cliBuilder.registerCommand(cliCommand);
//cliBuilder.displayHelp();

View file

@ -2,5 +2,5 @@
"name": "arg-parser", "name": "arg-parser",
"version": "0.0.0", "version": "0.0.0",
"main": "index.ts", "main": "index.ts",
"dependencies": {} "dependencies": { "strip-ansi": "^7.1.0" }
} }

View file

@ -1,6 +1,7 @@
// FILEPATH: /home/jessew/Dev/lysand/packages/cli-parser/index.test.ts // FILEPATH: /home/jessew/Dev/lysand/packages/cli-parser/index.test.ts
import { CliCommand, CliBuilder, startsWithArray } from ".."; import { CliCommand, CliBuilder, startsWithArray } from "..";
import { describe, beforeEach, it, expect, jest } from "bun:test"; import { describe, beforeEach, it, expect, jest, spyOn } from "bun:test";
import stripAnsi from "strip-ansi";
describe("startsWithArray", () => { describe("startsWithArray", () => {
it("should return true when fullArray starts with startArray", () => { it("should return true when fullArray starts with startArray", () => {
@ -141,6 +142,70 @@ describe("CliCommand", () => {
arg5: "value5", arg5: "value5",
}); });
}); });
it("should display help message correctly", () => {
const consoleLogSpy = spyOn(console, "log").mockImplementation(() => {
// Do nothing
});
cliCommand = new CliCommand(
["category1", "category2"],
[
{
name: "arg1",
type: "string",
needsValue: true,
description: "Argument 1",
optional: true,
},
{
name: "arg2",
type: "number",
needsValue: true,
description: "Argument 2",
},
{
name: "arg3",
type: "boolean",
needsValue: false,
description: "Argument 3",
optional: true,
positioned: true,
},
{
name: "arg4",
type: "array",
needsValue: true,
description: "Argument 4",
positioned: true,
},
],
() => {
// Do nothing
},
"This is a test command",
"category1 category2 --arg1 value1 --arg2 42 arg3 --arg4 value1,value2"
);
cliCommand.displayHelp();
const loggedString = consoleLogSpy.mock.calls.map(call =>
stripAnsi(call[0])
)[0];
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"
);
});
}); });
describe("CliBuilder", () => { describe("CliBuilder", () => {
@ -214,4 +279,64 @@ describe("CliBuilder", () => {
arg1: "value1", 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;
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 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,
},
});
});
});
}); });