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
353 lines
14 KiB
PHP
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);
|
|
}
|
|
}
|