From c687bd079597e7c0425bdc18edd86ab70f0320b4 Mon Sep 17 00:00:00 2001 From: Sebastian Krupinski Date: Sun, 22 Feb 2026 00:40:38 -0500 Subject: [PATCH] feat: improve module management Signed-off-by: Sebastian Krupinski --- core/lib/Console/ModuleDisableCommand.php | 3 +- core/lib/Console/ModuleEnableCommand.php | 3 +- core/lib/Console/ModuleInstallCommand.php | 86 ++++++++++++ core/lib/Console/ModuleListCommand.php | 5 +- core/lib/Console/ModuleUninstallCommand.php | 95 +++++++++++++ core/lib/Console/ModuleUpgradeCommand.php | 144 ++++++++++++++++++++ core/lib/Controllers/ModuleController.php | 2 +- core/lib/Module/Module.php | 3 + core/lib/Module/ModuleAutoloader.php | 4 +- core/lib/Module/ModuleManager.php | 29 ++-- core/lib/Module/ModuleObject.php | 23 +++- core/lib/Module/Store/ModuleStore.php | 22 ++- 12 files changed, 391 insertions(+), 28 deletions(-) create mode 100644 core/lib/Console/ModuleInstallCommand.php create mode 100644 core/lib/Console/ModuleUninstallCommand.php create mode 100644 core/lib/Console/ModuleUpgradeCommand.php diff --git a/core/lib/Console/ModuleDisableCommand.php b/core/lib/Console/ModuleDisableCommand.php index b4881d7..a6ae67b 100644 --- a/core/lib/Console/ModuleDisableCommand.php +++ b/core/lib/Console/ModuleDisableCommand.php @@ -54,8 +54,7 @@ class ModuleDisableCommand extends Command } // Find the module - $modules = $this->moduleManager->list(installedOnly: true, enabledOnly: false); - $module = $modules[$handle] ?? null; + $module = $this->moduleManager->fetch($handle); if (!$module) { $io->error("Module '{$handle}' not found or not installed."); diff --git a/core/lib/Console/ModuleEnableCommand.php b/core/lib/Console/ModuleEnableCommand.php index 297b2d7..bff0b1f 100644 --- a/core/lib/Console/ModuleEnableCommand.php +++ b/core/lib/Console/ModuleEnableCommand.php @@ -48,8 +48,7 @@ class ModuleEnableCommand extends Command try { // Find the module - $modules = $this->moduleManager->list(installedOnly: true, enabledOnly: false); - $module = $modules[$handle] ?? null; + $module = $this->moduleManager->fetch($handle); if (!$module) { $io->error("Module '{$handle}' not found or not installed."); diff --git a/core/lib/Console/ModuleInstallCommand.php b/core/lib/Console/ModuleInstallCommand.php new file mode 100644 index 0000000..e878dad --- /dev/null +++ b/core/lib/Console/ModuleInstallCommand.php @@ -0,0 +1,86 @@ +addArgument('handle', InputArgument::REQUIRED, 'Module handle to install') + ->setHelp('This command installs a module from the filesystem.') + ; + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + $handle = $input->getArgument('handle'); + + $io->title('Install Module'); + + try { + // Prevent installing core module + if ($handle === 'core') { + $io->error('Cannot install the core module.'); + return Command::FAILURE; + } + + // Check if the module is already installed + $module = $this->moduleManager->fetch($handle); + + if ($module) { + $io->warning("Module '{$handle}' is already installed."); + return Command::SUCCESS; + } + + // Install the module + $io->text("Installing module '{$handle}'..."); + $this->moduleManager->install($handle); + + $this->logger->info('Module installed via console', [ + 'handle' => $handle, + 'command' => $this->getName(), + ]); + + $io->success("Module '{$handle}' installed successfully!"); + + return Command::SUCCESS; + + } catch (\Throwable $e) { + $io->error('Failed to install module: ' . $e->getMessage()); + $this->logger->error('Module install failed', [ + 'handle' => $handle, + 'error' => $e->getMessage(), + ]); + return Command::FAILURE; + } + } +} diff --git a/core/lib/Console/ModuleListCommand.php b/core/lib/Console/ModuleListCommand.php index a27c49b..0e2b69c 100644 --- a/core/lib/Console/ModuleListCommand.php +++ b/core/lib/Console/ModuleListCommand.php @@ -45,10 +45,7 @@ class ModuleListCommand extends Command $io->title('Installed Modules'); try { - $modules = $this->moduleManager->list( - installedOnly: true, - enabledOnly: !$showAll - ); + $modules = $this->moduleManager->list(); if (count($modules) === 0) { $io->warning('No modules found.'); diff --git a/core/lib/Console/ModuleUninstallCommand.php b/core/lib/Console/ModuleUninstallCommand.php new file mode 100644 index 0000000..5242c4f --- /dev/null +++ b/core/lib/Console/ModuleUninstallCommand.php @@ -0,0 +1,95 @@ +addArgument('handle', InputArgument::REQUIRED, 'Module handle to uninstall') + ->addOption('force', 'f', InputOption::VALUE_NONE, 'Skip confirmation prompt') + ->setHelp('This command uninstalls an installed module.') + ; + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + $handle = $input->getArgument('handle'); + $force = $input->getOption('force'); + + $io->title('Uninstall Module'); + + try { + // Prevent uninstalling core module + if ($handle === 'core') { + $io->error('Cannot uninstall the core module.'); + return Command::FAILURE; + } + + // Find the module + $module = $this->moduleManager->fetch($handle); + + if (!$module) { + $io->error("Module '{$handle}' not found or not installed."); + return Command::FAILURE; + } + + // Confirm unless --force is passed + if (!$force && !$io->confirm("Are you sure you want to uninstall module '{$handle}'?", false)) { + $io->text('Uninstall cancelled.'); + return Command::SUCCESS; + } + + // Uninstall the module + $io->text("Uninstalling module '{$handle}'..."); + $this->moduleManager->uninstall($handle); + + $this->logger->info('Module uninstalled via console', [ + 'handle' => $handle, + 'command' => $this->getName(), + ]); + + $io->success("Module '{$handle}' uninstalled successfully!"); + + return Command::SUCCESS; + + } catch (\Throwable $e) { + $io->error('Failed to uninstall module: ' . $e->getMessage()); + $this->logger->error('Module uninstall failed', [ + 'handle' => $handle, + 'error' => $e->getMessage(), + ]); + return Command::FAILURE; + } + } +} diff --git a/core/lib/Console/ModuleUpgradeCommand.php b/core/lib/Console/ModuleUpgradeCommand.php new file mode 100644 index 0000000..b64bd62 --- /dev/null +++ b/core/lib/Console/ModuleUpgradeCommand.php @@ -0,0 +1,144 @@ +addArgument('handle', InputArgument::OPTIONAL, 'Module handle to upgrade (omit to upgrade all)') + ->addOption('all', 'a', InputOption::VALUE_NONE, 'Upgrade all modules that need upgrading') + ->setHelp('This command upgrades an installed module. Use --all to upgrade all modules that need upgrading.') + ; + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + $handle = $input->getArgument('handle'); + $all = $input->getOption('all'); + + $io->title('Upgrade Module'); + + try { + if ($all || !$handle) { + return $this->upgradeAll($io); + } + + return $this->upgradeOne($io, $handle); + + } catch (\Throwable $e) { + $io->error('Failed to upgrade module: ' . $e->getMessage()); + $this->logger->error('Module upgrade failed', [ + 'handle' => $handle, + 'error' => $e->getMessage(), + ]); + return Command::FAILURE; + } + } + + private function upgradeOne(SymfonyStyle $io, string $handle): int + { + // Find the module + $module = $this->moduleManager->fetch($handle); + + if (!$module) { + $io->error("Module '{$handle}' not found or not installed."); + return Command::FAILURE; + } + + if (!$module->needsUpgrade()) { + $io->success("Module '{$handle}' is already up to date."); + return Command::SUCCESS; + } + + $io->text("Upgrading module '{$handle}'..."); + $this->moduleManager->upgrade($handle); + + $this->logger->info('Module upgraded via console', [ + 'handle' => $handle, + 'command' => $this->getName(), + ]); + + $io->success("Module '{$handle}' upgraded successfully!"); + + return Command::SUCCESS; + } + + private function upgradeAll(SymfonyStyle $io): int + { + $modules = $this->moduleManager->list(); + $pending = []; + + foreach ($modules as $module) { + if ($module->needsUpgrade()) { + $pending[] = $module->handle(); + } + } + + if (count($pending) === 0) { + $io->success('All modules are up to date.'); + return Command::SUCCESS; + } + + $io->text(sprintf('Found %d module(s) to upgrade: %s', count($pending), implode(', ', $pending))); + + $failed = []; + foreach ($pending as $handle) { + try { + $io->text("Upgrading module '{$handle}'..."); + $this->moduleManager->upgrade($handle); + $this->logger->info('Module upgraded via console', [ + 'handle' => $handle, + 'command' => $this->getName(), + ]); + $io->text("✓ '{$handle}' upgraded."); + } catch (\Throwable $e) { + $failed[] = $handle; + $io->text("✗ '{$handle}' failed: {$e->getMessage()}"); + $this->logger->error('Module upgrade failed', [ + 'handle' => $handle, + 'error' => $e->getMessage(), + ]); + } + } + + if (count($failed) > 0) { + $io->error(sprintf('Failed to upgrade %d module(s): %s', count($failed), implode(', ', $failed))); + return Command::FAILURE; + } + + $io->success(sprintf('Successfully upgraded %d module(s).', count($pending))); + + return Command::SUCCESS; + } +} diff --git a/core/lib/Controllers/ModuleController.php b/core/lib/Controllers/ModuleController.php index 140d329..5629167 100644 --- a/core/lib/Controllers/ModuleController.php +++ b/core/lib/Controllers/ModuleController.php @@ -21,7 +21,7 @@ class ModuleController extends ControllerAbstract )] public function index(): JsonResponse { - $modules = $this->moduleManager->list(false); + $modules = $this->moduleManager->list(); return new JsonResponse(['modules' => $modules]); } diff --git a/core/lib/Module/Module.php b/core/lib/Module/Module.php index f9a29d1..b68b268 100644 --- a/core/lib/Module/Module.php +++ b/core/lib/Module/Module.php @@ -102,6 +102,9 @@ class Module extends ModuleInstanceAbstract implements ModuleConsoleInterface, M \KTXC\Console\ModuleListCommand::class, \KTXC\Console\ModuleEnableCommand::class, \KTXC\Console\ModuleDisableCommand::class, + \KTXC\Console\ModuleInstallCommand::class, + \KTXC\Console\ModuleUninstallCommand::class, + \KTXC\Console\ModuleUpgradeCommand::class, ]; } diff --git a/core/lib/Module/ModuleAutoloader.php b/core/lib/Module/ModuleAutoloader.php index e2d5816..ea840fc 100644 --- a/core/lib/Module/ModuleAutoloader.php +++ b/core/lib/Module/ModuleAutoloader.php @@ -2,7 +2,7 @@ namespace KTXC\Module; -use KTXC\Application; +use KTXC\Server; /** * Custom autoloader for modules that allows PascalCase namespaces @@ -73,7 +73,7 @@ class ModuleAutoloader } // Register module namespaces with Composer ClassLoader - $composerLoader = \KTXC\Application::getComposerLoader(); + $composerLoader = Server::getComposerLoader(); if ($composerLoader !== null) { foreach ($this->namespaceMap as $namespace => $folderName) { $composerLoader->addPsr4( diff --git a/core/lib/Module/ModuleManager.php b/core/lib/Module/ModuleManager.php index 653a4a1..c550451 100644 --- a/core/lib/Module/ModuleManager.php +++ b/core/lib/Module/ModuleManager.php @@ -31,9 +31,9 @@ class ModuleManager * * @param bool $installedOnly If true, only return modules that are in the database * @param bool $enabledOnly If true, only return modules that are enabled (implies installedOnly) - * @return Module[] + * @return ModuleObject[] */ - public function list(bool $installedOnly = true, $enabledOnly = true): ModuleCollection + public function list(bool| null $installedOnly = null, bool| null $enabledOnly = null): ModuleCollection { $modules = New ModuleCollection(); @@ -44,12 +44,8 @@ class ModuleManager } // load all modules from store - $entries = $this->repository->list(); + $entries = $this->repository->list($installedOnly, $enabledOnly); foreach ($entries as $entry) { - if ($enabledOnly && !$entry->getEnabled()) { - continue; // Skip disabled modules if filtering for enabled only - } - // instance module $handle = $entry->getHandle(); if (isset($this->moduleInstances[$entry->getHandle()])) { $modules[$handle] = new ModuleObject($this->moduleInstances[$handle], $entry); @@ -60,7 +56,7 @@ class ModuleManager } } // load all modules from filesystem - if ($installedOnly === false) { + if ($installedOnly !== true) { $discovered = $this->modulesDiscover(); foreach ($discovered as $moduleInstance) { $handle = $moduleInstance->handle(); @@ -72,6 +68,21 @@ class ModuleManager return $modules; } + + public function fetch(string $handle): ?ModuleObject + { + $entry = $this->repository->fetch($handle); + if (!$entry) { + return null; + } + + $moduleInstance = $this->moduleInstance($entry->getHandle(), $entry->getNamespace()); + if (!$moduleInstance) { + return null; + } + + return new ModuleObject($moduleInstance, $entry); + } public function install(string $handle): void { @@ -258,7 +269,7 @@ class ModuleManager public function modulesBoot(): void { // Only load modules that are enabled in the database - $modules = $this->list(); + $modules = $this->list(true, true); $this->logger->debug('Booting enabled modules', ['count' => count($modules)]); foreach ($modules as $module) { $handle = $module->handle(); diff --git a/core/lib/Module/ModuleObject.php b/core/lib/Module/ModuleObject.php index 7257a77..f3c265e 100644 --- a/core/lib/Module/ModuleObject.php +++ b/core/lib/Module/ModuleObject.php @@ -9,10 +9,7 @@ use KTXF\Module\ModuleConsoleInterface; use KTXF\Module\ModuleInstanceInterface; /** - * Module is a unified wrapper that combines both the ModuleInterface instance - * (from filesystem) and ModuleEntry (from database) into a single object. - * - * This provides a single source of truth for all module information. + * Module is a unified wrapper that combines the filesystem module instance and the database module entry. */ class ModuleObject implements JsonSerializable { @@ -32,6 +29,9 @@ class ModuleObject implements JsonSerializable return [ 'id' => $this->id(), 'handle' => $this->handle(), + 'label' => $this->label(), + 'description' => $this->description(), + 'author' => $this->author(), 'version' => $this->version(), 'namespace' => $this->namespace(), 'installed' => $this->installed(), @@ -86,6 +86,21 @@ class ModuleObject implements JsonSerializable return null; } + public function label(): string + { + return $this->instance?->label() ?? ''; + } + + public function description(): string + { + return $this->instance?->description() ?? ''; + } + + public function author(): string + { + return $this->instance?->author() ?? ''; + } + public function version(): string { // Prefer current version from filesystem diff --git a/core/lib/Module/Store/ModuleStore.php b/core/lib/Module/Store/ModuleStore.php index 749cf3a..476b916 100644 --- a/core/lib/Module/Store/ModuleStore.php +++ b/core/lib/Module/Store/ModuleStore.php @@ -3,6 +3,7 @@ namespace KTXC\Module\Store; use KTXC\Db\DataStore; +use KTXC\Db\ObjectId; class ModuleStore { @@ -13,9 +14,18 @@ class ModuleStore protected readonly DataStore $dataStore ) { } - public function list(): array + public function list(bool|null $installed = null, bool|null $enabled = null): array { - $cursor = $this->dataStore->selectCollection(self::COLLECTION_NAME)->find(['enabled' => true, 'installed' => true]); + $filter = []; + if ($installed !== null) { + $filter['installed'] = $installed; + } + if ($enabled !== null) { + $filter['enabled'] = $enabled; + } + + $cursor = $this->dataStore->selectCollection(self::COLLECTION_NAME)->find($filter); + $modules = []; foreach ($cursor as $entry) { $entity = new ModuleEntry(); @@ -52,7 +62,11 @@ class ModuleStore { $id = $entry->getId(); if (!$id) { return null; } - $this->dataStore->selectCollection(self::COLLECTION_NAME)->updateOne(['_id' => $id], ['$set' => $entry->jsonSerialize()]); + + $data = $entry->jsonSerialize(); + unset($data['id']); + + $result = $this->dataStore->selectCollection(self::COLLECTION_NAME)->updateOne(['_id' => new ObjectId($id)], ['$set' => $data]); return $entry; } @@ -60,7 +74,7 @@ class ModuleStore { $id = $entry->getId(); if (!$id) { return; } - $this->dataStore->selectCollection(self::COLLECTION_NAME)->deleteOne([ '_id' => $id]); + $result = $this->dataStore->selectCollection(self::COLLECTION_NAME)->deleteOne(['_id' => new ObjectId($id)]); } } -- 2.39.5