Files
server/core/lib/Module/ModuleManager.php
2025-12-21 10:09:54 -05:00

465 lines
16 KiB
PHP

<?php
namespace KTXC\Module;
use Exception;
use KTXC\Module\Store\ModuleStore;
use KTXC\Module\Store\ModuleEntry;
use KTXC\Server;
use KTXF\Module\ModuleInstanceInterface;
use Psr\Log\LoggerInterface;
use ReflectionClass;
class ModuleManager
{
private string $serverRoot = '';
private array $moduleInstances = [];
public function __construct(
private readonly ModuleStore $repository,
private readonly LoggerInterface $logger
) {
// Initialize server root path
$this->serverRoot = Server::runtimeRootLocation();
}
/**
* List all modules as unified Module objects
*
* @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[]
*/
public function list(bool $installedOnly = true, $enabledOnly = true): ModuleCollection
{
$modules = New ModuleCollection();
// load all modules from store
$entries = $this->repository->list();
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);
} else {
$moduleInstance = $this->moduleInstance($handle, $entry->getNamespace());
$modules[$handle] = new ModuleObject($moduleInstance, $entry);
$this->moduleInstances[$handle] = $moduleInstance;
}
}
// load all modules from filesystem
if ($installedOnly === false) {
$discovered = $this->modulesDiscover();
foreach ($discovered as $moduleInstance) {
$handle = $moduleInstance->handle();
if (!isset($modules[$handle])) {
$modules[$handle] = new ModuleObject($moduleInstance, null);
}
}
}
return $modules;
}
public function install(string $handle): void
{
// First, try to find the module by scanning the filesystem
$modulesDir = $this->serverRoot . '/modules';
$namespace = null;
// Scan for the module by checking if handle matches any folder or module's handle() method
if (is_dir($modulesDir)) {
$moduleDirs = glob($modulesDir . '/*', GLOB_ONLYDIR);
foreach ($moduleDirs as $moduleDir) {
$testModuleFile = $moduleDir . '/lib/Module.php';
if (!file_exists($testModuleFile)) {
continue;
}
// Extract namespace from the Module.php file
$testNamespace = $this->extractNamespaceFromFile($testModuleFile);
if (!$testNamespace) {
continue;
}
// Try to instantiate with a temporary handle to check if it matches
$folderName = basename($moduleDir);
$testInstance = $this->moduleInstance($folderName, $testNamespace);
if ($testInstance && $testInstance->handle() === $handle) {
$namespace = $testNamespace;
break;
}
}
}
if (!$namespace) {
$this->logger->error('Module not found for installation', ['handle' => $handle]);
return;
}
$moduleInstance = $this->moduleInstance($handle, $namespace);
if (!$moduleInstance) {
return;
}
try {
$moduleInstance->install();
} catch (Exception $e) {
$this->logger->error('Module installation failed: ' . $handle, [
'exception' => [
'code' => $e->getCode(),
'message' => $e->getMessage(),
]
]);
return;
}
$module = new ModuleEntry();
$module->setHandle($handle);
$module->setVersion($moduleInstance->version());
$module->setEnabled(false);
$module->setInstalled(true);
// Store the namespace we found
$module->setNamespace($namespace);
$this->repository->deposit($module);
}
public function uninstall(string $handle): void
{
$moduleEntry = $this->repository->fetch($handle);
if (!$moduleEntry || !$moduleEntry->getInstalled()) {
$this->logger->warning('Attempted to uninstall non-installed module: ' . $handle);
throw new Exception('Module not installed: ' . $handle);
}
$moduleInstance = $this->moduleInstance($moduleEntry->getHandle(), $moduleEntry->getNamespace());
if (!$moduleInstance) {
return;
}
try {
$moduleInstance->uninstall();
} catch (Exception $e) {
$this->logger->error('Module uninstallation failed: ' . $moduleEntry->getHandle(), [
'exception' => [
'code' => $e->getCode(),
'message' => $e->getMessage(),
]
]);
return;
}
$this->repository->destroy($moduleEntry);
}
public function enable(string $handle): void
{
$moduleEntry = $this->repository->fetch($handle);
if (!$moduleEntry || !$moduleEntry->getInstalled()) {
$this->logger->warning('Attempted to uninstall non-installed module: ' . $handle);
throw new Exception('Module not installed: ' . $handle);
}
$moduleInstance = $this->moduleInstance($moduleEntry->getHandle(), $moduleEntry->getNamespace());
if (!$moduleInstance) {
return;
}
try {
$moduleInstance->enable();
} catch (Exception $e) {
$this->logger->error('Module enabling failed: ' . $moduleEntry->getHandle(), [
'exception' => [
'code' => $e->getCode(),
'message' => $e->getMessage(),
]
]);
return;
}
$moduleEntry->setEnabled(true);
$this->repository->deposit($moduleEntry);
}
public function disable(string $handle): void
{
$moduleEntry = $this->repository->fetch($handle);
if (!$moduleEntry || !$moduleEntry->getInstalled()) {
$this->logger->warning('Attempted to uninstall non-installed module: ' . $handle);
throw new Exception('Module not installed: ' . $handle);
}
$moduleInstance = $this->moduleInstance($moduleEntry->getHandle(), $moduleEntry->getNamespace());
if (!$moduleInstance) {
return;
}
try {
$moduleInstance->disable();
} catch (Exception $e) {
$this->logger->error('Module disabling failed: ' . $moduleEntry->getHandle(), [
'exception' => [
'code' => $e->getCode(),
'message' => $e->getMessage(),
]
]);
return;
}
$moduleEntry->setEnabled(false);
$this->repository->deposit($moduleEntry);
}
public function upgrade(string $handle): void
{
$moduleEntry = $this->repository->fetch($handle);
if (!$moduleEntry || !$moduleEntry->getInstalled()) {
$this->logger->warning('Attempted to uninstall non-installed module: ' . $handle);
throw new Exception('Module not installed: ' . $handle);
}
$moduleInstance = $this->moduleInstance($moduleEntry->getHandle(), $moduleEntry->getNamespace());
if (!$moduleInstance) {
return;
}
try {
$moduleInstance->upgrade();
} catch (Exception $e) {
$this->logger->error('Module upgrade failed: ' . $moduleEntry->getHandle(), [
'exception' => [
'code' => $e->getCode(),
'message' => $e->getMessage(),
]
]);
return;
}
$moduleEntry->setVersion($moduleInstance->version());
$this->repository->deposit($moduleEntry);
}
/**
* Scan filesystem for module directories and return module instances
*
* @return array<string, ModuleInstanceInterface> Map of handle => ModuleInstanceInterface
*/
private function modulesDiscover(): array
{
$modules = [];
$modulesDir = $this->serverRoot . '/modules';
if (!is_dir($modulesDir)) {
return $modules;
}
// Get list of installed module handles to skip
$installedHandles = [];
foreach ($this->repository->list() as $entry) {
$installedHandles[] = $entry->getHandle();
}
// Scan for module directories
$moduleDirs = glob($modulesDir . '/*', GLOB_ONLYDIR);
foreach ($moduleDirs as $moduleDir) {
$moduleFile = $moduleDir . '/lib/Module.php';
if (!file_exists($moduleFile)) {
continue;
}
// Extract namespace from the Module.php file
$namespace = $this->extractNamespaceFromFile($moduleFile);
if (!$namespace) {
$this->logger->warning('Could not extract namespace from Module.php', [
'file' => $moduleFile
]);
continue;
}
// Use the folder name as a temporary handle to instantiate the module
$folderName = basename($moduleDir);
$moduleInstance = $this->moduleInstance($folderName, $namespace);
if (!$moduleInstance) {
continue;
}
// Get the actual handle from the module instance
$handle = $moduleInstance->handle();
// Skip if already installed
if (in_array($handle, $installedHandles)) {
continue;
}
// Re-cache with the correct handle if different from folder name
if ($handle !== $folderName) {
unset($this->moduleInstances[$folderName]);
$this->moduleInstances[$handle] = $moduleInstance;
}
$modules[$handle] = $moduleInstance;
}
return $modules;
}
/**
* Boot all enabled modules (must be called after container is ready).
*/
public function modulesBoot(): void
{
// Only load modules that are enabled in the database
$modules = $this->list();
$this->logger->debug('Booting enabled modules', ['count' => count($modules)]);
foreach ($modules as $module) {
$handle = $module->handle();
try {
$module->boot();
$this->logger->debug('Module booted', ['handle' => $handle]);
} catch (Exception $e) {
$this->logger->error('Module boot failed: ' . $handle, [
'exception' => $e,
'message' => $e->getMessage(),
'code' => $e->getCode(),
]);
}
}
}
public function moduleInstance(string $handle, ?string $namespace = null): ?ModuleInstanceInterface
{
// Return from cache if already instantiated
if (isset($this->moduleInstances[$handle])) {
return $this->moduleInstances[$handle];
}
// Determine the namespace segment
// If namespace is provided, use it; otherwise derive from handle
$nsSegment = $namespace ?: $this->studly($handle);
$className = 'KTXM\\' . $nsSegment . '\\Module';
if (!class_exists($className)) {
$this->logger->error('Module class not found', [
'handle' => $handle,
'namespace' => $namespace,
'resolved' => $className
]);
return null;
}
if (!in_array(ModuleInstanceInterface::class, class_implements($className))) {
$this->logger->error('Module class does not implement ModuleInstanceInterface', [
'class' => $className
]);
return null;
}
try {
$module = $this->moduleLoad($className);
} catch (Exception $e) {
$this->logger->error('Failed to lazily create module instance', [
'handle' => $handle,
'namespace' => $namespace,
'exception' => $e->getMessage()
]);
return null;
}
// Cache by handle
if ($module) {
$this->moduleInstances[$handle] = $module;
}
return $module;
}
private function moduleLoad(string $className): ?ModuleInstanceInterface
{
try {
// Use reflection to check constructor requirements
$reflectionClass = new ReflectionClass($className);
$constructor = $reflectionClass->getConstructor();
if (!$constructor || $constructor->getNumberOfRequiredParameters() === 0) {
// Simple instantiation for modules without dependencies
return new $className();
}
// For modules with dependencies, try to resolve them from the container
$container = Server::runtimeContainer();
$parameters = $constructor->getParameters();
$args = [];
foreach ($parameters as $parameter) {
$type = $parameter->getType();
if ($type && !$type->isBuiltin()) {
$typeName = $type->getName();
// Try to get service from container
if ($container->has($typeName)) {
$args[] = $container->get($typeName);
} elseif ($parameter->isDefaultValueAvailable()) {
$args[] = $parameter->getDefaultValue();
} else {
// Cannot resolve dependency
$this->logger->warning('Cannot resolve dependency for module: ' . $className, [
'dependency' => $typeName
]);
return null;
}
} elseif ($parameter->isDefaultValueAvailable()) {
$args[] = $parameter->getDefaultValue();
} else {
// Cannot resolve primitive dependency
return null;
}
}
return $reflectionClass->newInstanceArgs($args);
} catch (Exception $e) {
$this->logger->error('Failed to instantiate module: ' . $className, [
'exception' => $e->getMessage()
]);
return null;
}
}
private function studly(string $value): string
{
$value = str_replace(['-', '_'], ' ', strtolower($value));
$value = ucwords($value);
return str_replace(' ', '', $value);
}
/**
* Extract the PHP namespace from a Module.php file by parsing its contents
*
* @param string $moduleFilePath Absolute path to the Module.php file (located at /modules/{handle}/lib/Module.php)
* @return string|null The namespace segment (e.g., 'ContactsManager' from 'KTXM\ContactsManager')
*/
private function extractNamespaceFromFile(string $moduleFilePath): ?string
{
if (!file_exists($moduleFilePath)) {
return null;
}
$content = file_get_contents($moduleFilePath);
if ($content === false) {
return null;
}
// Match namespace declaration: namespace KTXM\<Namespace>;
if (preg_match('/^\s*namespace\s+KTXM\\\\([a-zA-Z0-9_]+)\s*;/m', $content, $matches)) {
return $matches[1];
}
return null;
}
}