diff --git a/src/helper/bridge.php b/src/helper/bridge.php deleted file mode 100644 index 34f14fe..0000000 --- a/src/helper/bridge.php +++ /dev/null @@ -1,256 +0,0 @@ - - * - * This file is part of a Moko Consulting project. - * - * SPDX-License-Identifier: GPL-3.0-or-later - */ - -/** - * Bridge migration helper — MokoCassiopeia → MokoOnyx - * - * Renames template files/folders and updates the database to migrate - * from MokoCassiopeia to MokoOnyx. No external downloads required. - */ - -defined('_JEXEC') or die; - -use Joomla\CMS\Factory; -use Joomla\CMS\Log\Log; - -class MokoBridgeMigration -{ - private const OLD_NAME = 'mokocassiopeia'; - private const NEW_NAME = 'mokoonyx'; - - private const OLD_DISPLAY = 'MokoCassiopeia'; - private const NEW_DISPLAY = 'MokoOnyx'; - - /** - * Run the full migration. - */ - public static function run(): bool - { - $app = Factory::getApplication(); - - // Already migrated? - if (is_dir(JPATH_ROOT . '/templates/' . self::NEW_NAME)) { - self::log('MokoOnyx template dir already exists — updating database only.'); - self::updateDatabase(); - self::notifyUser($app); - return true; - } - - // 1. Rename template directory - $renamed = self::renameTemplateDir(); - if (!$renamed) { - $app->enqueueMessage( - 'MokoOnyx migration: could not rename template directory. ' - . 'Please rename templates/mokocassiopeia to templates/mokoonyx manually.', - 'warning' - ); - return false; - } - - // 2. Rename media directory - self::renameMediaDir(); - - // 3. Update database (extensions, template_styles, menu assignments) - self::updateDatabase(); - - // 4. Notify admin - self::notifyUser($app); - - self::log('Bridge migration completed successfully.'); - return true; - } - - /** - * Rename templates/mokocassiopeia → templates/mokoonyx - */ - private static function renameTemplateDir(): bool - { - $oldDir = JPATH_ROOT . '/templates/' . self::OLD_NAME; - $newDir = JPATH_ROOT . '/templates/' . self::NEW_NAME; - - if (!is_dir($oldDir)) { - self::log('Bridge: old template dir not found: ' . $oldDir, 'warning'); - 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; - } - - /** - * Rename media/templates/site/mokocassiopeia → mokoonyx - */ - private static function renameMediaDir(): void - { - $oldMedia = JPATH_ROOT . '/media/templates/site/' . self::OLD_NAME; - $newMedia = JPATH_ROOT . '/media/templates/site/' . self::NEW_NAME; - - if (!is_dir($oldMedia)) { - self::log('Bridge: old media dir not found — skipping'); - return; - } - - if (is_dir($newMedia)) { - self::log('Bridge: new media dir already exists — skipping rename'); - return; - } - - if (@rename($oldMedia, $newMedia)) { - self::log('Bridge: renamed media dir to ' . self::NEW_NAME); - } else { - self::log('Bridge: failed to rename media dir', 'warning'); - } - } - - /** - * Update all database references from mokocassiopeia → mokoonyx. - */ - private static function updateDatabase(): void - { - $db = Factory::getDbo(); - - // 1. Update #__extensions — change element and name - $query = $db->getQuery(true) - ->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'); - } - } - - if (!empty($styles)) { - self::log('Bridge: updated ' . count($styles) . ' template style(s) in #__template_styles'); - } - - // 3. Update #__menu — fix template_style_id link field references - // 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 - { - $app->enqueueMessage( - 'MokoCassiopeia has been renamed to MokoOnyx.
' - . 'Your template files, settings, and menu assignments have been migrated automatically. ' - . 'MokoOnyx is now your active site template.', - 'success' - ); - } - - private static function log(string $message, string $priority = 'info'): void - { - $priorities = [ - 'info' => Log::INFO, - 'warning' => Log::WARNING, - 'error' => Log::ERROR, - ]; - - Log::addLogger( - ['text_file' => 'mokocassiopeia_bridge.log.php'], - Log::ALL, - ['mokocassiopeia_bridge'] - ); - - Log::add($message, $priorities[$priority] ?? Log::INFO, 'mokocassiopeia_bridge'); - } -} diff --git a/src/script.php b/src/script.php index 92e2333..b379812 100644 --- a/src/script.php +++ b/src/script.php @@ -1,6 +1,6 @@ + * Copyright (C) 2026 Moko Consulting * * This file is part of a Moko Consulting project. * @@ -8,12 +8,11 @@ */ /** - * Template install/update/uninstall script. - * Joomla calls the methods in this class automatically during template - * install, update, and uninstall via the element in - * templateDetails.xml. - * Joomla 5 and 6 compatible — uses the InstallerScriptInterface when - * available, falls back to the legacy class-based approach otherwise. + * MokoCassiopeia install/update/uninstall script. + * + * On update: renames the template to MokoOnyx (dirs + database), + * copies all style params, creates matching styles, copies user files, + * and redirects the update server. No external downloads needed. */ defined('_JEXEC') or die; @@ -24,33 +23,23 @@ use Joomla\CMS\Log\Log; class Tpl_MokocassiopeiaInstallerScript { - /** - * Minimum PHP version required by this template. - */ - private const MIN_PHP = '8.1.0'; - - /** - * Minimum Joomla version required by this template. - */ + private const MIN_PHP = '8.1.0'; private const MIN_JOOMLA = '4.4.0'; - /** - * Called before install/update/uninstall. - * - * @param string $type install, update, discover_install, or uninstall. - * @param InstallerAdapter $parent The adapter calling this method. - * - * @return bool True to proceed, false to abort. - */ + private const OLD_NAME = 'mokocassiopeia'; + private const NEW_NAME = 'mokoonyx'; + private const OLD_DISPLAY = 'MokoCassiopeia'; + private const NEW_DISPLAY = 'MokoOnyx'; + + private const ONYX_UPDATES_URL = 'https://git.mokoconsulting.tech/MokoConsulting/MokoOnyx/raw/branch/main/updates.xml'; + + // ── Joomla lifecycle methods ─────────────────────────────────────── + public function preflight(string $type, InstallerAdapter $parent): bool { if (version_compare(PHP_VERSION, self::MIN_PHP, '<')) { Factory::getApplication()->enqueueMessage( - sprintf( - 'MokoCassiopeia requires PHP %s or later. You are running PHP %s.', - self::MIN_PHP, - PHP_VERSION - ), + sprintf('MokoCassiopeia requires PHP %s+. Running %s.', self::MIN_PHP, PHP_VERSION), 'error' ); return false; @@ -58,11 +47,7 @@ class Tpl_MokocassiopeiaInstallerScript if (version_compare(JVERSION, self::MIN_JOOMLA, '<')) { Factory::getApplication()->enqueueMessage( - sprintf( - 'MokoCassiopeia requires Joomla %s or later. You are running Joomla %s.', - self::MIN_JOOMLA, - JVERSION - ), + sprintf('MokoCassiopeia requires Joomla %s+. Running %s.', self::MIN_JOOMLA, JVERSION), 'error' ); return false; @@ -71,167 +56,231 @@ class Tpl_MokocassiopeiaInstallerScript return true; } - /** - * Called after a successful install. - * - * @param InstallerAdapter $parent The adapter calling this method. - * - * @return bool - */ public function install(InstallerAdapter $parent): bool { - $this->logMessage('MokoCassiopeia template installed.'); + $this->log('MokoCassiopeia installed.'); return true; } - /** - * Called after a successful update. - * - * This is where the CSS variable sync runs — it detects variables that - * were added in the new version and injects them into the user's custom - * palette files without overwriting existing values. - * - * @param InstallerAdapter $parent The adapter calling this method. - * - * @return bool - */ public function update(InstallerAdapter $parent): bool { - $this->logMessage('MokoCassiopeia update() called — version ' . ($parent->getManifest()->version ?? 'unknown')); - - // Run CSS variable sync to inject any new variables into user's custom palettes. - $synced = $this->syncCustomVariables($parent); - - if ($synced > 0) { - Factory::getApplication()->enqueueMessage( - sprintf( - 'MokoCassiopeia: %d new CSS variable(s) were added to your custom palette files. ' - . 'Review them in your light.custom.css and/or dark.custom.css to customise the new defaults.', - $synced - ), - 'notice' - ); - } - - // Bridge migration runs in postflight() — not here — to avoid double execution + $this->log('MokoCassiopeia update() — version ' . ($parent->getManifest()->version ?? '?')); return true; } - /** - * Called after a successful uninstall. - * - * @param InstallerAdapter $parent The adapter calling this method. - * - * @return bool - */ public function uninstall(InstallerAdapter $parent): bool { - $this->logMessage('MokoCassiopeia template uninstalled.'); + $this->log('MokoCassiopeia uninstalled.'); return true; } - /** - * Called after install/update completes (regardless of type). - * - * @param string $type install, update, or discover_install. - * @param InstallerAdapter $parent The adapter calling this method. - * - * @return bool - */ public function postflight(string $type, InstallerAdapter $parent): bool { - // Bridge migration runs in postflight (more reliable than update() for templates) if ($type === 'update') { - $bridgeScript = $parent->getParent()->getPath('source') . '/helper/bridge.php'; - if (!is_file($bridgeScript)) { - $bridgeScript = __DIR__ . '/helper/bridge.php'; - } - if (is_file($bridgeScript)) { - require_once $bridgeScript; - if (class_exists('MokoBridgeMigration')) { - $this->logMessage('Running MokoOnyx bridge migration from postflight...'); - MokoBridgeMigration::run(); - } - } + $this->log('=== MokoCassiopeia → MokoOnyx bridge ==='); + $this->bridge(); } return true; } - /** - * Run the CSS variable sync utility. - * - * Loads sync_custom_vars.php from the template directory and calls - * MokoCssVarSync::run() to detect and inject missing variables. - * - * @param InstallerAdapter $parent The adapter calling this method. - * - * @return int Number of variables added across all files. - */ - private function syncCustomVariables(InstallerAdapter $parent): int + // ── Bridge: rename-in-place + DB migration ───────────────────────── + + private function bridge(): void { - $templateDir = $parent->getParent()->getPath('source'); + $app = Factory::getApplication(); - // The sync script lives alongside this script in the template root. - $syncScript = $templateDir . '/sync_custom_vars.php'; - - if (!is_file($syncScript)) { - $this->logMessage('CSS variable sync script not found at: ' . $syncScript, 'warning'); - return 0; - } - - require_once $syncScript; - - if (!class_exists('MokoCssVarSync')) { - $this->logMessage('MokoCssVarSync class not found after loading script.', 'warning'); - return 0; - } - - try { - $joomlaRoot = JPATH_ROOT; - $results = MokoCssVarSync::run($joomlaRoot); - - $totalAdded = 0; - foreach ($results as $filePath => $result) { - $totalAdded += count($result['added']); - if (!empty($result['added'])) { - $this->logMessage( - sprintf( - 'CSS sync: added %d variable(s) to %s', - count($result['added']), - basename($filePath) - ) - ); - } - } - - return $totalAdded; - } catch (\Throwable $e) { - $this->logMessage('CSS variable sync failed: ' . $e->getMessage(), 'error'); - return 0; - } - } - - /** - * Log a message to Joomla's log system. - * - * @param string $message The log message. - * @param string $priority Log priority (info, warning, error). - */ - private function logMessage(string $message, string $priority = 'info'): void - { - $priorities = [ - 'info' => Log::INFO, - 'warning' => Log::WARNING, - 'error' => Log::ERROR, - ]; - - Log::addLogger( - ['text_file' => 'mokocassiopeia.log.php'], - Log::ALL, - ['mokocassiopeia'] + // 1. Rename template directory + $templateRenamed = $this->renameDir( + JPATH_ROOT . '/templates/' . self::OLD_NAME, + JPATH_ROOT . '/templates/' . self::NEW_NAME, + 'template' ); - Log::add($message, $priorities[$priority] ?? Log::INFO, 'mokocassiopeia'); + if (!$templateRenamed && !is_dir(JPATH_ROOT . '/templates/' . self::NEW_NAME)) { + $app->enqueueMessage( + 'Could not rename template directory to MokoOnyx. ' + . 'Please rename templates/mokocassiopeia to templates/mokoonyx manually.', + 'warning' + ); + return; + } + + // 2. Rename media directory + $this->renameDir( + JPATH_ROOT . '/media/templates/site/' . self::OLD_NAME, + JPATH_ROOT . '/media/templates/site/' . self::NEW_NAME, + 'media' + ); + + // 3. Update #__extensions + $this->updateExtensions(); + + // 4. Migrate template styles (create matching MokoOnyx styles with same params) + $this->migrateStyles(); + + // 5. Copy user files (custom themes, user.css, user.js) + $this->copyUserFiles(); + + // 6. Redirect update server to MokoOnyx + $this->updateUpdateServer(); + + // 7. Notify + $app->enqueueMessage( + 'MokoCassiopeia has been renamed to MokoOnyx.
' + . 'All template settings, styles, and custom files have been migrated. ' + . 'MokoOnyx is now your active site template.', + 'success' + ); + + $this->log('=== Bridge completed ==='); + } + + // ── Bridge helpers ───────────────────────────────────────────────── + + private function renameDir(string $old, string $new, string $label): bool + { + if (!is_dir($old)) { + $this->log("Bridge: {$label} dir not found ({$old}) — skipping."); + return false; + } + + if (is_dir($new)) { + $this->log("Bridge: {$label} dir already exists ({$new}) — skipping rename."); + return true; + } + + if (@rename($old, $new)) { + $this->log("Bridge: renamed {$label} dir → " . self::NEW_NAME); + return true; + } + + $this->log("Bridge: failed to rename {$label} dir.", 'error'); + return false; + } + + private function updateExtensions(): void + { + $db = Factory::getDbo(); + + try { + $query = $db->getQuery(true) + ->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')); + $db->setQuery($query)->execute(); + + $n = $db->getAffectedRows(); + if ($n > 0) { + $this->log("Bridge: updated {$n} row(s) in #__extensions."); + } + } catch (\Throwable $e) { + $this->log('Bridge: #__extensions failed: ' . $e->getMessage(), 'error'); + } + } + + private function migrateStyles(): void + { + $db = Factory::getDbo(); + + // Get all MokoCassiopeia styles (may already be renamed to mokoonyx by updateExtensions) + $query = $db->getQuery(true) + ->select('*') + ->from('#__template_styles') + ->where('(' . $db->quoteName('template') . ' = ' . $db->quote(self::OLD_NAME) + . ' OR ' . $db->quoteName('template') . ' = ' . $db->quote(self::NEW_NAME) . ')') + ->where($db->quoteName('client_id') . ' = 0'); + $styles = $db->setQuery($query)->loadObjectList(); + + if (empty($styles)) { + $this->log('Bridge: no styles found to migrate.'); + return; + } + + foreach ($styles as $style) { + $newTitle = str_replace(self::OLD_DISPLAY, self::NEW_DISPLAY, $style->title); + $newTitle = str_replace(self::OLD_NAME, self::NEW_NAME, $newTitle); + $newParams = is_string($style->params) + ? str_replace(self::OLD_NAME, self::NEW_NAME, $style->params) + : $style->params; + + $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) { + $this->log('Bridge: style update failed (id=' . $style->id . '): ' . $e->getMessage(), 'warning'); + } + } + + $this->log('Bridge: migrated ' . count($styles) . ' style(s).'); + } + + private function copyUserFiles(): void + { + $media = JPATH_ROOT . '/media/templates/site/' . self::NEW_NAME; + if (!is_dir($media)) { + return; + } + + // User files are already in the renamed media dir — nothing to copy. + // They were moved with the rename. Log for clarity. + $this->log('Bridge: user files preserved via directory rename.'); + } + + private function updateUpdateServer(): void + { + $db = Factory::getDbo(); + + try { + $query = $db->getQuery(true) + ->update('#__update_sites') + ->set($db->quoteName('location') . ' = ' . $db->quote(self::ONYX_UPDATES_URL)) + ->set($db->quoteName('name') . ' = ' . $db->quote(self::NEW_DISPLAY)) + ->where($db->quoteName('location') . ' LIKE ' . $db->quote('%MokoCassiopeia%')); + $db->setQuery($query)->execute(); + + $n = $db->getAffectedRows(); + if ($n > 0) { + $this->log("Bridge: redirected {$n} update site(s) to MokoOnyx."); + } + } catch (\Throwable $e) { + $this->log('Bridge: update server redirect failed: ' . $e->getMessage(), 'warning'); + } + + // Clear cached updates for old element + try { + $query = $db->getQuery(true) + ->delete('#__updates') + ->where($db->quoteName('element') . ' = ' . $db->quote(self::OLD_NAME)); + $db->setQuery($query)->execute(); + } catch (\Throwable $e) { + // Not critical + } + } + + // ── Logging ──────────────────────────────────────────────────────── + + private function log(string $message, string $priority = 'info'): void + { + static $init = false; + if (!$init) { + Log::addLogger( + ['text_file' => 'mokocassiopeia_bridge.log.php'], + Log::ALL, + ['mokocassiopeia_bridge'] + ); + $init = true; + } + + $levels = ['info' => Log::INFO, 'warning' => Log::WARNING, 'error' => Log::ERROR]; + Log::add($message, $levels[$priority] ?? Log::INFO, 'mokocassiopeia_bridge'); } } diff --git a/updates.xml b/updates.xml index b4cc69c..54d1610 100644 --- a/updates.xml +++ b/updates.xml @@ -1,7 +1,7 @@ @@ -17,9 +17,8 @@ 2026-04-21 https://git.mokoconsulting.tech/MokoConsulting/MokoCassiopeia/releases/tag/development - https://git.mokoconsulting.tech/MokoConsulting/MokoOnyx/releases/download/development/mokoonyx-01.00.01-dev.zip + https://git.mokoconsulting.tech/MokoConsulting/MokoCassiopeia/releases/download/development/mokocassiopeia-03.10.17-dev.zip - 089563017322317f989c49f8260d6f84cf2b84235cad4584504b716b9c429e83 development Moko Consulting https://mokoconsulting.tech @@ -38,9 +37,8 @@ 2026-04-19 https://git.mokoconsulting.tech/MokoConsulting/MokoCassiopeia/releases/tag/alpha - https://git.mokoconsulting.tech/MokoConsulting/MokoOnyx/releases/download/alpha/mokoonyx-01.00.00.zip + https://git.mokoconsulting.tech/MokoConsulting/MokoCassiopeia/releases/download/alpha/mokocassiopeia-03.10.13.zip - 954c26f29af533c58658ed312b4b6261cc9e783dcf0cd9d879d34df6e8a421f4 alpha Moko Consulting https://mokoconsulting.tech @@ -59,9 +57,8 @@ 2026-04-19 https://git.mokoconsulting.tech/MokoConsulting/MokoCassiopeia/releases/tag/beta - https://git.mokoconsulting.tech/MokoConsulting/MokoOnyx/releases/download/beta/mokoonyx-01.00.00.zip + https://git.mokoconsulting.tech/MokoConsulting/MokoCassiopeia/releases/download/beta/mokocassiopeia-03.10.13.zip - 954c26f29af533c58658ed312b4b6261cc9e783dcf0cd9d879d34df6e8a421f4 beta Moko Consulting https://mokoconsulting.tech @@ -80,9 +77,8 @@ 2026-04-19 https://git.mokoconsulting.tech/MokoConsulting/MokoCassiopeia/releases/tag/release-candidate - https://git.mokoconsulting.tech/MokoConsulting/MokoOnyx/releases/download/release-candidate/mokoonyx-01.00.00.zip + https://git.mokoconsulting.tech/MokoConsulting/MokoCassiopeia/releases/download/release-candidate/mokocassiopeia-03.10.13.zip - 954c26f29af533c58658ed312b4b6261cc9e783dcf0cd9d879d34df6e8a421f4 rc Moko Consulting https://mokoconsulting.tech @@ -101,9 +97,8 @@ 2026-04-19 https://git.mokoconsulting.tech/MokoConsulting/MokoCassiopeia/releases/tag/v03 - https://git.mokoconsulting.tech/MokoConsulting/MokoOnyx/releases/download/v01/mokoonyx-01.00.00.zip + https://git.mokoconsulting.tech/MokoConsulting/MokoCassiopeia/releases/download/v03/mokocassiopeia-03.10.13.zip - 954c26f29af533c58658ed312b4b6261cc9e783dcf0cd9d879d34df6e8a421f4 stable Moko Consulting https://mokoconsulting.tech