seedproject-web/api/app/Helpers/PluginManager.php
Carlos Arias 1559ce017d chore: scaffold SeedProject base (Phase 1)
Clean-room copy of the reusable engines from comiida, with all
instance data, secrets, dependencies, and build output excluded:
- app/         Astro theme skeleton (no comiida blog posts; hero image -> placeholder)
- api/         SeedProject PHP framework (no vendor/.env/config.php)
- content-pipeline/  engine only (scripts/admin/prompts; empty runtime state)
- astroagent.config.json + app/.astroagent/skills

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SYHWLHihq3v9nxNwoPCKSn
2026-07-04 22:53:10 +00:00

353 lines
14 KiB
PHP

<?php
/**
* PluginManager — WordPress-style plugin engine for SeedProject.
*
* Plugins live in plugins/{slug}/ and are integrated via symlinks into
* public/controllers/, public/views/plugins/, and public/assets/plugins/.
* No core framework files need modification beyond a single boot() call.
*/
class PluginManager {
private static string $pluginsDir;
private static string $controllersDir;
private static string $viewsDir;
private static string $assetsDir;
private static function init(): void {
static $initialized = false;
if ($initialized) return;
$base = dirname(__DIR__, 2); // project root
self::$pluginsDir = $base . '/plugins';
self::$controllersDir = $base . '/public/controllers';
self::$viewsDir = $base . '/public/views/plugins';
self::$assetsDir = $base . '/public/assets/plugins';
$initialized = true;
}
// ─── Boot (called once per request from Router::Routing) ─────────────────
/**
* Register AltoRouter routes for all enabled plugins.
* Called from Router::Routing() before $router->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);
}
}