Changed command loading to use a glob pattern

This commit is contained in:
WatDuhHekBro 2021-01-26 03:52:39 -06:00
parent 8b29163c26
commit 5ed9d79715
9 changed files with 170 additions and 154 deletions

39
package-lock.json generated
View file

@ -43,6 +43,16 @@
"resolved": "https://registry.npmjs.org/@types/color-name/-/color-name-1.1.1.tgz",
"integrity": "sha512-rr+OQyAjxze7GgWrSaJwydHStIhHq2lvY3BOC2Mj7KnzI7XK0Uw1TOOdI9lDoajEbSWLiYgoo4f1R51erQfhPQ=="
},
"@types/glob": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/@types/glob/-/glob-7.1.3.tgz",
"integrity": "sha512-SEYeGAIQIQX8NN6LDKprLjbrd5dARM5EXsd8GI/A5l0apYI1fGMWgPHSe4ZKL4eozlAyI+doUE9XbYS4xCkQ1w==",
"dev": true,
"requires": {
"@types/minimatch": "*",
"@types/node": "*"
}
},
"@types/inquirer": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/@types/inquirer/-/inquirer-6.5.0.tgz",
@ -53,6 +63,12 @@
"rxjs": "^6.4.0"
}
},
"@types/minimatch": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-3.0.3.tgz",
"integrity": "sha512-tHq6qdbT9U1IRSGf14CL0pUlULksvY9OZ+5eEgl1N7t+OA3tGvNpxJCzuKQlsNgCVwbAs670L1vcVQi8j9HjnA==",
"dev": true
},
"@types/mocha": {
"version": "8.2.0",
"resolved": "https://registry.npmjs.org/@types/mocha/-/mocha-8.2.0.tgz",
@ -172,8 +188,7 @@
"balanced-match": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz",
"integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c=",
"dev": true
"integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c="
},
"binary-extensions": {
"version": "2.1.0",
@ -185,7 +200,6 @@
"version": "1.1.11",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
"integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
"dev": true,
"requires": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
@ -336,8 +350,7 @@
"concat-map": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
"integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=",
"dev": true
"integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s="
},
"create-require": {
"version": "1.1.1",
@ -515,8 +528,7 @@
"fs.realpath": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
"integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=",
"dev": true
"integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8="
},
"fsevents": {
"version": "2.1.3",
@ -535,7 +547,6 @@
"version": "7.1.6",
"resolved": "https://registry.npmjs.org/glob/-/glob-7.1.6.tgz",
"integrity": "sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==",
"dev": true,
"requires": {
"fs.realpath": "^1.0.0",
"inflight": "^1.0.4",
@ -583,7 +594,6 @@
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
"integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=",
"dev": true,
"requires": {
"once": "^1.3.0",
"wrappy": "1"
@ -592,8 +602,7 @@
"inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"dev": true
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="
},
"inquirer": {
"version": "7.3.3",
@ -738,7 +747,6 @@
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz",
"integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==",
"dev": true,
"requires": {
"brace-expansion": "^1.1.7"
}
@ -841,7 +849,6 @@
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
"integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=",
"dev": true,
"requires": {
"wrappy": "1"
}
@ -897,8 +904,7 @@
"path-is-absolute": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
"integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=",
"dev": true
"integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18="
},
"path-key": {
"version": "3.1.1",
@ -1333,8 +1339,7 @@
"wrappy": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=",
"dev": true
"integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8="
},
"ws": {
"version": "7.4.2",

View file

@ -8,12 +8,14 @@
"chalk": "^4.1.0",
"discord.js": "^12.5.1",
"discord.js-lavalink-lib": "^0.1.8",
"glob": "^7.1.6",
"inquirer": "^7.3.3",
"moment": "^2.29.1",
"ms": "^2.1.3",
"os": "^0.1.1"
},
"devDependencies": {
"@types/glob": "^7.1.3",
"@types/inquirer": "^6.5.0",
"@types/mocha": "^8.2.0",
"@types/ms": "^0.7.31",

View file

@ -1,6 +1,6 @@
import Command from "../core/command";
import {CommonLibrary} from "../core/lib";
import {loadCommands, categories} from "../core/command";
import {loadableCommands, categories} from "../core/command";
import {PermissionNames} from "../core/permissions";
export default new Command({
@ -8,11 +8,11 @@ export default new Command({
usage: "([command, [subcommand/type], ...])",
aliases: ["h"],
async run($: CommonLibrary): Promise<any> {
const commands = await loadCommands();
const commands = await loadableCommands;
let output = `Legend: \`<type>\`, \`[list/of/stuff]\`, \`(optional)\`, \`(<optional type>)\`, \`([optional/list/...])\``;
for (const [category, headers] of categories) {
output += `\n\n===[ ${category} ]===`;
output += `\n\n===[ ${$(category).toTitleCase()} ]===`;
for (const header of headers) {
if (header !== "test") {
@ -30,7 +30,7 @@ export default new Command({
},
any: new Command({
async run($: CommonLibrary): Promise<any> {
const commands = await loadCommands();
const commands = await loadableCommands;
let header = $.args.shift() as string;
let command = commands.get(header);
@ -51,7 +51,7 @@ export default new Command({
$.warn(
`Command "${header}" is somehow in multiple categories. This means that the command loading stage probably failed in properly adding categories.`
);
else selectedCategory = category;
else selectedCategory = $(category).toTitleCase();
}
}

57
src/commands/template.ts Normal file
View file

@ -0,0 +1,57 @@
import Command from "../core/command";
import {CommonLibrary} from "../core/lib";
export default new Command({
description:
'This is a template/testing command providing common functionality. Remove what you don\'t need, and rename/delete this file to generate a fresh command file here. This command should be automatically excluded from the help command. The "usage" parameter (string) overrides the default usage for the help command. The "endpoint" parameter (boolean) prevents further arguments from being passed. Also, as long as you keep the run function async, it\'ll return a promise allowing the program to automatically catch any synchronous errors. However, you\'ll have to do manual error handling if you go the then and catch route.',
endpoint: false,
usage: "",
permission: null,
aliases: [],
async run($: CommonLibrary): Promise<any> {
// code
},
subcommands: {
layer: new Command({
description:
'This is a named subcommand, meaning that the key name is what determines the keyword to use. With default settings for example, "$test layer".',
endpoint: false,
usage: "",
permission: null,
aliases: [],
async run($: CommonLibrary): Promise<any> {
// code
}
})
},
user: new Command({
description:
'This is the subcommand for getting users by pinging them or copying their ID. With default settings for example, "$test 237359961842253835". The argument will be a user object and won\'t run if no user is found by that ID.',
endpoint: false,
usage: "",
permission: null,
async run($: CommonLibrary): Promise<any> {
// code
}
}),
number: new Command({
description:
'This is a numeric subcommand, meaning that any type of number (excluding Infinity/NaN) will route to this command if present. With default settings for example, "$test -5.2". The argument with the number is already parsed so you can just use it without converting it.',
endpoint: false,
usage: "",
permission: null,
async run($: CommonLibrary): Promise<any> {
// code
}
}),
any: new Command({
description:
"This is a generic subcommand, meaning that if there isn't a more specific subcommand that's called, it falls to this. With default settings for example, \"$test reeee\".",
endpoint: false,
usage: "",
permission: null,
async run($: CommonLibrary): Promise<any> {
// code
}
})
});

View file

@ -1,9 +1,8 @@
import $, {isType, parseVars, CommonLibrary} from "./lib";
import {Collection} from "discord.js";
import {generateHandler} from "./storage";
import {promises as ffs, existsSync, writeFile} from "fs";
import {PERMISSIONS} from "./permissions";
import {getPrefix} from "../core/structures";
import glob from "glob";
interface CommandOptions {
description?: string;
@ -148,133 +147,80 @@ export default class Command {
}
}
let commands: Collection<string, Command> | null = null;
export const categories: Collection<string, string[]> = new Collection();
export const aliases: Collection<string, string> = new Collection(); // Top-level aliases only.
// Internally, it'll keep its original capitalization. It's up to you to convert it to title case when you make a help command.
export const categories = new Collection<string, string[]>();
/** Returns the cache of the commands if it exists and searches the directory if not. */
export async function loadCommands(): Promise<Collection<string, Command>> {
if (commands) return commands;
export const loadableCommands = (async () => {
const commands = new Collection<string, Command>();
// Include all .ts files recursively in "src/commands/".
const files = await globP("src/commands/**/*.ts");
// Extract the usable parts from "src/commands/" if:
// - The path is 1 to 2 subdirectories (a or a/b, not a/b/c)
// - Any leading directory isn't "modules"
// - The filename doesn't end in .test.ts (for jest testing)
// - The filename cannot be the hardcoded top-level "template.ts", reserved for generating templates
const pattern = /src\/commands\/(?!template\.ts)(?!modules\/)(\w+(?:\/\w+)?)(?:test\.)?\.ts/;
const lists: {[category: string]: string[]} = {};
if (process.argv[2] === "dev" && !existsSync("src/commands/test.ts"))
writeFile(
"src/commands/test.ts",
template,
generateHandler('"test.ts" (testing/template command) successfully generated.')
);
for (const path of files) {
const match = pattern.exec(path);
commands = new Collection();
const dir = await ffs.opendir("dist/commands");
const listMisc: string[] = [];
let selected;
if (match) {
const commandID = match[1]; // e.g. "utilities/info"
const slashIndex = commandID.indexOf("/");
const isMiscCommand = slashIndex !== -1;
const category = isMiscCommand ? commandID.substring(0, slashIndex) : "miscellaneous";
const commandName = isMiscCommand ? commandID.substring(slashIndex + 1) : commandID; // e.g. "info"
// If the dynamic import works, it must be an object at the very least. Then, just test to see if it's a proper instance.
const command = (await import(`../commands/${commandID}`)).default as unknown;
// There will only be one level of directory searching (per category).
while ((selected = await dir.read())) {
if (selected.isDirectory()) {
if (selected.name === "subcommands") continue;
if (command instanceof Command) {
command.originalCommandName = commandName;
const subdir = await ffs.opendir(`dist/commands/${selected.name}`);
const category = $(selected.name).toTitleCase();
const list: string[] = [];
let cmd;
if (commands.has(commandName)) {
$.warn(
`Command "${commandName}" already exists! Make sure to make each command uniquely identifiable across categories!`
);
} else {
commands.set(commandName, command);
}
while ((cmd = await subdir.read())) {
if (cmd.isDirectory()) {
if (cmd.name === "subcommands") continue;
else $.warn(`You can't have multiple levels of directories! From: "dist/commands/${cmd.name}"`);
} else loadCommand(cmd.name, list, selected.name);
for (const alias of command.aliases) {
if (commands.has(alias)) {
$.warn(
`Top-level alias "${alias}" from command "${commandID}" already exists either as a command or alias!`
);
} else {
commands.set(alias, command);
}
}
if (!(category in lists)) lists[category] = [];
lists[category].push(commandName);
$.log(`Loading Command: ${commandID}`);
} else {
$.warn(`Command "${commandID}" has no default export which is a Command instance!`);
}
subdir.close();
categories.set(category, list);
} else loadCommand(selected.name, listMisc);
}
}
dir.close();
categories.set("Miscellaneous", listMisc);
for (const category in lists) {
categories.set(category, lists[category]);
}
return commands;
})();
function globP(path: string) {
return new Promise<string[]>((resolve, reject) => {
glob(path, (error, files) => {
if (error) {
reject(error);
} else {
resolve(files);
}
});
});
}
async function loadCommand(filename: string, list: string[], category?: string) {
if (!commands) return $.error(`Function "loadCommand" was called without first initializing commands!`);
const prefix = category ?? "";
const header = filename.substring(0, filename.indexOf(".js"));
const command = (await import(`../commands/${prefix}/${header}`)).default as Command | undefined;
if (!command) return $.warn(`Command "${header}" has no default export which is a Command instance!`);
command.originalCommandName = header;
list.push(header);
if (commands.has(header))
$.warn(
`Command "${header}" already exists! Make sure to make each command uniquely identifiable across categories!`
);
else commands.set(header, command);
for (const alias of command.aliases) {
if (commands.has(alias))
$.warn(`Top-level alias "${alias}" from command "${header}" already exists either as a command or alias!`);
else commands.set(alias, command);
}
$.log(`Loading Command: ${header} (${category ? $(category).toTitleCase() : "Miscellaneous"})`);
}
// The template should be built with a reductionist mentality.
// Provide everything the user needs and then let them remove whatever they want.
// That way, they aren't focusing on what's missing, but rather what they need for their command.
const template = `import Command from '../core/command';
import {CommonLibrary} from '../core/lib';
export default new Command({
description: "This is a template/testing command providing common functionality. Remove what you don't need, and rename/delete this file to generate a fresh command file here. This command should be automatically excluded from the help command. The \\"usage\\" parameter (string) overrides the default usage for the help command. The \\"endpoint\\" parameter (boolean) prevents further arguments from being passed. Also, as long as you keep the run function async, it'll return a promise allowing the program to automatically catch any synchronous errors. However, you'll have to do manual error handling if you go the then and catch route.",
endpoint: false,
usage: '',
permission: null,
aliases: [],
async run($: CommonLibrary): Promise<any> {
},
subcommands: {
layer: new Command({
description: "This is a named subcommand, meaning that the key name is what determines the keyword to use. With default settings for example, \\"$test layer\\".",
endpoint: false,
usage: '',
permission: null,
aliases: [],
async run($: CommonLibrary): Promise<any> {
}
})
},
user: new Command({
description: "This is the subcommand for getting users by pinging them or copying their ID. With default settings for example, \\"$test 237359961842253835\\". The argument will be a user object and won't run if no user is found by that ID.",
endpoint: false,
usage: '',
permission: null,
async run($: CommonLibrary): Promise<any> {
}
}),
number: new Command({
description: "This is a numeric subcommand, meaning that any type of number (excluding Infinity/NaN) will route to this command if present. With default settings for example, \\"$test -5.2\\". The argument with the number is already parsed so you can just use it without converting it.",
endpoint: false,
usage: '',
permission: null,
async run($: CommonLibrary): Promise<any> {
}
}),
any: new Command({
description: "This is a generic subcommand, meaning that if there isn't a more specific subcommand that's called, it falls to this. With default settings for example, \\"$test reeee\\".",
endpoint: false,
usage: '',
permission: null,
async run($: CommonLibrary): Promise<any> {
}
})
});`;

View file

@ -1,17 +1,13 @@
import Event from "../core/event";
import Command, {loadCommands} from "../core/command";
import Command, {loadableCommands} from "../core/command";
import {hasPermission, getPermissionLevel, PermissionNames} from "../core/permissions";
import {Permissions, Collection} from "discord.js";
import {Permissions} from "discord.js";
import {getPrefix} from "../core/structures";
import $, {replyEventListeners} from "../core/lib";
// It's a rather hacky solution, but since there's no top-level await, I just have to make the loading conditional.
let commands: Collection<string, Command> | null = null;
export default new Event<"message">({
async on(message) {
// Load commands if it hasn't already done so. Luckily, it's called once at most.
if (!commands) commands = await loadCommands();
const commands = await loadableCommands;
// Message Setup //
if (message.author.bot) return;

View file

@ -1,7 +1,6 @@
import {Client} from "discord.js";
import setup from "./setup";
import {Config} from "./core/structures";
import {loadCommands} from "./core/command";
import {loadEvents} from "./core/event";
import "discord.js-lavalink-lib";
import LavalinkMusic from "discord.js-lavalink-lib";
@ -30,9 +29,8 @@ export const client = new Client();
admins: ["717352467280691331"]
});
// Begin the command loading here rather than when it's needed like in the message event.
// Command loading will start as soon as an instance of "core/command" is loaded, which is loaded during "events/message".
setup.init().then(() => {
loadCommands();
loadEvents(client);
client.login(Config.token).catch(setup.again);
});

View file

@ -1,9 +1,20 @@
import {existsSync as exists} from "fs";
import {existsSync as exists, readFileSync as read, writeFile as write} from "fs";
import inquirer from "inquirer";
import Storage from "./core/storage";
import Storage, {generateHandler} from "./core/storage";
import {Config} from "./core/structures";
import $, {setConsoleActivated} from "./core/lib";
// The template should be built with a reductionist mentality.
// Provide everything the user needs and then let them remove whatever they want.
// That way, they aren't focusing on what's missing, but rather what they need for their command.
if (process.argv[2] === "dev" && !exists("src/commands/test.ts")) {
write(
"src/commands/test.ts",
read("src/commands/template.ts"),
generateHandler('"test.ts" (testing/template command) successfully generated.')
);
}
// This file is called (or at least should be called) automatically as long as a config file doesn't exist yet.
// And that file won't be written until the data is successfully initialized.
const prompts = [

View file

@ -11,7 +11,8 @@
"strictNullChecks": true,
"strictFunctionTypes": true,
"strictPropertyInitialization": true,
"removeComments": true
"removeComments": true,
"sourceMap": true
},
"exclude": ["test"]
}