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:
80
bin/module-assets-sync.php
Normal file
80
bin/module-assets-sync.php
Normal file
@@ -0,0 +1,80 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Publishes enabled module web assets into web/modules/.
|
||||
*
|
||||
* Usage: php bin/module-assets-sync.php
|
||||
*
|
||||
* APP_MODULE_ASSET_MODE:
|
||||
* - symlink (default): create web/modules/<module-id> symlinks to modules/<id>/web
|
||||
* - copy: copy module web directories (for environments without symlink support)
|
||||
*
|
||||
* Exit codes:
|
||||
* 0 — sync successful
|
||||
* 1 — error during sync
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use MintyPHP\App\Module\ModuleRegistry;
|
||||
use MintyPHP\App\Module\ModuleRuntimeAssetPublisher;
|
||||
|
||||
require_once __DIR__ . '/module-cli-bootstrap.php';
|
||||
|
||||
function moduleAssetsSyncRun(): int
|
||||
{
|
||||
$warningsBefore = moduleCliVendorWarningsIgnoredTotal();
|
||||
moduleCliBootstrapApp();
|
||||
|
||||
$exitCode = 0;
|
||||
$runtimeModulesDir = __DIR__ . '/../web/modules';
|
||||
$lockFile = __DIR__ . '/../storage/runtime/.module-assets-sync.lock';
|
||||
$mode = (string) getenv('APP_MODULE_ASSET_MODE');
|
||||
if (trim($mode) === '') {
|
||||
$mode = 'symlink';
|
||||
}
|
||||
|
||||
try {
|
||||
$lockDir = dirname($lockFile);
|
||||
if (!is_dir($lockDir) && !mkdir($lockDir, 0775, true) && !is_dir($lockDir)) {
|
||||
throw new RuntimeException("Failed to create lock directory: {$lockDir}");
|
||||
}
|
||||
|
||||
$lock = fopen($lockFile, 'c');
|
||||
if ($lock === false || !flock($lock, LOCK_EX | LOCK_NB)) {
|
||||
fwrite(STDERR, "module-assets-sync: another sync is in progress, skipping.\n");
|
||||
return 0;
|
||||
}
|
||||
|
||||
$registry = app(ModuleRegistry::class);
|
||||
$publisher = new ModuleRuntimeAssetPublisher();
|
||||
$result = $publisher->publish($runtimeModulesDir, $registry->getModules(), $mode);
|
||||
|
||||
fwrite(STDOUT, sprintf(
|
||||
"module-assets-sync: mode=%s created=%d updated=%d removed=%d unchanged=%d\n",
|
||||
$mode,
|
||||
$result['created'],
|
||||
$result['updated'],
|
||||
$result['removed'],
|
||||
$result['unchanged']
|
||||
));
|
||||
} catch (Throwable $exception) {
|
||||
fwrite(STDERR, sprintf("module-assets-sync: FAILED: %s\n", $exception->getMessage()));
|
||||
$exitCode = 1;
|
||||
} finally {
|
||||
if (isset($lock) && is_resource($lock)) {
|
||||
flock($lock, LOCK_UN);
|
||||
fclose($lock);
|
||||
}
|
||||
}
|
||||
|
||||
$vendorWarningsIgnored = moduleCliVendorWarningsIgnoredSince($warningsBefore);
|
||||
fwrite(STDOUT, sprintf("module-assets-sync: vendor_warnings_ignored=%d\n", $vendorWarningsIgnored));
|
||||
|
||||
return $exitCode;
|
||||
}
|
||||
|
||||
if (PHP_SAPI === 'cli' && realpath((string) ($_SERVER['SCRIPT_FILENAME'] ?? '')) === __FILE__) {
|
||||
exit(moduleAssetsSyncRun());
|
||||
}
|
||||
@@ -18,159 +18,64 @@
|
||||
|
||||
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\App\Module\ModuleRuntimePageBuilder;
|
||||
|
||||
$exitCode = 0;
|
||||
$runtimeDir = __DIR__ . '/../storage/runtime/pages';
|
||||
$lockFile = __DIR__ . '/../storage/runtime/.module-build.lock';
|
||||
require_once __DIR__ . '/module-cli-bootstrap.php';
|
||||
|
||||
try {
|
||||
// Ensure runtime directory exists
|
||||
$runtimeParent = dirname($runtimeDir);
|
||||
if (!is_dir($runtimeParent)) {
|
||||
mkdir($runtimeParent, 0775, true);
|
||||
}
|
||||
|
||||
// Lock to prevent concurrent builds
|
||||
$lock = fopen($lockFile, 'c');
|
||||
if ($lock === false || !flock($lock, LOCK_EX | LOCK_NB)) {
|
||||
fwrite(STDERR, "module-build: another build is in progress, skipping.\n");
|
||||
exit(0);
|
||||
}
|
||||
|
||||
$registry = app(ModuleRegistry::class);
|
||||
$modules = $registry->getModules();
|
||||
|
||||
$corePages = realpath(__DIR__ . '/../pages');
|
||||
if ($corePages === false) {
|
||||
throw new RuntimeException('Core pages/ directory not found.');
|
||||
}
|
||||
|
||||
// If no modules, ensure runtime dir points to core pages
|
||||
if (count($modules) === 0) {
|
||||
// Remove existing runtime dir if it's a real directory (not a symlink)
|
||||
if (is_dir($runtimeDir) && !is_link($runtimeDir)) {
|
||||
removeDirectoryContents($runtimeDir);
|
||||
rmdir($runtimeDir);
|
||||
}
|
||||
// Symlink runtime → core pages
|
||||
if (is_link($runtimeDir)) {
|
||||
unlink($runtimeDir);
|
||||
}
|
||||
symlink($corePages, $runtimeDir);
|
||||
fwrite(STDOUT, "module-build: no modules, runtime → core pages (symlink).\n");
|
||||
exit(0);
|
||||
}
|
||||
|
||||
// With modules: build merged directory
|
||||
if (is_link($runtimeDir)) {
|
||||
unlink($runtimeDir);
|
||||
}
|
||||
if (!is_dir($runtimeDir)) {
|
||||
mkdir($runtimeDir, 0775, true);
|
||||
}
|
||||
|
||||
// Clean existing symlinks in runtime dir
|
||||
removeDirectoryContents($runtimeDir);
|
||||
|
||||
// Symlink all core page directories/files
|
||||
$coreEntries = scandir($corePages);
|
||||
if ($coreEntries === false) {
|
||||
throw new RuntimeException('Cannot scan core pages/ directory.');
|
||||
}
|
||||
foreach ($coreEntries as $entry) {
|
||||
if ($entry === '.' || $entry === '..') {
|
||||
continue;
|
||||
}
|
||||
$source = $corePages . '/' . $entry;
|
||||
$target = $runtimeDir . '/' . $entry;
|
||||
symlink($source, $target);
|
||||
}
|
||||
|
||||
// Add module page symlinks (fail-fast on collision)
|
||||
$modulePageCount = 0;
|
||||
foreach ($modules as $manifest) {
|
||||
$modulePagesDir = $manifest->basePath . '/pages';
|
||||
if (!is_dir($modulePagesDir)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$entries = scandir($modulePagesDir);
|
||||
if ($entries === false) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($entries as $entry) {
|
||||
if ($entry === '.' || $entry === '..') {
|
||||
continue;
|
||||
}
|
||||
$source = $modulePagesDir . '/' . $entry;
|
||||
$target = $runtimeDir . '/' . $entry;
|
||||
|
||||
if (file_exists($target) || is_link($target)) {
|
||||
throw new RuntimeException(
|
||||
"Page path collision: '{$entry}' from module '{$manifest->id}' conflicts with existing page."
|
||||
);
|
||||
}
|
||||
|
||||
symlink($source, $target);
|
||||
$modulePageCount++;
|
||||
}
|
||||
}
|
||||
|
||||
fwrite(STDOUT, sprintf(
|
||||
"module-build: runtime pages built — %d core entries + %d module entries.\n",
|
||||
count($coreEntries) - 2, // minus . and ..
|
||||
$modulePageCount
|
||||
));
|
||||
} catch (Throwable $exception) {
|
||||
fwrite(STDERR, sprintf("module-build: FAILED: %s\n", $exception->getMessage()));
|
||||
$exitCode = 1;
|
||||
} finally {
|
||||
if (isset($lock) && is_resource($lock)) {
|
||||
flock($lock, LOCK_UN);
|
||||
fclose($lock);
|
||||
}
|
||||
}
|
||||
|
||||
exit($exitCode);
|
||||
|
||||
/**
|
||||
* Remove all files and symlinks in a directory (non-recursive, top-level only).
|
||||
*/
|
||||
function removeDirectoryContents(string $dir): void
|
||||
function moduleBuildRun(): int
|
||||
{
|
||||
$entries = scandir($dir);
|
||||
if ($entries === false) {
|
||||
return;
|
||||
}
|
||||
foreach ($entries as $entry) {
|
||||
if ($entry === '.' || $entry === '..') {
|
||||
continue;
|
||||
$warningsBefore = moduleCliVendorWarningsIgnoredTotal();
|
||||
moduleCliBootstrapApp();
|
||||
|
||||
$exitCode = 0;
|
||||
$runtimeDir = __DIR__ . '/../storage/runtime/pages';
|
||||
$lockFile = __DIR__ . '/../storage/runtime/.module-build.lock';
|
||||
|
||||
try {
|
||||
// Ensure runtime directory exists
|
||||
$runtimeParent = dirname($runtimeDir);
|
||||
if (!is_dir($runtimeParent) && !mkdir($runtimeParent, 0775, true) && !is_dir($runtimeParent)) {
|
||||
throw new \RuntimeException("Failed to create runtime directory: {$runtimeParent}");
|
||||
}
|
||||
$path = $dir . '/' . $entry;
|
||||
if (is_link($path)) {
|
||||
unlink($path);
|
||||
} elseif (is_dir($path)) {
|
||||
// Recursively remove directories that were created (shouldn't happen in normal flow)
|
||||
$items = new RecursiveIteratorIterator(
|
||||
new RecursiveDirectoryIterator($path, FilesystemIterator::SKIP_DOTS),
|
||||
RecursiveIteratorIterator::CHILD_FIRST
|
||||
);
|
||||
foreach ($items as $item) {
|
||||
$item->isDir() ? rmdir($item->getPathname()) : unlink($item->getPathname());
|
||||
}
|
||||
rmdir($path);
|
||||
|
||||
// Lock to prevent concurrent builds
|
||||
$lock = fopen($lockFile, 'c');
|
||||
if ($lock === false || !flock($lock, LOCK_EX | LOCK_NB)) {
|
||||
fwrite(STDERR, "module-build: another build is in progress, skipping.\n");
|
||||
return 0;
|
||||
}
|
||||
|
||||
$registry = app(ModuleRegistry::class);
|
||||
$modules = $registry->getModules();
|
||||
$builder = new ModuleRuntimePageBuilder();
|
||||
$result = $builder->build($runtimeDir, __DIR__ . '/../pages', $modules);
|
||||
|
||||
if (count($modules) === 0) {
|
||||
fwrite(STDOUT, "module-build: no modules, runtime → core pages (symlink).\n");
|
||||
} else {
|
||||
unlink($path);
|
||||
fwrite(STDOUT, sprintf(
|
||||
"module-build: runtime pages built — %d core entries + %d module entries.\n",
|
||||
(int) ($result['core_entries'] ?? 0),
|
||||
(int) ($result['module_entries'] ?? 0)
|
||||
));
|
||||
}
|
||||
} catch (Throwable $exception) {
|
||||
fwrite(STDERR, sprintf("module-build: FAILED: %s\n", $exception->getMessage()));
|
||||
$exitCode = 1;
|
||||
} finally {
|
||||
if (isset($lock) && is_resource($lock)) {
|
||||
flock($lock, LOCK_UN);
|
||||
fclose($lock);
|
||||
}
|
||||
}
|
||||
|
||||
$vendorWarningsIgnored = moduleCliVendorWarningsIgnoredSince($warningsBefore);
|
||||
fwrite(STDOUT, sprintf("module-build: vendor_warnings_ignored=%d\n", $vendorWarningsIgnored));
|
||||
|
||||
return $exitCode;
|
||||
}
|
||||
|
||||
if (PHP_SAPI === 'cli' && realpath((string) ($_SERVER['SCRIPT_FILENAME'] ?? '')) === __FILE__) {
|
||||
exit(moduleBuildRun());
|
||||
}
|
||||
|
||||
116
bin/module-cli-bootstrap.php
Normal file
116
bin/module-cli-bootstrap.php
Normal file
@@ -0,0 +1,116 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Shared CLI bootstrap and error policy for module runtime scripts.
|
||||
*
|
||||
* Policy:
|
||||
* - first-party warnings/notices/deprecations => fail-fast (RuntimeException)
|
||||
* - vendor warnings/notices/deprecations => ignored and counted
|
||||
*/
|
||||
|
||||
function moduleCliProjectRoot(): string
|
||||
{
|
||||
return dirname(__DIR__);
|
||||
}
|
||||
|
||||
function moduleCliVendorWarningsIgnoredTotal(): int
|
||||
{
|
||||
$count = $GLOBALS['module_cli_vendor_warnings_ignored'] ?? 0;
|
||||
return is_int($count) ? $count : 0;
|
||||
}
|
||||
|
||||
function moduleCliVendorWarningsIgnoredSince(int $before): int
|
||||
{
|
||||
return max(0, moduleCliVendorWarningsIgnoredTotal() - $before);
|
||||
}
|
||||
|
||||
function moduleCliIncrementVendorWarningsIgnored(): void
|
||||
{
|
||||
$current = moduleCliVendorWarningsIgnoredTotal();
|
||||
$GLOBALS['module_cli_vendor_warnings_ignored'] = $current + 1;
|
||||
}
|
||||
|
||||
function moduleCliSeverityName(int $severity): string
|
||||
{
|
||||
return match ($severity) {
|
||||
E_WARNING => 'E_WARNING',
|
||||
E_NOTICE => 'E_NOTICE',
|
||||
E_USER_WARNING => 'E_USER_WARNING',
|
||||
E_USER_NOTICE => 'E_USER_NOTICE',
|
||||
E_DEPRECATED => 'E_DEPRECATED',
|
||||
E_USER_DEPRECATED => 'E_USER_DEPRECATED',
|
||||
default => 'E_' . $severity,
|
||||
};
|
||||
}
|
||||
|
||||
function moduleCliInstallErrorPolicy(): void
|
||||
{
|
||||
static $installed = false;
|
||||
if ($installed) {
|
||||
return;
|
||||
}
|
||||
$installed = true;
|
||||
|
||||
error_reporting(E_ALL);
|
||||
ini_set('display_errors', '0');
|
||||
|
||||
set_error_handler(static function (
|
||||
int $severity,
|
||||
string $message,
|
||||
string $file = '',
|
||||
int $line = 0
|
||||
): bool {
|
||||
$handledSeverities = [
|
||||
E_WARNING,
|
||||
E_NOTICE,
|
||||
E_USER_WARNING,
|
||||
E_USER_NOTICE,
|
||||
E_DEPRECATED,
|
||||
E_USER_DEPRECATED,
|
||||
];
|
||||
if (!in_array($severity, $handledSeverities, true)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ((error_reporting() & $severity) === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$normalizedFile = str_replace('\\', '/', $file);
|
||||
if (str_contains($normalizedFile, '/vendor/')) {
|
||||
moduleCliIncrementVendorWarningsIgnored();
|
||||
return true;
|
||||
}
|
||||
|
||||
throw new RuntimeException(sprintf(
|
||||
"First-party runtime warning (%s) in %s:%d: %s",
|
||||
moduleCliSeverityName($severity),
|
||||
$file,
|
||||
$line,
|
||||
$message
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
function moduleCliBootstrapApp(): void
|
||||
{
|
||||
static $booted = false;
|
||||
if ($booted) {
|
||||
return;
|
||||
}
|
||||
|
||||
moduleCliInstallErrorPolicy();
|
||||
|
||||
$projectRoot = moduleCliProjectRoot();
|
||||
chdir($projectRoot);
|
||||
|
||||
require_once $projectRoot . '/vendor/autoload.php';
|
||||
require_once $projectRoot . '/config/config.php';
|
||||
require_once $projectRoot . '/lib/Support/helpers.php';
|
||||
$container = require $projectRoot . '/lib/App/registerContainer.php';
|
||||
setAppContainer($container);
|
||||
|
||||
$booted = true;
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
55
bin/module-permissions-sync.php
Normal file
55
bin/module-permissions-sync.php
Normal file
@@ -0,0 +1,55 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Synchronizes module-declared permissions into the permissions table.
|
||||
*
|
||||
* Usage: php bin/module-permissions-sync.php
|
||||
*
|
||||
* Exit codes:
|
||||
* 0 — sync successful
|
||||
* 1 — error during sync
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use MintyPHP\App\Module\ModulePermissionSynchronizer;
|
||||
use MintyPHP\App\Module\ModuleRegistry;
|
||||
use MintyPHP\Repository\Access\PermissionRepository;
|
||||
|
||||
require_once __DIR__ . '/module-cli-bootstrap.php';
|
||||
|
||||
function modulePermissionsSyncRun(): int
|
||||
{
|
||||
$warningsBefore = moduleCliVendorWarningsIgnoredTotal();
|
||||
moduleCliBootstrapApp();
|
||||
|
||||
$exitCode = 0;
|
||||
try {
|
||||
$synchronizer = new ModulePermissionSynchronizer(
|
||||
app(ModuleRegistry::class),
|
||||
app(PermissionRepository::class)
|
||||
);
|
||||
$result = $synchronizer->sync();
|
||||
|
||||
fwrite(STDOUT, sprintf(
|
||||
"module-permissions-sync: created=%d updated=%d unchanged=%d total=%d\n",
|
||||
$result['created'],
|
||||
$result['updated'],
|
||||
$result['unchanged'],
|
||||
$result['total']
|
||||
));
|
||||
} catch (Throwable $exception) {
|
||||
fwrite(STDERR, sprintf("module-permissions-sync: FAILED: %s\n", $exception->getMessage()));
|
||||
$exitCode = 1;
|
||||
}
|
||||
|
||||
$vendorWarningsIgnored = moduleCliVendorWarningsIgnoredSince($warningsBefore);
|
||||
fwrite(STDOUT, sprintf("module-permissions-sync: vendor_warnings_ignored=%d\n", $vendorWarningsIgnored));
|
||||
|
||||
return $exitCode;
|
||||
}
|
||||
|
||||
if (PHP_SAPI === 'cli' && realpath((string) ($_SERVER['SCRIPT_FILENAME'] ?? '')) === __FILE__) {
|
||||
exit(modulePermissionsSyncRun());
|
||||
}
|
||||
67
bin/module-runtime-sync.php
Executable file
67
bin/module-runtime-sync.php
Executable file
@@ -0,0 +1,67 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/module-cli-bootstrap.php';
|
||||
|
||||
require_once __DIR__ . '/module-migrate.php';
|
||||
require_once __DIR__ . '/module-permissions-sync.php';
|
||||
require_once __DIR__ . '/module-build.php';
|
||||
require_once __DIR__ . '/module-assets-sync.php';
|
||||
|
||||
function moduleRuntimeSyncRun(): int
|
||||
{
|
||||
moduleCliBootstrapApp();
|
||||
$warningsBeforeAll = moduleCliVendorWarningsIgnoredTotal();
|
||||
|
||||
$steps = [
|
||||
'migrate' => static fn (): int => moduleMigrateRun(),
|
||||
'permissions-sync' => static fn (): int => modulePermissionsSyncRun(),
|
||||
'build' => static fn (): int => moduleBuildRun(),
|
||||
'assets-sync' => static fn (): int => moduleAssetsSyncRun(),
|
||||
];
|
||||
|
||||
$statusByStep = [];
|
||||
foreach (array_keys($steps) as $stepName) {
|
||||
$statusByStep[$stepName] = 'pending';
|
||||
}
|
||||
|
||||
$finalExitCode = 0;
|
||||
foreach ($steps as $stepName => $runner) {
|
||||
$stepWarningsBefore = moduleCliVendorWarningsIgnoredTotal();
|
||||
$stepExitCode = $runner();
|
||||
$stepWarnings = moduleCliVendorWarningsIgnoredSince($stepWarningsBefore);
|
||||
$stepStatus = $stepExitCode === 0 ? 'ok' : 'failed';
|
||||
$statusByStep[$stepName] = $stepStatus;
|
||||
|
||||
fwrite(STDOUT, sprintf(
|
||||
"module-runtime-sync: step=%s status=%s exit_code=%d vendor_warnings_ignored=%d\n",
|
||||
$stepName,
|
||||
$stepStatus,
|
||||
$stepExitCode,
|
||||
$stepWarnings
|
||||
));
|
||||
|
||||
if ($stepExitCode !== 0) {
|
||||
$finalExitCode = $stepExitCode;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
$totalVendorWarnings = moduleCliVendorWarningsIgnoredSince($warningsBeforeAll);
|
||||
fwrite(STDOUT, sprintf(
|
||||
"module-runtime-sync: summary migrate=%s permissions-sync=%s build=%s assets-sync=%s vendor_warnings_ignored=%d\n",
|
||||
$statusByStep['migrate'],
|
||||
$statusByStep['permissions-sync'],
|
||||
$statusByStep['build'],
|
||||
$statusByStep['assets-sync'],
|
||||
$totalVendorWarnings
|
||||
));
|
||||
|
||||
return $finalExitCode;
|
||||
}
|
||||
|
||||
if (PHP_SAPI === 'cli' && realpath((string) ($_SERVER['SCRIPT_FILENAME'] ?? '')) === __FILE__) {
|
||||
exit(moduleRuntimeSyncRun());
|
||||
}
|
||||
Reference in New Issue
Block a user