mirror of
https://github.com/versia-pub/server.git
synced 2026-03-13 05:49:16 +01:00
refactor(config): 🔥 Replace config validation with Zod
This commit is contained in:
parent
093337dd4f
commit
fb31375b74
15 changed files with 543 additions and 3491 deletions
|
|
@ -1,23 +0,0 @@
|
|||
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;
|
||||
}
|
||||
|
||||
export enum CliParameterType {
|
||||
STRING = "string",
|
||||
NUMBER = "number",
|
||||
BOOLEAN = "boolean",
|
||||
ARRAY = "array",
|
||||
EMPTY = "empty",
|
||||
}
|
||||
|
|
@ -1,450 +0,0 @@
|
|||
import chalk from "chalk";
|
||||
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<T> = (
|
||||
instance: CliCommand,
|
||||
args: Partial<T>,
|
||||
) => 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`
|
||||
*/
|
||||
|
||||
// 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")}
|
||||
${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<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];
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
{
|
||||
"name": "cli-parser",
|
||||
"version": "0.0.0",
|
||||
"main": "index.ts",
|
||||
"dependencies": { "chalk": "^5.3.0", "strip-ansi": "^7.1.0" }
|
||||
}
|
||||
|
|
@ -1,488 +0,0 @@
|
|||
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 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: 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);
|
||||
});
|
||||
});
|
||||
|
||||
describe("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
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
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", () => {
|
||||
// @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", () => {
|
||||
// @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,
|
||||
);
|
||||
|
||||
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,
|
||||
);
|
||||
|
||||
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",
|
||||
});
|
||||
});
|
||||
|
||||
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.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", () => {
|
||||
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]);
|
||||
});
|
||||
|
||||
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 error when adding duplicates", () => {
|
||||
expect(() => {
|
||||
cliBuilder.registerCommand(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 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",
|
||||
});
|
||||
});
|
||||
|
||||
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,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("should show help menu", () => {
|
||||
const consoleLogSpy = spyOn(console, "log").mockImplementation(() => {
|
||||
// Do nothing
|
||||
});
|
||||
|
||||
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",
|
||||
);
|
||||
|
||||
cliBuilder.registerCommand(cliCommand);
|
||||
cliBuilder.displayHelp();
|
||||
|
||||
const loggedString = consoleLogSpy.mock.calls
|
||||
.map((call) => stripAnsi(call[0]))
|
||||
.join("\n");
|
||||
|
||||
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",
|
||||
);
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -5,22 +5,43 @@
|
|||
* Fuses both and provides a way to retrieve individual values
|
||||
*/
|
||||
|
||||
import { watchConfig } from "c12";
|
||||
import { type Config, defaultConfig } from "./config.type";
|
||||
import { watchConfig, loadConfig } from "c12";
|
||||
import { configValidator, type Config } from "./config.type";
|
||||
import { fromError } from "zod-validation-error";
|
||||
import chalk from "chalk";
|
||||
|
||||
const { config } = await watchConfig<Config>({
|
||||
const { config } = await watchConfig({
|
||||
configFile: "./config/config.toml",
|
||||
defaultConfig: defaultConfig,
|
||||
overrides:
|
||||
(
|
||||
await watchConfig<Config>({
|
||||
await loadConfig<Config>({
|
||||
configFile: "./config/config.internal.toml",
|
||||
defaultConfig: {} as Config,
|
||||
})
|
||||
).config ?? undefined,
|
||||
});
|
||||
|
||||
const exportedConfig = config ?? defaultConfig;
|
||||
const parsed = await configValidator.safeParseAsync(config);
|
||||
|
||||
if (!parsed.success) {
|
||||
console.log(
|
||||
`${chalk.bgRed.white(
|
||||
" CRITICAL ",
|
||||
)} There was an error parsing the config file at ${chalk.bold(
|
||||
"./config/config.toml",
|
||||
)}. Please fix the file and try again.`,
|
||||
);
|
||||
console.log(
|
||||
`${chalk.bgRed.white(
|
||||
" CRITICAL ",
|
||||
)} Follow the installation intructions and get a sample config file from the repository if needed.`,
|
||||
);
|
||||
console.log(
|
||||
`${chalk.bgRed.white(" CRITICAL ")} ${fromError(parsed.error).message}`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const exportedConfig = parsed.data;
|
||||
|
||||
export { exportedConfig as config };
|
||||
export type { Config };
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@
|
|||
"main": "index.ts",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"c12": "^1.10.0"
|
||||
"c12": "^1.10.0",
|
||||
"zod": "^3.23.8",
|
||||
"zod-validation-error": "^3.3.0"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -365,8 +365,8 @@ export class User {
|
|||
: await Bun.password.hash(data.password),
|
||||
email: data.email,
|
||||
note: data.bio ?? "",
|
||||
avatar: data.avatar ?? config.defaults.avatar,
|
||||
header: data.header ?? config.defaults.avatar,
|
||||
avatar: data.avatar ?? config.defaults.avatar ?? "",
|
||||
header: data.header ?? config.defaults.avatar ?? "",
|
||||
isAdmin: data.admin ?? false,
|
||||
publicKey: keys.public_key,
|
||||
fields: [],
|
||||
|
|
@ -399,7 +399,7 @@ export class User {
|
|||
* @returns The raw URL for the user's header
|
||||
*/
|
||||
getHeaderUrl(config: Config) {
|
||||
if (!this.user.header) return config.defaults.header;
|
||||
if (!this.user.header) return config.defaults.header || "";
|
||||
return this.user.header;
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue