From daac12792a154e462cd2e411247fd8ce468de5b6 Mon Sep 17 00:00:00 2001 From: Telesphoreo Date: Thu, 28 May 2026 19:14:34 -0400 Subject: [PATCH] Allow installing/uninstalling modules in-game --- .../main/java/dev/plex/module/PlexModule.java | 21 ++++ .../java/dev/plex/command/impl/DebugCMD.java | 17 +++- .../java/dev/plex/command/impl/PlexCMD.java | 99 +++++++++---------- .../java/dev/plex/module/ModuleManager.java | 67 +++++++++++++ .../java/dev/plex/util/UpdateChecker.java | 22 ++++- server/src/main/resources/messages.yml | 1 + 6 files changed, 173 insertions(+), 54 deletions(-) diff --git a/api/src/main/java/dev/plex/module/PlexModule.java b/api/src/main/java/dev/plex/module/PlexModule.java index f0cc58b..0ad09d7 100644 --- a/api/src/main/java/dev/plex/module/PlexModule.java +++ b/api/src/main/java/dev/plex/module/PlexModule.java @@ -36,6 +36,7 @@ public abstract class PlexModule private ModuleConfiguration messages; private PlexModuleFile plexModuleFile; private File dataFolder; + private File moduleJar; private Logger logger; /** @@ -228,6 +229,16 @@ public abstract class PlexModule return dataFolder; } + /** + * Returns the JAR file this module was loaded from. + * + * @return the JAR file this module was loaded from + */ + public File getModuleJar() + { + return moduleJar; + } + /** * Returns the module logger. * @@ -362,6 +373,16 @@ public abstract class PlexModule this.dataFolder = dataFolder; } + /** + * Sets the JAR file this module was loaded from. + * + * @param moduleJar JAR file this module was loaded from + */ + public void setModuleJar(File moduleJar) + { + this.moduleJar = moduleJar; + } + /** * Sets the module logger. * diff --git a/server/src/main/java/dev/plex/command/impl/DebugCMD.java b/server/src/main/java/dev/plex/command/impl/DebugCMD.java index ec37286..ebd208e 100644 --- a/server/src/main/java/dev/plex/command/impl/DebugCMD.java +++ b/server/src/main/java/dev/plex/command/impl/DebugCMD.java @@ -4,6 +4,7 @@ import dev.plex.command.PlexCommand; import com.mojang.brigadier.builder.LiteralArgumentBuilder; import dev.plex.command.ServerCommand; import dev.plex.command.ServerCommandContext; +import dev.plex.command.exception.CommandFailException; import dev.plex.menu.impl.MaterialMenu; import dev.plex.util.GameRuleUtil; import dev.plex.util.PlexLog; @@ -26,7 +27,7 @@ public class DebugCMD extends ServerCommand { super(command("pdebug") .description("Plex's debug command") - .usage("/ | redis-reset | gamerules>") + .usage("/ | redis | redis-reset | gamerules>") .permission("plex.debug") .build()); } @@ -34,6 +35,8 @@ public class DebugCMD extends ServerCommand protected void buildCommand(LiteralArgumentBuilder command) { command.executes(context -> executeCommand(context)); + command.then(literal("redis") + .executes(context -> executeCommand(context, "redis"))); command.then(literal("redis-reset") .then(playerArgument("player") .executes(context -> executeCommand(context, "redis-reset", string(context, "player"))))); @@ -56,6 +59,18 @@ public class DebugCMD extends ServerCommand { return context.usage(); } + if (args[0].equalsIgnoreCase("redis")) + { + if (!plugin.getRedisConnection().isEnabled()) + { + throw new CommandFailException("&cRedis is not enabled."); + } + plugin.getRedisConnection().execute(jedis -> jedis.set("test", "123")); + context.send(sender, "Set test to 123. Now outputting key test..."); + String test = plugin.getRedisConnection().query(jedis -> jedis.get("test")); + context.send(sender, test); + return null; + } if (args[0].equalsIgnoreCase("redis-reset")) { if (args.length == 2) diff --git a/server/src/main/java/dev/plex/command/impl/PlexCMD.java b/server/src/main/java/dev/plex/command/impl/PlexCMD.java index 62b3695..e622e81 100644 --- a/server/src/main/java/dev/plex/command/impl/PlexCMD.java +++ b/server/src/main/java/dev/plex/command/impl/PlexCMD.java @@ -3,7 +3,7 @@ package dev.plex.command.impl; import com.mojang.brigadier.builder.LiteralArgumentBuilder; import dev.plex.command.ServerCommand; import dev.plex.command.ServerCommandContext; -import dev.plex.command.exception.CommandFailException; +import dev.plex.module.ModuleManager; import dev.plex.module.PlexModule; import dev.plex.module.PlexModuleFile; import dev.plex.util.BuildInfo; @@ -17,10 +17,7 @@ import java.util.stream.Collectors; import io.papermc.paper.command.brigadier.CommandSourceStack; import net.kyori.adventure.text.Component; import org.apache.commons.lang3.StringUtils; -import org.bukkit.Bukkit; -import org.bukkit.OfflinePlayer; import org.bukkit.command.CommandSender; -import org.bukkit.entity.Player; import org.jetbrains.annotations.NotNull; public class PlexCMD extends ServerCommand @@ -29,7 +26,7 @@ public class PlexCMD extends ServerCommand { super(command("plex") .description("Show information about Plex or reload it") - .usage("/ [reload | redis | update | modules [reload | update]]") + .usage("/ [reload | update | modules [reload | update | install | uninstall [-rmdir]]]") .build()); } // Don't modify this command @@ -39,8 +36,6 @@ public class PlexCMD extends ServerCommand command.executes(context -> executeCommand(context)); command.then(literal("reload") .executes(context -> executeCommand(context, "reload"))); - command.then(literal("redis") - .executes(context -> executeCommand(context, "redis"))); command.then(literal("update") .executes(context -> executeCommand(context, "update"))); command.then(literal("modules") @@ -48,14 +43,24 @@ public class PlexCMD extends ServerCommand .then(literal("reload") .executes(context -> executeCommand(context, "modules", "reload"))) .then(literal("update") - .executes(context -> executeCommand(context, "modules", "update")))); + .executes(context -> executeCommand(context, "modules", "update"))) + .then(literal("install") + .then(word("name") + .executes(context -> executeCommand(context, "modules", "install", string(context, "name"))))) + .then(literal("uninstall") + .then(word("name") + .suggests(suggest(() -> plugin.getModuleManager().getModules().stream() + .map(module -> module.getPlexModuleFile().getName()) + .collect(Collectors.toList()))) + .executes(context -> executeCommand(context, "modules", "uninstall", string(context, "name"))) + .then(literal("-rmdir") + .executes(context -> executeCommand(context, "modules", "uninstall", string(context, "name"), "-rmdir")))))); } @Override protected Component execute(@NotNull ServerCommandContext context) { CommandSender sender = context.sender(); - Player playerSender = context.player(); String[] args = context.args(); if (args.length == 0) { @@ -91,19 +96,6 @@ public class PlexCMD extends ServerCommand context.send(sender, "Plex successfully reloaded."); return null; } - else if (args[0].equalsIgnoreCase("redis")) - { - context.checkPermission(sender, "plex.redis"); - if (!plugin.getRedisConnection().isEnabled()) - { - throw new CommandFailException("&cRedis is not enabled."); - } - plugin.getRedisConnection().execute(jedis -> jedis.set("test", "123")); - context.send(sender, "Set test to 123. Now outputting key test..."); - String test = plugin.getRedisConnection().query(jedis -> jedis.get("test")); - context.send(sender, test); - return null; - } else if (args[0].equalsIgnoreCase("modules")) { if (args.length == 1) @@ -118,10 +110,7 @@ public class PlexCMD extends ServerCommand } else if (args[1].equalsIgnoreCase("update")) { - if (!hasUpdateAccess(context, playerSender, sender)) - { - return context.mmString("You must be a Developer to use this command."); - } + context.checkPermission(sender, "plex.modules.update"); for (PlexModule module : plugin.getModuleManager().getModules()) { plugin.getUpdateChecker().updateModuleJar(sender, module); @@ -129,13 +118,42 @@ public class PlexCMD extends ServerCommand plugin.getModuleManager().reloadModules(); return context.mmString("All modules updated and reloaded!"); } + else if (args[1].equalsIgnoreCase("install")) + { + context.checkPermission(sender, "plex.modules.install"); + if (args.length < 3) + { + return context.usage(); + } + String moduleName = args[2]; + plugin.getUpdateChecker().installModuleJar(sender, moduleName); + return context.mmString("Installing module " + moduleName + "..."); + } + else if (args[1].equalsIgnoreCase("uninstall")) + { + context.checkPermission(sender, "plex.modules.uninstall"); + if (args.length < 3) + { + return context.usage(); + } + String moduleName = args[2]; + boolean removeData = args.length >= 4 && args[3].equalsIgnoreCase("-rmdir"); + ModuleManager.UninstallResult result = plugin.getModuleManager().uninstallModule(moduleName, removeData); + switch (result) + { + case NOT_FOUND: + return context.mmString("No installed module named " + moduleName + " was found."); + case FAILED: + return context.mmString("Failed to delete the JAR for " + moduleName + ". Check the server log."); + case REMOVED: + context.send(sender, context.mmString("Uninstalled module " + moduleName + "" + (removeData ? " and its data folder" : "") + ".")); + return context.messageComponent("moduleRestartRequired"); + } + } } else if (args[0].equalsIgnoreCase("update")) { - if (!hasUpdateAccess(context, playerSender, sender)) - { - return context.mmString("You must be a Developer to use this command."); - } + context.checkPermission(sender, "plex.update"); if (!plugin.getUpdateChecker().getUpdateStatusMessage(sender, false, 0)) { return context.mmString("Plex is already up to date!"); @@ -149,25 +167,4 @@ public class PlexCMD extends ServerCommand } return null; } - - // Owners and developers only have access - private boolean hasUpdateAccess(ServerCommandContext context, Player player, CommandSender sender) - { - // Allow CONSOLE, get OfflinePlayer for Telnet - if (context.isConsole(sender)) - { - if (sender.getName().equalsIgnoreCase("CONSOLE")) - { - return true; - } - OfflinePlayer offlinePlayer = Bukkit.getOfflinePlayer(sender.getName()); - if (offlinePlayer.hasPlayedBefore()) - { - return PlexUtils.DEVELOPERS.contains(offlinePlayer.getUniqueId().toString()); - } - return false; - } - assert player != null; - return PlexUtils.DEVELOPERS.contains(player.getUniqueId().toString()); - } } diff --git a/server/src/main/java/dev/plex/module/ModuleManager.java b/server/src/main/java/dev/plex/module/ModuleManager.java index a088fe6..bbff87f 100644 --- a/server/src/main/java/dev/plex/module/ModuleManager.java +++ b/server/src/main/java/dev/plex/module/ModuleManager.java @@ -118,6 +118,7 @@ public class ModuleManager PlexModule plexModule = module.getConstructor().newInstance(); plexModule.setApi(plugin.getApi()); plexModule.setPlexModuleFile(plexModuleFile); + plexModule.setModuleJar(file); plexModule.setDataFolder(new File(plugin.getModulesFolder() + File.separator + plexModuleFile.getName())); if (!plexModule.getDataFolder().exists()) @@ -217,6 +218,11 @@ public class ModuleManager public void reloadModules() { unloadModules(); + reloadFromDisk(); + } + + private void reloadFromDisk() + { loadAllModules(); loadModules(); enableModules(); @@ -225,4 +231,65 @@ public class ModuleManager PlexLog.warn("Module command changes were staged after Paper's Brigadier command lifecycle. Restart the server for the live command dispatcher to match the loaded modules."); } } + + /** + * Outcome of an uninstall request. + */ + public enum UninstallResult + { + NOT_FOUND, + REMOVED, + FAILED + } + + /** + * Uninstalls a loaded module by name: deletes its JAR and, optionally, its data + * folder, then reloads the remaining modules. + * + * @param name module name as declared in the module's module.yml + * @param removeData whether to also delete the module's data folder + * @return the outcome of the uninstall request + */ + public UninstallResult uninstallModule(String name, boolean removeData) + { + PlexModule target = modules.stream() + .filter(module -> module.getPlexModuleFile().getName().equalsIgnoreCase(name)) + .findFirst() + .orElse(null); + if (target == null) + { + return UninstallResult.NOT_FOUND; + } + + File moduleJar = target.getModuleJar(); + File dataFolder = target.getDataFolder(); + + unloadModules(); + + boolean deleted = moduleJar.delete(); + if (deleted && removeData && dataFolder.isDirectory()) + { + deleteRecursively(dataFolder); + } + + reloadFromDisk(); + + return deleted ? UninstallResult.REMOVED : UninstallResult.FAILED; + } + + private void deleteRecursively(File file) + { + File[] children = file.listFiles(); + if (children != null) + { + for (File child : children) + { + deleteRecursively(child); + } + } + if (!file.delete()) + { + PlexLog.warn("Unable to delete " + file.getAbsolutePath()); + } + } } diff --git a/server/src/main/java/dev/plex/util/UpdateChecker.java b/server/src/main/java/dev/plex/util/UpdateChecker.java index c2aa032..14cabde 100644 --- a/server/src/main/java/dev/plex/util/UpdateChecker.java +++ b/server/src/main/java/dev/plex/util/UpdateChecker.java @@ -91,6 +91,15 @@ public class UpdateChecker updateJar(sender, name, module, List.of()); } + public void installModuleJar(CommandSender sender, String name) + { + updateJar(sender, name, true, List.of(), () -> plugin.getApi().scheduler().runGlobal(() -> + { + plugin.getModuleManager().reloadModules(); + sendMessage(sender, PlexUtils.messageComponent("moduleRestartRequired")); + })); + } + public void updateModuleJar(CommandSender sender, PlexModule module) { PlexModuleFile moduleFile = module.getPlexModuleFile(); @@ -103,6 +112,11 @@ public class UpdateChecker } private void updateJar(CommandSender sender, String name, boolean module, List moduleUpdateUrls) + { + updateJar(sender, name, module, moduleUpdateUrls, null); + } + + private void updateJar(CommandSender sender, String name, boolean module, List moduleUpdateUrls, Runnable onSuccess) { try { @@ -121,7 +135,7 @@ public class UpdateChecker : new File(Bukkit.getUpdateFolderFile(), metadata.fileName()); sendMessage(sender, PlexUtils.messageComponent("updateDownloading", metadata.fileName())); - plugin.getApi().scheduler().runAsync(() -> downloadAndInstall(sender, metadata, copyTo)); + plugin.getApi().scheduler().runAsync(() -> downloadAndInstall(sender, metadata, copyTo, onSuccess)); } catch (UpdateMetadataClient.MetadataException e) { @@ -174,7 +188,7 @@ public class UpdateChecker return System.currentTimeMillis() - latestPlexMetadataFailureAtMillis < PLEX_METADATA_FAILURE_CACHE_MILLIS; } - private void downloadAndInstall(CommandSender sender, ArtifactMetadata metadata, File copyTo) + private void downloadAndInstall(CommandSender sender, ArtifactMetadata metadata, File copyTo, Runnable onSuccess) { File parent = copyTo.getParentFile(); if (parent != null && !parent.exists() && !parent.mkdirs()) @@ -190,6 +204,10 @@ public class UpdateChecker validateDownloadedFile(metadata, temporaryFile); Files.move(temporaryFile.toPath(), copyTo.toPath(), StandardCopyOption.REPLACE_EXISTING); sendMessage(sender, PlexUtils.messageComponent("updateDownloaded")); + if (onSuccess != null) + { + onSuccess.run(); + } } catch (IOException e) { diff --git a/server/src/main/resources/messages.yml b/server/src/main/resources/messages.yml index fcc537c..d5195ba 100644 --- a/server/src/main/resources/messages.yml +++ b/server/src/main/resources/messages.yml @@ -357,3 +357,4 @@ updateMetadataNotFound: "No compatible update is available on the {0} chann # 0 - The error message updateMetadataError: "There was an error checking update metadata: {0}" moduleUpdateDisabled: "Skipping {0}; module updates are disabled." +moduleRestartRequired: "Module changes applied. Restart the server if module commands do not appear or disappear."