From a3524d75bde491114bf337b50160563c0911877a Mon Sep 17 00:00:00 2001 From: Gregor Harlan Date: Mon, 1 Jun 2026 14:31:42 +0200 Subject: [PATCH] feat: keep config of addons missing from composer (dev-only addons) Supports addons installed as composer require-dev (present only locally) that survive a `--no-dev` production deploy, where their code is intentionally absent but their entry remains in the committed addons.json. - getAddonOrder() filters out addons that are currently unavailable, so boot no longer crashes with "Required addon ... does not exist" when addons.json lists an addon whose code is missing. The stored order is left untouched. - saveConfig() preserves config entries of addons that are configured but not currently loaded, instead of rebuilding the config solely from loaded addons (which silently dropped a missing dev-only addon on every install/activate). - synchronizeWithFileSystem() only cleans up addons that vanished from composer when running a dev install (i.e. a real `composer remove`); under `--no-dev` the entry is kept untouched. When a still-installed addon is removed, its name-reachable leftovers (assets, cache, config) are cleaned up and a warning is logged, since its own uninstall hook can no longer run (its code is gone). - Dev mode is read from the install data set that actually contains redaxo/core, not InstalledVersions::getRootPackage(), which a dependency's scoped vendor (e.g. rector) can shadow. - The addons.json config is now always sorted (ksort centralised in saveAddonsData) so the committed file stays stable. --- src/Addon/AddonManager.php | 90 +++++++++++++++++++++++++++++++------- 1 file changed, 74 insertions(+), 16 deletions(-) diff --git a/src/Addon/AddonManager.php b/src/Addon/AddonManager.php index bf3d1aa0be..b8dd17d1fb 100644 --- a/src/Addon/AddonManager.php +++ b/src/Addon/AddonManager.php @@ -4,6 +4,7 @@ use Composer\Autoload\ClassLoader; use Composer\InstalledVersions; +use Psr\Log\LogLevel; use Redaxo\Core\Backend\Controller; use Redaxo\Core\Base\FactoryTrait; use Redaxo\Core\Config; @@ -13,6 +14,7 @@ use Redaxo\Core\Filesystem\File; use Redaxo\Core\Filesystem\Path; use Redaxo\Core\Filesystem\Url; +use Redaxo\Core\Log\Logger; use Redaxo\Core\Translation\I18n; use Redaxo\Core\Util\Str; use Redaxo\Core\Util\Type; @@ -220,14 +222,20 @@ public function deactivate(): bool return false; } - /** Cleans up a addon that was removed from the filesystem. */ - protected function _delete(): void + /** + * Cleans up the leftovers of an addon whose code was removed from the filesystem. + * + * Only the parts reachable by name are removed (assets, cache, config). The addon's own uninstall hook + * can no longer run because its code is gone, so anything it created itself (e.g. database tables) is + * left behind — to fully uninstall an addon, uninstall it before removing it via composer. + * + * @param non-empty-string $addon + */ + private static function clearLeftovers(string $addon): void { - if ($this->addon->isInstalled()) { - $this->uninstall(); - } - - $this->addon->clearCache(); + Dir::delete(Path::addonAssets($addon)); + Dir::delete(Path::addonCache($addon)); + Config::removeNamespace($addon); } /** Checks whether the required addons are available. */ @@ -304,7 +312,10 @@ public static function getAddonConfig(): array /** @return TAddonOrder */ public static function getAddonOrder(): array { - return self::loadAddonsData()['order']; + // The persisted order may list addons that are currently not available (e.g. a dev-only addon + // missing after `composer install --no-dev`). Filter them out so boot does not require a missing + // addon, while leaving the stored order untouched so it returns unchanged once available again. + return array_values(array_filter(self::loadAddonsData()['order'], Addon::exists(...))); } /** Generates the addon order. */ @@ -355,10 +366,12 @@ public static function generateAddonOrder(): void /** Saves the addon config. */ protected static function saveConfig(): void { - $config = []; + // Start from the existing config so entries of addons that are configured but currently not loaded + // (e.g. a dev-only addon missing after `composer install --no-dev`) are preserved. Removing orphaned + // entries is the dedicated job of synchronizeWithFileSystem(). + $config = self::getAddonConfig(); foreach (Addon::getRegisteredAddons() as $addonName => $addon) { - $config[$addonName]['class'] = $addon::class; - $config[$addonName]['state'] = $addon->state->value; + $config[$addonName] = ['class' => $addon::class, 'state' => $addon->state->value]; } self::saveAddonsData(config: $config); @@ -367,15 +380,35 @@ protected static function saveConfig(): void /** Synchronizes the addons with the file system. */ public static function synchronizeWithFileSystem(): void { + // Whether composer was installed with dev dependencies. In a production install (`--no-dev`) a + // dev-only addon is intentionally absent, so its config entry must be preserved. + $devMode = self::isDevInstall(); + $config = self::getAddonConfig(); - $registeredAddons = Addon::getRegisteredAddons(); $packages = self::getComposerPackages(); $addonClasses = self::getAddonClasses(); + $removed = false; + + // Addons that have a config entry but are no longer a composer package. + foreach (array_diff_key($config, $packages) as $addonName => $addonConfig) { + if (!$devMode) { + // Production install: the addon is intentionally absent (dev-only addon). Keep its config + // entry untouched so it returns unchanged once dev dependencies are installed again. + continue; + } + + // Dev install: the addon was genuinely removed (`composer remove`). Its code is gone, so its own + // uninstall hook can no longer run — clean up everything reachable by name and forget the addon. + if (AddonState::Uninstalled->value !== ($addonConfig['state'] ?? AddonState::Uninstalled->value)) { + Logger::factory()->log(LogLevel::WARNING, sprintf( + 'Addon "%s" was removed from the filesystem while still installed. Its assets, cache and config have been cleaned up, but its uninstall routine (e.g. database changes) could not run because its code is gone. To fully uninstall an addon, uninstall it before removing it via composer.', + $addonName, + )); + } - foreach (array_diff_key($registeredAddons, $packages) as $addonName => $addon) { - $manager = self::factory($addon); - $manager->_delete(); + self::clearLeftovers($addonName); unset($config[$addonName]); + $removed = true; } foreach ($packages as $addonName => $package) { if (!isset($addonClasses[$addonName])) { @@ -388,10 +421,32 @@ public static function synchronizeWithFileSystem(): void $config[$addonName]['state'] = Addon::require($addonName)->state->value; } } - ksort($config); self::saveAddonsData(config: $config); Addon::initialize(); + + if ($removed) { + // Drop the removed addons from the persisted order as well, so the committed addons.json stays clean. + self::generateAddonOrder(); + } + } + + /** + * Returns whether composer was installed with dev dependencies (i.e. not `--no-dev`). + * + * {@see InstalledVersions::getRootPackage()} is unreliable here: a dependency that ships a scoped vendor + * with its own autoloader (e.g. rector) can become the reported root package. So instead we look through + * all install data sets for the one that actually contains redaxo/core and read its root dev flag. + */ + private static function isDevInstall(): bool + { + foreach (InstalledVersions::getAllRawData() as $data) { + if ('redaxo/core' === ($data['root']['name'] ?? null) || isset($data['versions']['redaxo/core'])) { + return (bool) ($data['root']['dev'] ?? false); + } + } + + return false; } /** @return array{config: TAddonConfig, order: TAddonOrder} */ @@ -421,6 +476,9 @@ private static function saveAddonsData(?array $config = null, ?array $order = nu $data = self::loadAddonsData(); if (null !== $config) { + // keep the config sorted by addon name so the committed addons.json stays stable (the boot order + // is tracked separately in $data['order']) + ksort($config); $data['config'] = $config; } if (null !== $order) {