Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 74 additions & 16 deletions src/Addon/AddonManager.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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. */
Expand Down Expand Up @@ -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. */
Expand Down Expand Up @@ -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);
Expand All @@ -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])) {
Expand All @@ -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} */
Expand Down Expand Up @@ -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) {
Expand Down
Loading