501 lines
18 KiB
PHP
501 lines
18 KiB
PHP
<?php
|
|
|
|
namespace KTXC\Module;
|
|
|
|
use Exception;
|
|
use DI\Attribute\Inject;
|
|
use KTXC\Module\Store\ModuleStore;
|
|
use KTXC\Module\Store\ModuleEntry;
|
|
use Psr\Container\ContainerInterface;
|
|
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,
|
|
private readonly ContainerInterface $container,
|
|
#[Inject('rootDir')] private readonly string $rootDir
|
|
) {
|
|
// Initialize server root path
|
|
$this->serverRoot = $rootDir;
|
|
}
|
|
|
|
/**
|
|
* 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
|
|
{
|
|
// Load module's vendor autoloader if it exists
|
|
$this->loadModuleVendor($handle);
|
|
|
|
// 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
|
|
$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 ($this->container->has($typeName)) {
|
|
$args[] = $this->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;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Load a module's vendor autoloader if it has dependencies
|
|
*
|
|
* @param string $handle Module handle
|
|
* @throws Exception If module has dependencies but vendor directory is missing
|
|
*/
|
|
private function loadModuleVendor(string $handle): void
|
|
{
|
|
$moduleDir = $this->serverRoot . '/modules/' . $handle;
|
|
$composerJson = $moduleDir . '/composer.json';
|
|
$vendorAutoload = $moduleDir . '/lib/vendor/autoload.php';
|
|
|
|
// Check if module has a composer.json with dependencies
|
|
if (file_exists($composerJson)) {
|
|
$composerData = json_decode(file_get_contents($composerJson), true);
|
|
$hasDependencies = !empty($composerData['require']) && count($composerData['require']) > 1; // More than just PHP
|
|
|
|
if ($hasDependencies) {
|
|
if (file_exists($vendorAutoload)) {
|
|
require_once $vendorAutoload;
|
|
$this->logger->debug("Loaded vendor autoloader for module: {$handle}");
|
|
} else {
|
|
throw new Exception(
|
|
"Module '{$handle}' declares dependencies in composer.json but vendor directory is missing. "
|
|
. "Run 'composer install' in {$moduleDir}"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
}
|