mirror of
https://github.com/versia-pub/server.git
synced 2025-12-06 08:28:19 +01:00
Add new CLI parser package
This commit is contained in:
parent
78f216092b
commit
c7b36515b0
BIN
packages/cli-parser/bun.lockb
Executable file
BIN
packages/cli-parser/bun.lockb
Executable file
Binary file not shown.
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"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" }
|
||||||
}
|
}
|
||||||
|
|
@ -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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue