Bridge: copy instead of rename, register as new extension
Some checks failed
Repo Health / Access control (push) Successful in 1s
Repo Health / Release configuration (push) Failing after 3s
Repo Health / Scripts governance (push) Successful in 4s
Repo Health / Repository health (push) Failing after 4s

rename() fails because Joomla's installer locks the directory during
postflight. New approach:
- Copy templates/mokocassiopeia → templates/mokoonyx (recursive)
- Copy media dir the same way
- Register MokoOnyx as a new extension in #__extensions
- Create matching MokoOnyx styles with copied params
- Set MokoOnyx as default, redirect update server
- Old mokocassiopeia dir stays (user uninstalls later)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jonathan Miller
2026-04-21 13:14:27 -05:00
parent 7618d44f1e
commit f879d5002d

View File

@@ -10,9 +10,10 @@
/**
* 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.
* On update: copies the template as MokoOnyx (new directory), updates the
* database to register MokoOnyx, migrates styles + params, and sets it as
* the default site template. The old MokoCassiopeia directory stays intact
* (Joomla's installer still needs it) — the user can uninstall it later.
*/
defined('_JEXEC') or die;
@@ -33,7 +34,7 @@ class Tpl_MokocassiopeiaInstallerScript
private const ONYX_UPDATES_URL = 'https://git.mokoconsulting.tech/MokoConsulting/MokoOnyx/raw/branch/main/updates.xml';
// ── Joomla lifecycle methods ───────────────────────────────────────
// ── Joomla lifecycle ───────────────────────────────────────────────
public function preflight(string $type, InstallerAdapter $parent): bool
{
@@ -84,100 +85,165 @@ class Tpl_MokocassiopeiaInstallerScript
return true;
}
// ── Bridge: rename-in-place + DB migration ─────────────────────────
// ── Bridge ─────────────────────────────────────────────────────────
private function bridge(): void
{
$app = Factory::getApplication();
// 1. Rename template directory
$templateRenamed = $this->renameDir(
JPATH_ROOT . '/templates/' . self::OLD_NAME,
JPATH_ROOT . '/templates/' . self::NEW_NAME,
'template'
);
if (!$templateRenamed && !is_dir(JPATH_ROOT . '/templates/' . self::NEW_NAME)) {
// 1. Copy template directory (don't rename — Joomla still needs the old one)
$copied = $this->copyTemplateDir();
if (!$copied && !is_dir(JPATH_ROOT . '/templates/' . self::NEW_NAME)) {
$app->enqueueMessage(
'Could not rename template directory to MokoOnyx. '
. 'Please rename <code>templates/mokocassiopeia</code> to <code>templates/mokoonyx</code> manually.',
'MokoOnyx bridge: could not create template directory. '
. 'Please copy <code>templates/mokocassiopeia</code> to <code>templates/mokoonyx</code> 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'
);
// 2. Copy media directory
$this->copyMediaDir();
// 3. Update #__extensions
$this->updateExtensions();
// 3. Register MokoOnyx in #__extensions (if not already there)
$this->registerExtension();
// 4. Migrate template styles (create matching MokoOnyx styles with same params)
// 4. Migrate template styles (create 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
// 5. Redirect update server to MokoOnyx
$this->updateUpdateServer();
// 7. Notify
// 6. Notify
$app->enqueueMessage(
'<strong>MokoCassiopeia has been renamed to MokoOnyx.</strong><br>'
. 'All template settings, styles, and custom files have been migrated. '
. 'MokoOnyx is now your active site template.',
'<strong>MokoOnyx has been installed as a replacement for MokoCassiopeia.</strong><br>'
. 'Your template settings have been migrated. MokoOnyx is now your active site template.<br>'
. 'You can safely uninstall MokoCassiopeia from Extensions &rarr; Manage.',
'success'
);
$this->log('=== Bridge completed ===');
}
// ── Bridge helpers ─────────────────────────────────────────────────
// ── Copy directories ───────────────────────────────────────────────
private function renameDir(string $old, string $new, string $label): bool
private function copyTemplateDir(): bool
{
if (!is_dir($old)) {
$this->log("Bridge: {$label} dir not found ({$old}) — skipping.");
return false;
}
$src = JPATH_ROOT . '/templates/' . self::OLD_NAME;
$dst = JPATH_ROOT . '/templates/' . self::NEW_NAME;
if (is_dir($new)) {
$this->log("Bridge: {$label} dir already exists ({$new}) — skipping rename.");
if (is_dir($dst)) {
$this->log('Bridge: templates/' . self::NEW_NAME . ' already exists — skipping copy.');
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');
if (!is_dir($src)) {
$this->log('Bridge: source template dir not found.', 'error');
return false;
}
private function updateExtensions(): void
$result = $this->recursiveCopy($src, $dst);
$this->log('Bridge: ' . ($result ? 'copied' : 'FAILED to copy') . ' template dir → ' . self::NEW_NAME);
return $result;
}
private function copyMediaDir(): void
{
$src = JPATH_ROOT . '/media/templates/site/' . self::OLD_NAME;
$dst = JPATH_ROOT . '/media/templates/site/' . self::NEW_NAME;
if (is_dir($dst)) {
$this->log('Bridge: media dir already exists — skipping.');
return;
}
if (!is_dir($src)) {
$this->log('Bridge: source media dir not found — skipping.');
return;
}
$result = $this->recursiveCopy($src, $dst);
$this->log('Bridge: ' . ($result ? 'copied' : 'FAILED to copy') . ' media dir → ' . self::NEW_NAME);
}
private function recursiveCopy(string $src, string $dst): bool
{
if (!mkdir($dst, 0755, true) && !is_dir($dst)) {
return false;
}
$dir = opendir($src);
if ($dir === false) {
return false;
}
while (($file = readdir($dir)) !== false) {
if ($file === '.' || $file === '..') {
continue;
}
$srcPath = $src . '/' . $file;
$dstPath = $dst . '/' . $file;
if (is_dir($srcPath)) {
$this->recursiveCopy($srcPath, $dstPath);
} else {
copy($srcPath, $dstPath);
}
}
closedir($dir);
return true;
}
// ── Database updates ───────────────────────────────────────────────
private function registerExtension(): void
{
$db = Factory::getDbo();
try {
// Check if MokoOnyx is already registered
$query = $db->getQuery(true)
->update('#__extensions')
->set($db->quoteName('element') . ' = ' . $db->quote(self::NEW_NAME))
->set($db->quoteName('name') . ' = ' . $db->quote(self::NEW_NAME))
->select('extension_id')
->from('#__extensions')
->where($db->quoteName('element') . ' = ' . $db->quote(self::NEW_NAME))
->where($db->quoteName('type') . ' = ' . $db->quote('template'));
$exists = (int) $db->setQuery($query)->loadResult();
if ($exists) {
$this->log('Bridge: MokoOnyx already registered in #__extensions (id=' . $exists . ').');
return;
}
// Copy the MokoCassiopeia extension row and change element/name
$query = $db->getQuery(true)
->select('*')
->from('#__extensions')
->where($db->quoteName('element') . ' = ' . $db->quote(self::OLD_NAME))
->where($db->quoteName('type') . ' = ' . $db->quote('template'));
$db->setQuery($query)->execute();
$oldExt = $db->setQuery($query)->loadObject();
$n = $db->getAffectedRows();
if ($n > 0) {
$this->log("Bridge: updated {$n} row(s) in #__extensions.");
if (!$oldExt) {
$this->log('Bridge: MokoCassiopeia not found in #__extensions.', 'warning');
return;
}
$newExt = clone $oldExt;
unset($newExt->extension_id);
$newExt->element = self::NEW_NAME;
$newExt->name = self::NEW_NAME;
// Update manifest_cache to reflect new name
if (is_string($newExt->manifest_cache)) {
$newExt->manifest_cache = str_replace(self::OLD_NAME, self::NEW_NAME, $newExt->manifest_cache);
$newExt->manifest_cache = str_replace(self::OLD_DISPLAY, self::NEW_DISPLAY, $newExt->manifest_cache);
}
try {
$db->insertObject('#__extensions', $newExt, 'extension_id');
$this->log('Bridge: registered MokoOnyx in #__extensions (id=' . $newExt->extension_id . ').');
} catch (\Throwable $e) {
$this->log('Bridge: #__extensions failed: ' . $e->getMessage(), 'error');
$this->log('Bridge: failed to register extension: ' . $e->getMessage(), 'error');
}
}
@@ -185,54 +251,75 @@ class Tpl_MokocassiopeiaInstallerScript
{
$db = Factory::getDbo();
// Get all MokoCassiopeia styles (may already be renamed to mokoonyx by updateExtensions)
// Get all MokoCassiopeia styles
$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('template') . ' = ' . $db->quote(self::OLD_NAME))
->where($db->quoteName('client_id') . ' = 0');
$styles = $db->setQuery($query)->loadObjectList();
$oldStyles = $db->setQuery($query)->loadObjectList();
if (empty($styles)) {
$this->log('Bridge: no styles found to migrate.');
if (empty($oldStyles)) {
$this->log('Bridge: no MokoCassiopeia styles found.');
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;
$this->log('Bridge: migrating ' . count($oldStyles) . ' style(s).');
$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);
foreach ($oldStyles as $old) {
$newTitle = str_replace(self::OLD_DISPLAY, self::NEW_DISPLAY, $old->title);
$newTitle = str_replace(self::OLD_NAME, self::NEW_NAME, $newTitle);
// Skip if MokoOnyx already has this style
$check = $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($check)->loadResult() > 0) {
$this->log("Bridge: style '{$newTitle}' already exists — skipping.");
continue;
}
$newParams = is_string($old->params)
? str_replace(self::OLD_NAME, self::NEW_NAME, $old->params)
: $old->params;
$new = clone $old;
unset($new->id);
$new->template = self::NEW_NAME;
$new->title = $newTitle;
$new->params = $newParams;
$new->home = 0;
try {
$db->setQuery($update)->execute();
$db->insertObject('#__template_styles', $new, 'id');
$newId = $new->id;
// If old was default, make new default
if ($old->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) $old->id)
)->execute();
$this->log('Bridge: set MokoOnyx as default site template.');
}
$this->log("Bridge: created style '{$newTitle}'.");
} catch (\Throwable $e) {
$this->log('Bridge: style update failed (id=' . $style->id . '): ' . $e->getMessage(), 'warning');
$this->log("Bridge: failed to create style '{$newTitle}': " . $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
@@ -255,12 +342,13 @@ class Tpl_MokocassiopeiaInstallerScript
$this->log('Bridge: update server redirect failed: ' . $e->getMessage(), 'warning');
}
// Clear cached updates for old element
// Clear cached updates
try {
$query = $db->getQuery(true)
$db->setQuery(
$db->getQuery(true)
->delete('#__updates')
->where($db->quoteName('element') . ' = ' . $db->quote(self::OLD_NAME));
$db->setQuery($query)->execute();
->where($db->quoteName('element') . ' = ' . $db->quote(self::OLD_NAME))
)->execute();
} catch (\Throwable $e) {
// Not critical
}