diff --git a/CHANGELOG.md b/CHANGELOG.md
index f984fc6..ec9fd77 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -51,6 +51,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed
- SQL migration compatibility: removed `DROP COLUMN IF EXISTS` (MySQL 8.0.13+ only) in favor of plain `DROP COLUMN`
+- Honest installer success: package postflight now verifies every bundled child extension (component, modules, plugin) actually landed in `#__extensions` before showing the license/next-steps message; if any is missing it shows an error and suppresses the success message instead of reporting a false success. Fails open so a transient DB/IO glitch never fakes a failure.
## [1.1.0] - 2026-06-23
diff --git a/source/script.php b/source/script.php
index 1f4058e..75616ca 100644
--- a/source/script.php
+++ b/source/script.php
@@ -128,12 +128,121 @@ class Pkg_MokosuitestorelocatorInstallerScript implements InstallerScriptInterfa
*/
public function postflight(string $type, InstallerAdapter $parent): bool
{
+ // Housekeeping runs unconditionally.
$this->restoreDownloadKey();
+
+ // Be honest about success. Joomla's package installer only LOGS a failed
+ // child sub-install but still runs this postflight, so don't show the
+ // license / next-steps message if a bundled extension is actually missing.
+ // Fails open (see missingChildExtensions) so a query/IO glitch never fakes
+ // a failure.
+ $missing = $this->missingChildExtensions($parent);
+
+ if (!empty($missing))
+ {
+ \Joomla\CMS\Factory::getApplication()->enqueueMessage(
+ '
MokoSuiteStoreLocator did not install correctly.
'
+ . 'The following bundled extensions are missing: '
+ . htmlspecialchars(implode(', ', $missing), ENT_QUOTES) . '
'
+ . 'Please uninstall MokoSuiteStoreLocator and reinstall the full package.
',
+ 'error'
+ );
+
+ return true;
+ }
+
+ // Success / next-steps path — only reached when every child installed.
$this->warnMissingLicenseKey();
return true;
}
+ /**
+ * Verify every child extension declared in the package manifest actually landed
+ * in #__extensions. Returns readable labels of any that are missing.
+ *
+ * Matching per Joomla's #__extensions uniqueness:
+ * - type = the "type" attribute
+ * - element = the "id" attribute, EXCEPT for plugins the manifest often
+ * declares the full `plg__` id, so strip a leading
+ * `plg__` to get the real element; components/modules use id as-is
+ * - plugins ALSO match folder = the "group" attribute
+ *
+ * FAILS OPEN: any error (unreadable manifest, query failure) returns [] so a
+ * transient glitch never turns a good install into a false failure.
+ *
+ * @param InstallerAdapter $parent The parent installer object.
+ *
+ * @return array Readable labels of missing child extensions.
+ *
+ * @since 1.0.0
+ */
+ private function missingChildExtensions(InstallerAdapter $parent): array
+ {
+ try
+ {
+ $manifest = $parent->getParent()->getManifest();
+
+ if (!$manifest || !isset($manifest->files) || !isset($manifest->files->file))
+ {
+ return [];
+ }
+
+ $db = \Joomla\CMS\Factory::getDbo();
+ $missing = [];
+
+ foreach ($manifest->files->file as $file)
+ {
+ $attrs = $file->attributes();
+ $id = isset($attrs['id']) ? (string) $attrs['id'] : '';
+ $exType = isset($attrs['type']) ? (string) $attrs['type'] : '';
+
+ if ($id === '' || $exType === '')
+ {
+ continue;
+ }
+
+ $group = isset($attrs['group']) ? (string) $attrs['group'] : '';
+ $element = $id;
+
+ // Plugin element in #__extensions is the id minus any leading plg__.
+ if ($exType === 'plugin' && $group !== '')
+ {
+ $prefix = 'plg_' . $group . '_';
+
+ if (strpos($element, $prefix) === 0)
+ {
+ $element = substr($element, \strlen($prefix));
+ }
+ }
+
+ $query = $db->getQuery(true)
+ ->select('COUNT(*)')
+ ->from($db->quoteName('#__extensions'))
+ ->where($db->quoteName('element') . ' = ' . $db->quote($element))
+ ->where($db->quoteName('type') . ' = ' . $db->quote($exType));
+
+ if ($exType === 'plugin' && $group !== '')
+ {
+ $query->where($db->quoteName('folder') . ' = ' . $db->quote($group));
+ }
+
+ if ((int) $db->setQuery($query)->loadResult() === 0)
+ {
+ $label = trim((string) $file);
+ $missing[] = $label !== '' ? preg_replace('/\.zip$/i', '', $label) : $id;
+ }
+ }
+
+ return $missing;
+ }
+ catch (\Throwable $e)
+ {
+ // Fail open — never fake a failure on a glitch.
+ return [];
+ }
+ }
+
private ?string $savedDownloadKey = null;
private function saveDownloadKey(): void