fix: installer postflight honest about partial/failed install
Universal: PR Check / Require Docs Update (pull_request) Has been skipped
Universal: PR Check / Wiki Update Reminder (pull_request) Has been skipped
Universal: PR Check / Branch Policy (pull_request) Successful in 2s
Universal: PR Check / Secret Scan (pull_request) Successful in 8s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 37s
Generic: Project CI / Lint & Validate (pull_request) Successful in 33s
Universal: PR Check / Validate PR (pull_request) Failing after 28s
Joomla: Metadata Validation / Validate Joomla Metadata (pull_request) Successful in 32s
Generic: Project CI / Tests (pull_request) Has been cancelled
Universal: PR Check / Build RC Package (pull_request) Has been cancelled
Universal: PR Check / Report Issues (pull_request) Has been cancelled

Joomla logs a failed child extension but still runs the package
postflight, so the 'installed successfully' message could appear on a
broken install. In postflight, before the success/next-steps messages:

- verify every bundled child declared in the package manifest actually
  registered in #__extensions (match element+type, and folder=group for
  plugins), and
- verify the component's schema tables (parsed dynamically from the
  installed install.mysql.sql) exist in the DB.

If anything is missing, enqueue an error listing it and RETURN before the
success message. Fail-open (try/catch -> nothing missing) so a transient
manifest/DB/IO glitch can't fail a good install. Unconditional
housekeeping (download-key restore, menu fixes) still runs regardless.
Names escaped with htmlspecialchars; table/child lists derived
dynamically, never hard-coded.

Claude-Session: https://claude.ai/code/session_01WbGBN9VyRK61zczYWcCQ2i
This commit is contained in:
2026-07-06 10:49:49 -05:00
parent 0d832e60e9
commit 6b4f98869a
2 changed files with 157 additions and 0 deletions
+156
View File
@@ -188,6 +188,35 @@ class Pkg_MokoSuiteBackupInstallerScript
PREPARE/EXECUTE migration that MySQL 8 rejected in Joomla's installer). */
$this->dropLegacyRemoteColumns();
/* Honesty gate: Joomla logs a failed child extension but still runs the
package postflight, so verify every bundled child actually registered
before claiming success. If any are missing, say so and do NOT print the
success / next-steps messages. (All housekeeping above runs regardless.)
Fail-open — a manifest/DB glitch returns no missing children. */
$problems = $this->findMissingPackageChildren($parent);
foreach ($this->findMissingComponentTables() as $table) {
$problems[] = 'table ' . $table;
}
if (!empty($problems)) {
$safe = array_map(
static fn($m) => htmlspecialchars($m, ENT_QUOTES, 'UTF-8'),
$problems
);
$detail = count($safe) > 3
? count($safe) . ' bundled extensions/tables are missing'
: 'missing: ' . implode(', ', $safe);
Factory::getApplication()->enqueueMessage(
'<strong>MokoSuiteBackup did not install correctly</strong> — ' . $detail
. '. Review the installation log and re-install.',
'error'
);
return;
}
/* Install completion notice (install and update) */
$this->installSuccessful();
@@ -208,6 +237,133 @@ class Pkg_MokoSuiteBackupInstallerScript
}
}
/**
* Verify every bundled child extension declared in the package manifest
* actually registered in #__extensions. Joomla logs a failed child but does
* not fail the package, so this answers "did the package really install?"
* honestly. Returns human-readable labels for any missing children (empty =
* all present). Matching mirrors extension uniqueness: element + type, and
* for plugins also folder (group), since plugins are only unique that way.
*
* Fail-open: any manifest/DB error returns [] so a transient glitch can never
* turn a good install into a false failure.
*
* @param InstallerAdapter $parent Package installer adapter
*
* @return string[] Labels of missing children (empty when everything installed)
*/
private function findMissingPackageChildren(InstallerAdapter $parent): array
{
try {
$manifest = $parent->getParent()->getManifest();
if (!$manifest instanceof \SimpleXMLElement
|| !isset($manifest->files) || !isset($manifest->files->file)) {
return [];
}
$db = Factory::getContainer()->get(\Joomla\Database\DatabaseInterface::class);
$missing = [];
foreach ($manifest->files->file as $file) {
$attrs = $file->attributes();
$element = isset($attrs['id']) ? (string) $attrs['id'] : '';
$extType = isset($attrs['type']) ? (string) $attrs['type'] : '';
if ($element === '' || $extType === '') {
continue;
}
$query = $db->getQuery(true)
->select('COUNT(*)')
->from($db->quoteName('#__extensions'))
->where($db->quoteName('element') . ' = ' . $db->quote($element))
->where($db->quoteName('type') . ' = ' . $db->quote($extType));
$group = '';
/* Plugins are only unique by element + folder (group). */
if ($extType === 'plugin' && isset($attrs['group'])) {
$group = (string) $attrs['group'];
if ($group !== '') {
$query->where($db->quoteName('folder') . ' = ' . $db->quote($group));
}
}
$db->setQuery($query);
if ((int) $db->loadResult() === 0) {
$label = $extType . ' "' . $element . '"';
if ($group !== '') {
$label .= ' (' . $group . ')';
}
$missing[] = $label;
}
}
return $missing;
} catch (\Throwable $e) {
return [];
}
}
/**
* Verify the component's schema actually installed. The component has no
* installer script of its own, so this package postflight is the only place
* to catch a component whose extension row exists but whose CREATE TABLE
* statements failed (e.g. an installer-rejected migration). Reads the
* *installed* install SQL, extracts every declared table, and confirms each
* exists. Table names are derived dynamically — nothing is hard-coded.
*
* Fail-open: any IO/DB/parse error returns [] so a glitch can't fail a good
* install.
*
* @return string[] Missing table names (empty when all present)
*/
private function findMissingComponentTables(): array
{
try {
$sqlFile = JPATH_ADMINISTRATOR . '/components/com_mokosuitebackup/sql/install.mysql.sql';
if (!is_file($sqlFile)) {
return [];
}
$sql = file_get_contents($sqlFile);
if ($sql === false || $sql === '') {
return [];
}
if (!preg_match_all('/CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?[`"\']?(#__[A-Za-z0-9_]+)/i', $sql, $m)) {
return [];
}
$db = Factory::getContainer()->get(\Joomla\Database\DatabaseInterface::class);
$prefix = $db->getPrefix();
/* Existing tables, compared case-insensitively. */
$existing = array_flip(array_map('strtolower', $db->getTableList()));
$missing = [];
foreach (array_unique($m[1]) as $declared) {
$real = str_replace('#__', $prefix, $declared);
if (!isset($existing[strtolower($real)])) {
$missing[] = $real;
}
}
return $missing;
} catch (\Throwable $e) {
return [];
}
}
/**
* Remove the orphaned component registration created when the component
* <name> briefly used the "Type - Name" display convention. Joomla derives a