refactor(config): 🔥 Replace config validation with Zod

This commit is contained in:
Jesse Wierzbinski 2024-05-15 16:37:25 -10:00
parent 093337dd4f
commit fb31375b74
No known key found for this signature in database
15 changed files with 543 additions and 3491 deletions

View file

@ -8,6 +8,7 @@
"build",
"api",
"cli",
"federation"
"federation",
"config"
]
}

BIN
bun.lockb

Binary file not shown.

1759
cli.ts

File diff suppressed because it is too large Load diff

View file

@ -1,15 +1,9 @@
# Lysand Config
# All of these values can be changed via the CLI (they will be saved in a file named config.internal.toml
# in the same directory as this one)
# Changing this file does not require a restart, but might take a few seconds to apply
# This file will be merged with the CLI configuration, taking precedence over it
[database]
# Main PostgreSQL database connection
host = "localhost"
port = 5432
username = "lysand"
password = "lysand"
password = "mycoolpassword"
database = "lysand"
[redis.queue]
@ -19,12 +13,13 @@ host = "localhost"
port = 6379
password = ""
database = 0
enabled = true
[redis.cache]
# Redis instance to be used as a timeline cache
# Optional, can be the same as the queue instance
host = "localhost"
port = 6379
port = 40004
password = ""
database = 1
enabled = false
@ -32,13 +27,13 @@ enabled = false
[meilisearch]
# If Meilisearch is not configured, search will not be enabled
host = "localhost"
port = 7700
api_key = "______________________________"
enabled = false
port = 40007
api_key = ""
enabled = true
[signups]
# URL of your Terms of Service
tos_url = "https://my-site.com/tos"
tos_url = "https://social.lysand.org/tos"
# Whether to enable registrations or not
registration = true
rules = [
@ -56,23 +51,20 @@ jwt_key = ""
# This is an example configuration
# The provider MUST support OpenID Connect with .well-known discovery
# Most notably, GitHub does not support this
# Set the allowed redirect URIs to (regex) <base_url>/oauth/callback/<name>?.* to allow Lysand to use it
# The last ?.* is important, as it allows for query parameters to be passed
[[oidc.providers]]
# Test with custom Authentik instance
name = "CPlusPatch ID"
id = "cpluspatch-id"
url = "https://id.cpluspatch.com/application/o/lysand-testing/"
client_id = "______________________________"
client_secret = "__________________________________"
icon = "https://cpluspatch.com/images/icons/logo.svg"
# name = "CPlusPatch ID"
# id = "cpluspatch-id"
# url = "https://id.cpluspatch.com/application/o/lysand-testing/"
# client_id = "XXXX"
# client_secret = "XXXXX"
# icon = "https://cpluspatch.com/images/icons/logo.svg"
[http]
# The full URL Lysand will be reachable by (paths are not supported)
base_url = "https://lysand.social"
# Address to bind to
bind = "0.0.0.0"
bind_port = "8080"
base_url = "https://lysand.localhost:9900"
# Address to bind to (0.0.0.0 is suggested for proxies)
bind = "lysand.localhost"
bind_port = 9900
# Bans IPv4 or IPv6 IPs (wildcards, networks and ranges are supported)
banned_ips = []
@ -85,8 +77,8 @@ banned_user_agents = [
[http.tls]
# If these values are set, Lysand will use these files for TLS
enabled = false
key = "config/privatekey.pem"
cert = "config/certificate.pem"
key = ""
cert = ""
passphrase = ""
ca = ""
@ -107,20 +99,25 @@ enabled = true
# The URL to reach the frontend at (should be on a local network)
url = "http://localhost:3000"
[frontend.settings]
# Arbitrary key/value pairs to be passed to the frontend
# This can be used to set up custom themes, etc on supported frontends.
# theme = "dark"
[frontend.glitch]
# Enable the Glitch frontend integration
enabled = false
# Glitch assets folder
assets = "glitch"
# Server the assets were ripped from (and any eventual CDNs)
server = ["https://glitch.social", "https://static.glitch.social"]
server = ["https://tech.lgbt"]
[smtp]
# SMTP server to use for sending emails
server = "smtp.example.com"
port = 465
username = "test@example.com"
password = "____________"
password = "password123"
tls = true
# Disable all email functions (this will allow people to sign up without verifying
# their email)
@ -131,7 +128,7 @@ enabled = false
# If you need to change this value after setting up your instance, you must move all the files
# from one backend to the other manually (the CLI will have an option to do this later)
# TODO: Add CLI command to move files
backend = "local"
backend = "s3"
# Whether to check the hash of media when uploading to avoid duplication
deduplicate_media = true
# If media backend is "local", this is the folder where the files will be stored
@ -140,29 +137,19 @@ local_uploads_folder = "uploads"
[media.conversion]
# Whether to automatically convert images to another format on upload
convert_images = false
# Can be: "jxl", "webp", "avif", "png", "jpg", "heif"
convert_images = true
# Can be: "image/jxl", "image/webp", "image/avif", "image/png", "image/jpeg", "image/heif", "image/gif"
# JXL support will likely not work
convert_to = "webp"
convert_to = "image/webp"
[s3]
# Can be left blank if you don't use the S3 media backend
endpoint = "myhostname.banana.com"
access_key = "_____________"
secret_access_key = "_________________"
region = ""
bucket_name = "lysand"
public_url = "https://cdn.test.com"
[email]
# Sends an email to moderators when a report is received
send_on_report = false
# Sends an email to moderators when a user is suspended
send_on_suspend = false
# Sends an email to moderators when a user is unsuspended
send_on_unsuspend = false
# Verify user emails when signing up (except via OIDC)
verify_email = false
# endpoint = ""
# access_key = "XXXXX"
# secret_access_key = "XXX"
# region = ""
# bucket_name = "lysand"
# public_url = "https://cdn.example.com"
[validation]
# Checks user data
@ -240,36 +227,8 @@ url_scheme_whitelist = [
# This can easily be spoofed, but if it is spoofed it will appear broken
# to normal clients until despoofed
enforce_mime_types = false
allowed_mime_types = [
"image/jpeg",
"image/png",
"image/gif",
"image/heic",
"image/heif",
"image/webp",
"image/avif",
"video/webm",
"video/mp4",
"video/quicktime",
"video/ogg",
"audio/wave",
"audio/wav",
"audio/x-wav",
"audio/x-pn-wave",
"audio/vnd.wave",
"audio/ogg",
"audio/vorbis",
"audio/mpeg",
"audio/mp3",
"audio/webm",
"audio/flac",
"audio/aac",
"audio/m4a",
"audio/x-m4a",
"audio/mp4",
"audio/3gpp",
"video/x-ms-asf",
]
# Defaults to all valid MIME types
# allowed_mime_types = []
[defaults]
# Default visibility for new notes
@ -278,10 +237,10 @@ allowed_mime_types = [
visibility = "public"
# Default language for new notes (ISO code)
language = "en"
# Default avatar, must be a valid URL or "" for a placeholder avatar
avatar = ""
# Default header, must be a valid URL or "" for none
header = ""
# Default avatar, must be a valid URL or left out for a placeholder avatar
# avatar = ""
# Default header, must be a valid URL or left out for none
# header = ""
# A style name from https://www.dicebear.com/styles
placeholder_style = "thumbs"
@ -310,19 +269,20 @@ avatars = []
[instance]
name = "Lysand"
description = "A test instance of Lysand"
description = "A Lysand instance"
# Path to a file containing a longer description of your instance
# This will be parsed as Markdown
extended_description_path = ""
# URL to your instance logo (jpg files should be renamed to jpeg)
logo = ""
# URL to your instance banner (jpg files should be renamed to jpeg)
banner = ""
# extended_description_path = "config/description.md"
# URL to your instance logo
# logo = ""
# URL to your instance banner
# banner = ""
[filters]
# Regex filters for federated and local data
# Does not apply retroactively (try the CLI for that)
# Drops data matching the filters
# Does not apply retroactively to existing data
# Note contents
note_content = [
@ -341,7 +301,7 @@ log_requests = false
# Log request and their contents (warning: this is a lot of data)
log_requests_verbose = false
# Available levels: debug, info, warning, error, critical
log_level = "info"
log_level = "debug"
# For GDPR compliance, you can disable logging of IPs
log_ip = false
@ -362,5 +322,5 @@ max_coeff = 1.0
[custom_ratelimits]
# Add in any API route in this style here
# Applies before the global ratelimit changes
"/api/v1/accounts/:id/block" = { duration = 30, max = 60 }
"/api/v1/timelines/public" = { duration = 60, max = 200 }
# "/api/v1/accounts/:id/block" = { duration = 30, max = 60 }
# "/api/v1/timelines/public" = { duration = 60, max = 200 }

View file

@ -1,6 +1,7 @@
import { dualLogger } from "@loggers";
import { connectMeili } from "@meilisearch";
import { errorResponse, response } from "@response";
import chalk from "chalk";
import { config } from "config-manager";
import { Hono } from "hono";
import { LogLevel, LogManager, type MultiLogManager } from "log-manager";
@ -70,7 +71,7 @@ if (isEntry) {
await dualServerLogger.log(
LogLevel.CRITICAL,
"Server",
`${privateKey};${publicKey}`,
chalk.gray(`${privateKey};${publicKey}`),
);
process.exit(1);
}

View file

@ -104,7 +104,6 @@
"blurhash": "^2.0.5",
"bullmq": "^5.7.1",
"chalk": "^5.3.0",
"cli-parser": "workspace:*",
"cli-progress": "^3.12.0",
"cli-table": "^0.3.11",
"config-manager": "workspace:*",

View file

@ -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",
}

View file

@ -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);
}
}

View file

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

View file

@ -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

View file

@ -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 };

View file

@ -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"
}
}

View file

@ -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;
}

View file

@ -31,8 +31,8 @@ export const applyConfig = (routeMeta: APIRouteMetadata) => {
newMeta.ratelimits.duration *= config.ratelimits.duration_coeff;
newMeta.ratelimits.max *= config.ratelimits.max_coeff;
if (config.custom_ratelimits[routeMeta.route]) {
newMeta.ratelimits = config.custom_ratelimits[routeMeta.route];
if (config.ratelimits.custom[routeMeta.route]) {
newMeta.ratelimits = config.ratelimits.custom[routeMeta.route];
}
return newMeta;