Bridge: rewrite as rename-in-place + DB update (no external downloads)
Some checks failed
Repo Health / Access control (push) Successful in 1s
Repo Health / Release configuration (push) Failing after 4s
Repo Health / Scripts governance (push) Successful in 3s
Repo Health / Repository health (push) Failing after 4s

- Rename templates/mokocassiopeia → mokoonyx
- Rename media/templates/site/mokocassiopeia → mokoonyx
- Update #__extensions element and name
- Update #__template_styles template, title, and params
- Update #__menu link references
- Update #__update_sites to point to MokoOnyx updates.xml
- Clear #__updates cached entries for old extension
- No HTTP requests, no ZIP downloads, no Installer conflicts

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jonathan Miller
2026-04-21 12:31:50 -05:00
parent 300ce0d61f
commit 9e257d16ed

View File

@@ -10,14 +10,13 @@
/** /**
* Bridge migration helper — MokoCassiopeia → MokoOnyx * Bridge migration helper — MokoCassiopeia → MokoOnyx
* *
* Downloads and installs MokoOnyx from the Gitea release, then migrates * Renames template files/folders and updates the database to migrate
* template styles and menu assignments from MokoCassiopeia. * from MokoCassiopeia to MokoOnyx. No external downloads required.
*/ */
defined('_JEXEC') or die; defined('_JEXEC') or die;
use Joomla\CMS\Factory; use Joomla\CMS\Factory;
use Joomla\CMS\Installer\Installer;
use Joomla\CMS\Log\Log; use Joomla\CMS\Log\Log;
class MokoBridgeMigration class MokoBridgeMigration
@@ -28,12 +27,6 @@ class MokoBridgeMigration
private const OLD_DISPLAY = 'MokoCassiopeia'; private const OLD_DISPLAY = 'MokoCassiopeia';
private const NEW_DISPLAY = 'MokoOnyx'; private const NEW_DISPLAY = 'MokoOnyx';
/** Raw URL for MokoOnyx updates.xml on main — used to discover the stable download URL */
private const UPDATES_XML_URL = 'https://git.mokoconsulting.tech/MokoConsulting/MokoOnyx/raw/branch/main/updates.xml';
/** Fallback URL if updates.xml cannot be parsed */
private const FALLBACK_URL = 'https://git.mokoconsulting.tech/MokoConsulting/MokoOnyx/releases/download/v01/mokoonyx-01.00.00.zip';
/** /**
* Run the full migration. * Run the full migration.
*/ */
@@ -41,45 +34,32 @@ class MokoBridgeMigration
{ {
$app = Factory::getApplication(); $app = Factory::getApplication();
// Check if MokoOnyx is already installed // Already migrated?
if (is_dir(JPATH_ROOT . '/templates/' . self::NEW_NAME)) { if (is_dir(JPATH_ROOT . '/templates/' . self::NEW_NAME)) {
self::log('MokoOnyx already installed — skipping download.'); self::log('MokoOnyx template dir already exists — updating database only.');
self::migrateStyles(); self::updateDatabase();
self::notifyUser($app); self::notifyUser($app);
return true; return true;
} }
// 1. Try downloading and installing MokoOnyx from Gitea release // 1. Rename template directory
$installed = false; $renamed = self::renameTemplateDir();
$zipPath = self::downloadRelease(); if (!$renamed) {
if ($zipPath) {
$installed = self::installPackage($zipPath);
@unlink($zipPath);
}
// 2. Fallback: copy from MokoCassiopeia and rename
if (!$installed) {
self::log('Bridge: download/install failed, falling back to file copy');
$installed = self::copyAndRename();
}
if (!$installed) {
$app->enqueueMessage( $app->enqueueMessage(
'MokoOnyx migration: automatic installation failed. ' 'MokoOnyx migration: could not rename template directory. '
. 'Please install MokoOnyx manually from ' . 'Please rename templates/mokocassiopeia to templates/mokoonyx manually.',
. '<a href="https://git.mokoconsulting.tech/MokoConsulting/MokoOnyx/releases" target="_blank">Gitea Releases</a>.',
'warning' 'warning'
); );
return false; return false;
} }
// 3. Copy user files (custom themes, user.css, user.js) // 2. Rename media directory
self::copyAndRename(); self::renameMediaDir();
// 4. Migrate template styles and params // 3. Update database (extensions, template_styles, menu assignments)
self::migrateStyles(); self::updateDatabase();
// 5. Notify admin // 4. Notify admin
self::notifyUser($app); self::notifyUser($app);
self::log('Bridge migration completed successfully.'); self::log('Bridge migration completed successfully.');
@@ -87,308 +67,176 @@ class MokoBridgeMigration
} }
/** /**
* Download the MokoOnyx ZIP to Joomla's tmp directory. * Rename templates/mokocassiopeia → templates/mokoonyx
*
* Reads MokoOnyx's updates.xml on main to discover the current stable
* download URL, falling back to a hardcoded URL if parsing fails.
*/ */
private static function downloadRelease(): ?string private static function renameTemplateDir(): bool
{ {
$tmpDir = Factory::getApplication()->get('tmp_path', JPATH_ROOT . '/tmp'); $oldDir = JPATH_ROOT . '/templates/' . self::OLD_NAME;
$zipPath = $tmpDir . '/mokoonyx-install.zip'; $newDir = JPATH_ROOT . '/templates/' . self::NEW_NAME;
// 1. Discover the stable download URL from MokoOnyx's updates.xml if (!is_dir($oldDir)) {
$releaseUrl = self::discoverStableUrl(); self::log('Bridge: old template dir not found: ' . $oldDir, 'warning');
if (!$releaseUrl) {
self::log('Bridge: could not discover release URL from updates.xml, using fallback');
$releaseUrl = self::FALLBACK_URL;
}
self::log('Bridge: downloading MokoOnyx from ' . $releaseUrl);
// 2. Download the ZIP
$content = self::httpGet($releaseUrl);
if ($content === false || strlen($content) < 1000) {
self::log('Bridge: failed to download MokoOnyx ZIP from ' . $releaseUrl, 'error');
return null;
}
if (file_put_contents($zipPath, $content) === false) {
self::log('Bridge: failed to write ZIP to ' . $zipPath, 'error');
return null;
}
self::log('Bridge: downloaded MokoOnyx ZIP (' . strlen($content) . ' bytes)');
return $zipPath;
}
/**
* Fetch MokoOnyx's updates.xml and extract the stable channel ZIP URL.
*
* Always targets the stable channel — the bridge should only install
* production-ready builds of MokoOnyx.
*/
private static function discoverStableUrl(): ?string
{
$xml = self::httpGet(self::UPDATES_XML_URL);
if ($xml === false || strlen($xml) < 100) {
self::log('Bridge: failed to fetch MokoOnyx updates.xml', 'warning');
return null;
}
libxml_use_internal_errors(true);
$doc = simplexml_load_string($xml);
libxml_clear_errors();
if (!$doc) {
self::log('Bridge: failed to parse MokoOnyx updates.xml', 'warning');
return null;
}
// Find the stable <update> block
foreach ($doc->update as $update) {
$tags = $update->tags->tag ?? [];
foreach ($tags as $tag) {
if ((string) $tag === 'stable') {
foreach ($update->downloads->downloadurl as $dl) {
$format = (string) ($dl['format'] ?? '');
$url = trim((string) $dl);
if ($format === 'zip' && !empty($url)) {
self::log('Bridge: discovered stable URL: ' . $url);
return $url;
}
}
}
}
}
self::log('Bridge: no stable ZIP URL found in MokoOnyx updates.xml', 'warning');
return null;
}
/**
* HTTP GET helper — tries file_get_contents then cURL.
*
* @return string|false Response body or false on failure.
*/
private static function httpGet(string $url)
{
$content = false;
if (ini_get('allow_url_fopen')) {
$ctx = stream_context_create([
'http' => [
'timeout' => 60,
'follow_location' => true,
'max_redirects' => 5,
],
'ssl' => [
'verify_peer' => true,
'verify_peer_name' => true,
],
]);
$content = @file_get_contents($url, false, $ctx);
}
if ($content === false && function_exists('curl_init')) {
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_MAXREDIRS => 5,
CURLOPT_TIMEOUT => 60,
CURLOPT_SSL_VERIFYPEER => true,
]);
$content = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($httpCode !== 200) {
$content = false;
}
}
return $content;
}
/**
* Install the downloaded ZIP via Joomla's Installer.
*/
private static function installPackage(string $zipPath): bool
{
try {
$installer = Installer::getInstance();
$tmpDir = Factory::getApplication()->get('tmp_path', JPATH_ROOT . '/tmp');
$extractDir = $tmpDir . '/mokoonyx_install_' . time();
$zip = new \ZipArchive();
if ($zip->open($zipPath) !== true) {
self::log('Bridge: failed to open ZIP', 'error');
return false;
}
$zip->extractTo($extractDir);
$zip->close();
$result = $installer->install($extractDir);
if (is_dir($extractDir)) {
self::removeDirectory($extractDir);
}
if ($result) {
self::log('Bridge: MokoOnyx installed via Joomla Installer');
} else {
self::log('Bridge: Joomla Installer returned false', 'error');
}
return (bool) $result;
} catch (\Throwable $e) {
self::log('Bridge: install failed: ' . $e->getMessage(), 'error');
return false; return false;
} }
if (is_dir($newDir)) {
self::log('Bridge: new template dir already exists — skipping rename');
return true;
}
$result = @rename($oldDir, $newDir);
if ($result) {
self::log('Bridge: renamed template dir to ' . self::NEW_NAME);
} else {
self::log('Bridge: failed to rename template dir', 'error');
}
return $result;
} }
/** /**
* Migrate template styles and menu assignments from MokoCassiopeia to MokoOnyx. * Rename media/templates/site/mokocassiopeia → mokoonyx
*/ */
private static function migrateStyles(): void private static function renameMediaDir(): void
{
$db = Factory::getDbo();
$query = $db->getQuery(true)
->select('*')
->from('#__template_styles')
->where($db->quoteName('template') . ' = ' . $db->quote(self::OLD_NAME))
->where($db->quoteName('client_id') . ' = 0');
$oldStyles = $db->setQuery($query)->loadObjectList();
if (empty($oldStyles)) {
self::log('No MokoCassiopeia styles found — nothing to migrate.');
return;
}
foreach ($oldStyles as $oldStyle) {
$newTitle = str_replace(self::OLD_DISPLAY, self::NEW_DISPLAY, $oldStyle->title);
$query = $db->getQuery(true)
->select('COUNT(*)')
->from('#__template_styles')
->where($db->quoteName('template') . ' = ' . $db->quote(self::NEW_NAME))
->where($db->quoteName('title') . ' = ' . $db->quote($newTitle));
if ((int) $db->setQuery($query)->loadResult() > 0) {
continue;
}
$newStyle = clone $oldStyle;
unset($newStyle->id);
$newStyle->template = self::NEW_NAME;
$newStyle->title = $newTitle;
if (is_string($newStyle->params)) {
$newStyle->params = str_replace(self::OLD_NAME, self::NEW_NAME, $newStyle->params);
}
$db->insertObject('#__template_styles', $newStyle, 'id');
$newId = $newStyle->id;
if ($oldStyle->home == 1) {
$db->setQuery(
$db->getQuery(true)
->update('#__template_styles')
->set($db->quoteName('home') . ' = 1')
->where('id = ' . (int) $newId)
)->execute();
$db->setQuery(
$db->getQuery(true)
->update('#__template_styles')
->set($db->quoteName('home') . ' = 0')
->where('id = ' . (int) $oldStyle->id)
)->execute();
self::log('Set MokoOnyx as default site template.');
}
}
self::log('Migrated ' . count($oldStyles) . ' template style(s).');
}
/**
* Copy user-specific files from MokoCassiopeia to MokoOnyx.
* Only copies custom themes, user.css, and user.js — not the full template.
* MokoOnyx must already be installed (via download or manual).
*/
private static function copyAndRename(): bool
{ {
$oldMedia = JPATH_ROOT . '/media/templates/site/' . self::OLD_NAME; $oldMedia = JPATH_ROOT . '/media/templates/site/' . self::OLD_NAME;
$newMedia = JPATH_ROOT . '/media/templates/site/' . self::NEW_NAME; $newMedia = JPATH_ROOT . '/media/templates/site/' . self::NEW_NAME;
if (!is_dir($newMedia)) { if (!is_dir($oldMedia)) {
self::log('Bridge: MokoOnyx media dir not found — cannot copy user files', 'warning'); self::log('Bridge: old media dir not found — skipping');
return false; return;
} }
$copied = 0; if (is_dir($newMedia)) {
self::log('Bridge: new media dir already exists — skipping rename');
return;
}
// Copy custom theme palettes if (@rename($oldMedia, $newMedia)) {
$userFiles = [ self::log('Bridge: renamed media dir to ' . self::NEW_NAME);
'css/theme/light.custom.css', } else {
'css/theme/dark.custom.css', self::log('Bridge: failed to rename media dir', 'warning');
'css/theme/light.custom.min.css', }
'css/theme/dark.custom.min.css', }
'css/user.css',
'css/user.min.css',
'js/user.js',
'js/user.min.js',
];
foreach ($userFiles as $relPath) { /**
$srcFile = $oldMedia . '/' . $relPath; * Update all database references from mokocassiopeia → mokoonyx.
$dstFile = $newMedia . '/' . $relPath; */
if (is_file($srcFile) && !is_file($dstFile)) { private static function updateDatabase(): void
$dstDir = dirname($dstFile); {
if (!is_dir($dstDir)) { $db = Factory::getDbo();
mkdir($dstDir, 0755, true);
} // 1. Update #__extensions — change element and name
copy($srcFile, $dstFile); $query = $db->getQuery(true)
$copied++; ->update('#__extensions')
->set($db->quoteName('element') . ' = ' . $db->quote(self::NEW_NAME))
->set($db->quoteName('name') . ' = ' . $db->quote(self::NEW_NAME))
->where($db->quoteName('element') . ' = ' . $db->quote(self::OLD_NAME))
->where($db->quoteName('type') . ' = ' . $db->quote('template'));
try {
$db->setQuery($query)->execute();
$affected = $db->getAffectedRows();
if ($affected > 0) {
self::log("Bridge: updated {$affected} row(s) in #__extensions");
}
} catch (\Throwable $e) {
self::log('Bridge: #__extensions update failed: ' . $e->getMessage(), 'error');
}
// 2. Update #__template_styles — rename template and title
$query = $db->getQuery(true)
->select('*')
->from('#__template_styles')
->where($db->quoteName('template') . ' = ' . $db->quote(self::OLD_NAME));
$styles = $db->setQuery($query)->loadObjectList();
foreach ($styles as $style) {
$newTitle = str_replace(self::OLD_DISPLAY, self::NEW_DISPLAY, $style->title);
// Also catch lowercase variant
$newTitle = str_replace(self::OLD_NAME, self::NEW_NAME, $newTitle);
$newParams = $style->params;
if (is_string($newParams)) {
$newParams = str_replace(self::OLD_NAME, self::NEW_NAME, $newParams);
}
$update = $db->getQuery(true)
->update('#__template_styles')
->set($db->quoteName('template') . ' = ' . $db->quote(self::NEW_NAME))
->set($db->quoteName('title') . ' = ' . $db->quote($newTitle))
->set($db->quoteName('params') . ' = ' . $db->quote($newParams))
->where('id = ' . (int) $style->id);
try {
$db->setQuery($update)->execute();
} catch (\Throwable $e) {
self::log('Bridge: style update failed for id=' . $style->id . ': ' . $e->getMessage(), 'error');
} }
} }
// Copy favicon directory if (!empty($styles)) {
$faviconSrc = JPATH_ROOT . '/images/favicons'; self::log('Bridge: updated ' . count($styles) . ' template style(s) in #__template_styles');
if (is_dir($faviconSrc)) {
self::log('Bridge: favicons already at images/favicons — shared between templates');
} }
self::log("Bridge: copied {$copied} user file(s) to MokoOnyx"); // 3. Update #__menu — fix template_style_id link field references
return true; // Menu items store the template name in the link for template-specific assignments
try {
$query = $db->getQuery(true)
->update('#__menu')
->set($db->quoteName('link') . ' = REPLACE(' . $db->quoteName('link') . ', '
. $db->quote(self::OLD_NAME) . ', ' . $db->quote(self::NEW_NAME) . ')')
->where($db->quoteName('link') . ' LIKE ' . $db->quote('%' . self::OLD_NAME . '%'));
$db->setQuery($query)->execute();
$affected = $db->getAffectedRows();
if ($affected > 0) {
self::log("Bridge: updated {$affected} menu link(s)");
}
} catch (\Throwable $e) {
self::log('Bridge: #__menu update failed: ' . $e->getMessage(), 'warning');
}
// 4. Update #__update_sites — point to MokoOnyx updates.xml
try {
$newLocation = 'https://git.mokoconsulting.tech/MokoConsulting/MokoOnyx/raw/branch/main/updates.xml';
$query = $db->getQuery(true)
->update('#__update_sites')
->set($db->quoteName('location') . ' = ' . $db->quote($newLocation))
->set($db->quoteName('name') . ' = ' . $db->quote(self::NEW_DISPLAY))
->where($db->quoteName('location') . ' LIKE ' . $db->quote('%MokoCassiopeia%'));
$db->setQuery($query)->execute();
$affected = $db->getAffectedRows();
if ($affected > 0) {
self::log("Bridge: updated {$affected} update site(s) to MokoOnyx");
}
} catch (\Throwable $e) {
self::log('Bridge: #__update_sites update failed: ' . $e->getMessage(), 'warning');
}
// 5. Update #__updates — clear cached updates for old extension
try {
$query = $db->getQuery(true)
->delete('#__updates')
->where($db->quoteName('element') . ' = ' . $db->quote(self::OLD_NAME));
$db->setQuery($query)->execute();
$affected = $db->getAffectedRows();
if ($affected > 0) {
self::log("Bridge: cleared {$affected} cached update(s) for old extension");
}
} catch (\Throwable $e) {
self::log('Bridge: #__updates cleanup failed: ' . $e->getMessage(), 'warning');
}
} }
private static function notifyUser($app): void private static function notifyUser($app): void
{ {
$app->enqueueMessage( $app->enqueueMessage(
'<strong>MokoCassiopeia has been renamed to MokoOnyx.</strong><br>' '<strong>MokoCassiopeia has been renamed to MokoOnyx.</strong><br>'
. 'Your template settings have been migrated automatically. ' . 'Your template files, settings, and menu assignments have been migrated automatically. '
. 'MokoOnyx is now your active site template. ' . 'MokoOnyx is now your active site template.',
. 'You can safely uninstall MokoCassiopeia from Extensions &rarr; Manage.',
'success' 'success'
); );
} }
private static function removeDirectory(string $dir): void
{
$items = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS),
\RecursiveIteratorIterator::CHILD_FIRST
);
foreach ($items as $item) {
$item->isDir() ? rmdir($item->getPathname()) : unlink($item->getPathname());
}
rmdir($dir);
}
private static function log(string $message, string $priority = 'info'): void private static function log(string $message, string $priority = 'info'): void
{ {
$priorities = [ $priorities = [