feat: extend module platform with UI slots, runtime components, CLI tooling and {{userId}} search support
Completes the generic module platform that enables modules to contribute
UI elements, runtime JS components, and search resources without any
core hardcoding.
New generic UI slot types:
- topbar.right_item: module-contributed topbar buttons
- layout.body_end_template: module-contributed dialog/overlay templates
- layout.head_style: module-contributed global CSS
- runtime.component: declarative JS component registration with phase ordering
New infrastructure:
- ModuleAutoloader: PSR-4 autoloading for module-local PHP classes
- ModuleRuntimePageBuilder: symlinks module pages into runtime directory
- ModuleRuntimeAssetPublisher: publishes module CSS/JS to web/modules/
- ModulePermissionSynchronizer: syncs module permissions to DB
- CLI scripts: module-runtime-sync, module-build, module-migrate,
module-permissions-sync, module-assets-sync
- {{userId}} placeholder in SearchDataService for user-scoped search queries
- Component runtime with phased initialization (early/default/late)
- AppContainer.protectExistingBindings() to prevent module→core overwrites
- Architecture tests: ModuleStructureContractTest, CoreTemplateIsolationTest,
FrontendComponentRuntimeContractTest, AppContainerIsolationContractTest
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -17,102 +17,120 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
chdir(__DIR__ . '/..');
|
||||
|
||||
require 'vendor/autoload.php';
|
||||
require 'config/config.php';
|
||||
require 'lib/Support/helpers.php';
|
||||
$container = require 'lib/App/registerContainer.php';
|
||||
setAppContainer($container);
|
||||
|
||||
use MintyPHP\App\Module\ModuleRegistry;
|
||||
use MintyPHP\DB;
|
||||
|
||||
$exitCode = 0;
|
||||
$totalApplied = 0;
|
||||
require_once __DIR__ . '/module-cli-bootstrap.php';
|
||||
|
||||
try {
|
||||
$registry = app(ModuleRegistry::class);
|
||||
$modules = $registry->getModules();
|
||||
function moduleMigrateRun(): int
|
||||
{
|
||||
$warningsBefore = moduleCliVendorWarningsIgnoredTotal();
|
||||
moduleCliBootstrapApp();
|
||||
|
||||
if (count($modules) === 0) {
|
||||
fwrite(STDOUT, "module-migrate: no modules enabled, nothing to do.\n");
|
||||
exit(0);
|
||||
}
|
||||
$exitCode = 0;
|
||||
$totalApplied = 0;
|
||||
|
||||
// Ensure tracking table exists (safe if already created by db/updates/ script)
|
||||
DB::execute(
|
||||
'CREATE TABLE IF NOT EXISTS `module_migrations` (
|
||||
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`module_id` VARCHAR(64) NOT NULL,
|
||||
`filename` VARCHAR(255) NOT NULL,
|
||||
`applied_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uq_module_migration` (`module_id`, `filename`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci'
|
||||
);
|
||||
try {
|
||||
$registry = app(ModuleRegistry::class);
|
||||
$modules = $registry->getModules();
|
||||
$hasModules = count($modules) > 0;
|
||||
|
||||
foreach ($modules as $manifest) {
|
||||
$migrationsPath = $manifest->migrationsPath;
|
||||
if ($migrationsPath === null || !is_dir($migrationsPath)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Discover SQL files
|
||||
$files = glob($migrationsPath . '/*.sql');
|
||||
if ($files === false || count($files) === 0) {
|
||||
continue;
|
||||
}
|
||||
sort($files); // alphabetical order
|
||||
|
||||
// Get already-applied filenames
|
||||
$applied = [];
|
||||
$rows = DB::select('SELECT filename FROM module_migrations WHERE module_id = ?', [$manifest->id]);
|
||||
foreach ($rows as $row) {
|
||||
$applied[$row['filename']] = true;
|
||||
}
|
||||
|
||||
foreach ($files as $file) {
|
||||
$filename = basename($file);
|
||||
if (isset($applied[$filename])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$sql = file_get_contents($file);
|
||||
if ($sql === false || trim($sql) === '') {
|
||||
fwrite(STDERR, sprintf("module-migrate: WARNING skipping empty file %s/%s\n", $manifest->id, $filename));
|
||||
continue;
|
||||
}
|
||||
|
||||
fwrite(STDOUT, sprintf("module-migrate: applying %s/%s ... ", $manifest->id, $filename));
|
||||
|
||||
// Execute migration (may contain multiple statements)
|
||||
$statements = array_filter(array_map('trim', explode(';', $sql)), static fn (string $s): bool => $s !== '');
|
||||
foreach ($statements as $statement) {
|
||||
DB::execute($statement);
|
||||
}
|
||||
|
||||
// Record as applied
|
||||
DB::execute(
|
||||
'INSERT INTO module_migrations (module_id, filename) VALUES (?, ?)',
|
||||
[$manifest->id, $filename]
|
||||
if (!$hasModules) {
|
||||
fwrite(STDOUT, "module-migrate: no modules enabled, nothing to do.\n");
|
||||
} else {
|
||||
// Ensure tracking table exists (safe if already created by db/updates/ script)
|
||||
DB::query(
|
||||
'CREATE TABLE IF NOT EXISTS `module_migrations` (
|
||||
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`module_id` VARCHAR(64) NOT NULL,
|
||||
`filename` VARCHAR(255) NOT NULL,
|
||||
`applied_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uq_module_migration` (`module_id`, `filename`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci'
|
||||
);
|
||||
|
||||
fwrite(STDOUT, "OK\n");
|
||||
$totalApplied++;
|
||||
}
|
||||
|
||||
foreach ($modules as $manifest) {
|
||||
$migrationsPath = $manifest->migrationsPath;
|
||||
if ($migrationsPath === null || !is_dir($migrationsPath)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Discover SQL files
|
||||
$files = glob($migrationsPath . '/*.sql');
|
||||
if ($files === false || count($files) === 0) {
|
||||
continue;
|
||||
}
|
||||
sort($files); // alphabetical order
|
||||
|
||||
// Get already-applied filenames
|
||||
$applied = [];
|
||||
$rows = DB::select('SELECT filename FROM module_migrations WHERE module_id = ?', [$manifest->id]);
|
||||
foreach ($rows as $row) {
|
||||
$applied[$row['filename']] = true;
|
||||
}
|
||||
|
||||
foreach ($files as $file) {
|
||||
$filename = basename($file);
|
||||
if (isset($applied[$filename])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$sql = file_get_contents($file);
|
||||
if ($sql === false || trim($sql) === '') {
|
||||
fwrite(STDERR, sprintf("module-migrate: WARNING skipping empty file %s/%s\n", $manifest->id, $filename));
|
||||
continue;
|
||||
}
|
||||
|
||||
fwrite(STDOUT, sprintf("module-migrate: applying %s/%s ... ", $manifest->id, $filename));
|
||||
|
||||
// Execute migration inside a transaction so partial failures don't leave the DB inconsistent
|
||||
DB::query('START TRANSACTION');
|
||||
try {
|
||||
$statements = array_filter(array_map('trim', explode(';', $sql)), static fn (string $s): bool => $s !== '');
|
||||
foreach ($statements as $statement) {
|
||||
DB::query($statement);
|
||||
}
|
||||
|
||||
// Record as applied
|
||||
DB::query(
|
||||
'INSERT INTO module_migrations (module_id, filename) VALUES (?, ?)',
|
||||
[$manifest->id, $filename]
|
||||
);
|
||||
DB::query('COMMIT');
|
||||
} catch (\Throwable $migrationError) {
|
||||
DB::query('ROLLBACK');
|
||||
throw new \RuntimeException(
|
||||
sprintf("Migration %s/%s failed: %s", $manifest->id, $filename, $migrationError->getMessage()),
|
||||
0,
|
||||
$migrationError
|
||||
);
|
||||
}
|
||||
|
||||
fwrite(STDOUT, "OK\n");
|
||||
$totalApplied++;
|
||||
}
|
||||
}
|
||||
|
||||
if (!$hasModules) {
|
||||
// Already reported above.
|
||||
} elseif ($totalApplied === 0) {
|
||||
fwrite(STDOUT, "module-migrate: all modules up to date, 0 new migrations.\n");
|
||||
} else {
|
||||
fwrite(STDOUT, sprintf("module-migrate: applied %d migration(s).\n", $totalApplied));
|
||||
}
|
||||
} catch (Throwable $exception) {
|
||||
fwrite(STDERR, sprintf("module-migrate: FAILED: %s\n", $exception->getMessage()));
|
||||
$exitCode = 1;
|
||||
}
|
||||
|
||||
if ($totalApplied === 0) {
|
||||
fwrite(STDOUT, "module-migrate: all modules up to date, 0 new migrations.\n");
|
||||
} else {
|
||||
fwrite(STDOUT, sprintf("module-migrate: applied %d migration(s).\n", $totalApplied));
|
||||
}
|
||||
} catch (Throwable $exception) {
|
||||
fwrite(STDERR, sprintf("module-migrate: FAILED: %s\n", $exception->getMessage()));
|
||||
$exitCode = 1;
|
||||
} finally {
|
||||
DB::close();
|
||||
$vendorWarningsIgnored = moduleCliVendorWarningsIgnoredSince($warningsBefore);
|
||||
fwrite(STDOUT, sprintf("module-migrate: vendor_warnings_ignored=%d\n", $vendorWarningsIgnored));
|
||||
|
||||
return $exitCode;
|
||||
}
|
||||
|
||||
exit($exitCode);
|
||||
if (PHP_SAPI === 'cli' && realpath((string) ($_SERVER['SCRIPT_FILENAME'] ?? '')) === __FILE__) {
|
||||
exit(moduleMigrateRun());
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user