match(). */ public static function boot(AltoRouter $router): void { self::init(); $plugins = Db::select( "SELECT slug FROM sp_plugins WHERE enabled = 1", [] ); if (!$plugins) return; foreach ($plugins as $row) { $slug = $row['slug']; $manifestPath = self::$pluginsDir . '/' . $slug . '/plugin.php'; if (!file_exists($manifestPath)) continue; $manifest = require $manifestPath; foreach ($manifest['routes'] ?? [] as $rule) { // Each rule: [method, pattern, target, name] if (count($rule) >= 3) { $router->map($rule[0], $rule[1], $rule[2], $rule[3] ?? null); } } } } // ─── Install ────────────────────────────────────────────────────────────── /** * Install a plugin: create symlinks, run install.php, upsert DB row (enabled=0). */ public static function install(string $slug): array { self::init(); $slug = self::sanitizeSlug($slug); if (!$slug) return ['success' => false, 'error' => 'Invalid slug']; $pluginDir = self::$pluginsDir . '/' . $slug; if (!is_dir($pluginDir)) { return ['success' => false, 'error' => "Plugin directory not found: {$slug}"]; } $manifestPath = $pluginDir . '/plugin.php'; if (!file_exists($manifestPath)) { return ['success' => false, 'error' => "plugin.php manifest missing for: {$slug}"]; } $manifest = require $manifestPath; // Create symlinks $symlinkResult = self::createSymlinks($slug, $manifest); if (!$symlinkResult['success']) return $symlinkResult; // Run install.php $installScript = $pluginDir . '/install.php'; if (file_exists($installScript)) { try { require $installScript; } catch (Throwable $e) { return ['success' => false, 'error' => 'install.php failed: ' . $e->getMessage()]; } } // Upsert sp_plugins row (enabled=0 — admin must explicitly enable) $existing = Db::getRow("SELECT plugin_id FROM sp_plugins WHERE slug = ?", [$slug]); if ($existing) { Db::update('sp_plugins', [ 'name' => $manifest['name'] ?? $slug, 'version' => $manifest['version'] ?? '1.0.0', 'description' => $manifest['description'] ?? '', 'author' => $manifest['author'] ?? '', 'updated_at' => date('Y-m-d H:i:s'), ], 'slug = ?', [$slug]); } else { Db::insert('sp_plugins', [ 'slug' => $slug, 'name' => $manifest['name'] ?? $slug, 'version' => $manifest['version'] ?? '1.0.0', 'description' => $manifest['description'] ?? '', 'author' => $manifest['author'] ?? '', 'enabled' => 0, 'installed_at' => date('Y-m-d H:i:s'), 'updated_at' => date('Y-m-d H:i:s'), ]); } return ['success' => true, 'message' => "Plugin '{$slug}' installed. Enable it to activate."]; } // ─── Enable ─────────────────────────────────────────────────────────────── /** * Enable a plugin: ensure symlinks exist, set enabled=1. */ public static function enable(string $slug): array { self::init(); $slug = self::sanitizeSlug($slug); $row = Db::getRow("SELECT plugin_id FROM sp_plugins WHERE slug = ?", [$slug]); if (!$row) return ['success' => false, 'error' => "Plugin '{$slug}' is not installed"]; $manifest = self::loadManifest($slug); if (!$manifest) return ['success' => false, 'error' => "Cannot load manifest for '{$slug}'"]; $symlinkResult = self::createSymlinks($slug, $manifest); if (!$symlinkResult['success']) return $symlinkResult; Db::update('sp_plugins', ['enabled' => 1, 'updated_at' => date('Y-m-d H:i:s')], 'slug = ?', [$slug]); return ['success' => true, 'message' => "Plugin '{$slug}' enabled"]; } // ─── Disable ────────────────────────────────────────────────────────────── /** * Disable a plugin: remove symlinks, set enabled=0. * Symlink removal causes natural 404 without any guard code. */ public static function disable(string $slug): array { self::init(); $slug = self::sanitizeSlug($slug); $row = Db::getRow("SELECT plugin_id FROM sp_plugins WHERE slug = ?", [$slug]); if (!$row) return ['success' => false, 'error' => "Plugin '{$slug}' is not installed"]; self::removeSymlinks($slug); Db::update('sp_plugins', ['enabled' => 0, 'updated_at' => date('Y-m-d H:i:s')], 'slug = ?', [$slug]); return ['success' => true, 'message' => "Plugin '{$slug}' disabled"]; } // ─── Uninstall ──────────────────────────────────────────────────────────── /** * Uninstall a plugin: remove symlinks, run uninstall.php, delete DB row. */ public static function uninstall(string $slug): array { self::init(); $slug = self::sanitizeSlug($slug); self::removeSymlinks($slug); $uninstallScript = self::$pluginsDir . '/' . $slug . '/uninstall.php'; if (file_exists($uninstallScript)) { try { require $uninstallScript; } catch (Throwable $e) { // Log but don't abort — clean up DB anyway } } Db::delete('sp_plugins', ['slug' => $slug]); return ['success' => true, 'message' => "Plugin '{$slug}' uninstalled"]; } // ─── Discover ───────────────────────────────────────────────────────────── /** * Scan plugins/ directory and merge with DB rows. * Returns array of plugin info for the admin UI. */ public static function discover(): array { self::init(); // Get all DB rows keyed by slug $dbRows = []; $rows = Db::select("SELECT * FROM sp_plugins", []); foreach ($rows as $row) { $dbRows[$row['slug']] = $row; } $plugins = []; if (!is_dir(self::$pluginsDir)) return $plugins; $dirs = glob(self::$pluginsDir . '/*/plugin.php'); if (!$dirs) return $plugins; foreach ($dirs as $manifestPath) { $slug = basename(dirname($manifestPath)); $manifest = require $manifestPath; $dbRow = $dbRows[$slug] ?? null; $plugins[] = [ 'slug' => $slug, 'name' => $manifest['name'] ?? $slug, 'version' => $manifest['version'] ?? '1.0.0', 'description' => $manifest['description'] ?? '', 'author' => $manifest['author'] ?? '', 'installed' => $dbRow !== null, 'enabled' => (bool)($dbRow['enabled'] ?? false), 'installed_at' => $dbRow['installed_at'] ?? null, 'plugin_id' => $dbRow['plugin_id'] ?? null, ]; } return $plugins; } // ─── Helpers ────────────────────────────────────────────────────────────── public static function pluginPath(string $slug): string { self::init(); return self::$pluginsDir . '/' . $slug; } private static function sanitizeSlug(string $slug): string { return preg_replace('/[^a-z0-9_-]/', '', strtolower(trim($slug))); } private static function loadManifest(string $slug): ?array { self::init(); $path = self::$pluginsDir . '/' . $slug . '/plugin.php'; return file_exists($path) ? require $path : null; } /** * Copy plugin files into the framework directories. * public/controllers/{slug}.php ← plugins/{slug}/{Slug}Controller.php * public/views/plugins/{slug}/ ← plugins/{slug}/views/{slug}/ * public/assets/plugins/{slug}/ ← plugins/{slug}/assets/ */ private static function createSymlinks(string $slug, array $manifest): array { self::init(); $pluginDir = self::$pluginsDir . '/' . $slug; $controllerClass = ucfirst($slug) . 'Controller'; // Ensure parent dirs exist foreach ([self::$viewsDir, self::$assetsDir] as $dir) { if (!is_dir($dir)) { mkdir($dir, 0755, true); } } // Copy controller file $controllerSrc = $pluginDir . '/' . $controllerClass . '.php'; $controllerDest = self::$controllersDir . '/' . $slug . '.php'; if (!file_exists($controllerSrc)) { return ['success' => false, 'error' => "Controller not found: {$controllerClass}.php"]; } if (!copy($controllerSrc, $controllerDest)) { return ['success' => false, 'error' => "Failed to copy controller to: {$controllerDest}"]; } // Copy views directory (optional) $viewsSrc = $pluginDir . '/views/' . $slug; $viewsDest = self::$viewsDir . '/' . $slug; if (is_dir($viewsSrc)) { $result = self::copyDir($viewsSrc, $viewsDest); if (!$result) { return ['success' => false, 'error' => "Failed to copy views to: {$viewsDest}"]; } } // Copy assets directory (optional) $assetsSrc = $pluginDir . '/assets'; $assetsDest = self::$assetsDir . '/' . $slug; if (is_dir($assetsSrc)) { $result = self::copyDir($assetsSrc, $assetsDest); if (!$result) { return ['success' => false, 'error' => "Failed to copy assets to: {$assetsDest}"]; } } return ['success' => true]; } /** * Remove copied plugin files from framework directories. */ private static function removeSymlinks(string $slug): void { self::init(); $controllerFile = self::$controllersDir . '/' . $slug . '.php'; if (file_exists($controllerFile)) { unlink($controllerFile); } foreach ([self::$viewsDir . '/' . $slug, self::$assetsDir . '/' . $slug] as $dir) { if (is_dir($dir)) { self::removeDir($dir); } } } /** * Recursively copy a directory. */ private static function copyDir(string $src, string $dest): bool { if (!is_dir($dest)) { mkdir($dest, 0755, true); } foreach (scandir($src) as $item) { if ($item === '.' || $item === '..') continue; $s = $src . '/' . $item; $d = $dest . '/' . $item; if (is_dir($s)) { if (!self::copyDir($s, $d)) return false; } else { if (!copy($s, $d)) return false; } } return true; } /** * Recursively delete a directory. */ private static function removeDir(string $dir): void { foreach (scandir($dir) as $item) { if ($item === '.' || $item === '..') continue; $path = $dir . '/' . $item; is_dir($path) ? self::removeDir($path) : unlink($path); } rmdir($dir); } }