diff --git a/core/Command/App/Remove.php b/core/Command/App/Remove.php
index d43bfa96ccc..13e2990d850 100644
--- a/core/Command/App/Remove.php
+++ b/core/Command/App/Remove.php
@@ -9,6 +9,7 @@ declare(strict_types=1);
namespace OC\Core\Command\App;
use OC\Installer;
+use OCP\App\AppPathNotFoundException;
use OCP\App\IAppManager;
use Psr\Log\LoggerInterface;
use Stecman\Component\Symfony\Console\BashCompletion\Completion\CompletionAwareInterface;
@@ -32,7 +33,14 @@ class Remove extends Command implements CompletionAwareInterface {
protected function configure(): void {
$this
->setName('app:remove')
- ->setDescription('remove an app')
+ ->setDescription('Remove an app from this Nextcloud instance')
+ ->setHelp(
+ "Removes the specified app and, if present, runs the app's uninstall steps.\n" .
+ "\n" .
+ "By default, this command runs the app's uninstall steps (which may delete data) and then removes the app files.\n" .
+ "Use `--keep-data` to skip uninstall steps and preserve app data (database tables, configuration, and stored files).\n" .
+ "Note: Some apps may still preserve data either way, depending on their uninstall implementation.\n"
+ )
->addArgument(
'app-id',
InputArgument::REQUIRED,
@@ -42,63 +50,73 @@ class Remove extends Command implements CompletionAwareInterface {
'keep-data',
null,
InputOption::VALUE_NONE,
- 'keep app data and do not remove them'
+ 'Do not run uninstall tasks; preserve app data and configuration'
);
}
protected function execute(InputInterface $input, OutputInterface $output): int {
- $appId = $input->getArgument('app-id');
+ $appId = (string) $input->getArgument('app-id');
+ $keepData = (bool) $input->getOption('keep-data');
- // Check if the app is enabled
- if (!$this->manager->isEnabledForAnyone($appId)) {
- $output->writeln($appId . ' is not enabled');
- return 1;
- }
-
- // Removing shipped apps is not possible, therefore we pre-check that
- // before trying to remove it
+ // Prevent removal of shipped/core apps
if ($this->manager->isShipped($appId)) {
- $output->writeln($appId . ' could not be removed as it is a shipped app');
- return 1;
+ $output->writeln("App '$appId' is a shipped/core app and cannot be removed.");
+ return self::FAILURE;
}
- // If we want to keep the data of the app, we simply don't disable it here.
- // App uninstall tasks are being executed when disabled. More info: PR #11627.
- if (!$input->getOption('keep-data')) {
- try {
- $this->manager->disableApp($appId);
- $output->writeln($appId . ' disabled');
- } catch (Throwable $e) {
- $output->writeln('Error: ' . $e->getMessage() . '');
- $this->logger->error($e->getMessage(), [
- 'app' => 'CLI',
- 'exception' => $e,
- ]);
- return 1;
- }
- }
-
- // Let's try to remove the app...
+ // Prevent removal of apps that aren't even installed (note: don't use isInstalled(); it's a misnomer)
try {
- $result = $this->installer->removeApp($appId);
- } catch (Throwable $e) {
- $output->writeln('Error: ' . $e->getMessage() . '');
- $this->logger->error($e->getMessage(), [
- 'app' => 'CLI',
- 'exception' => $e,
- ]);
- return 1;
- }
-
- if ($result === false) {
- $output->writeln($appId . ' could not be removed');
- return 1;
+ $this->manager->getAppPath($appId);
+ } catch (AppPathNotFoundException $e) {
+ $output->writeln("App '$appId' is not installed. Nothing to remove.");
+ return self::FAILURE; // one could argue this a no-op and should be considered a success (?)
}
$appVersion = $this->manager->getAppVersion($appId);
- $output->writeln($appId . ' ' . $appVersion . ' removed');
- return 0;
+ // Do not run the specified app's uninstall tasks -- preserving app data/config -- if requested
+ if ($keepData) {
+ $message = "Removing app '$appId' but keeping app data (uninstall hooks skipped).";
+ $output->writeln($message);
+ $this->logger->info($message, [ 'app' => 'CLI', ]);
+ } else {
+ // Disable the app before removing to trigger uninstall steps
+ try {
+ $this->manager->disableApp($appId);
+ $message = "Disabled app '$appId' (uninstall steps executed).";
+ $output->writeln($message);
+ $this->logger->info($message, [ 'app' => 'CLI', ]);
+ } catch (Throwable $e) {
+ $message = "Failed to disable app '$appId' (version $appVersion) - app removal skipped.";
+ $output->writeln('Error: ' . $e->getMessage() . '');
+ $output->writeln("\n" . $message);
+ $this->logger->error($message, [ 'app' => 'CLI', 'exception' => $e, ]);
+ return self::FAILURE;
+ }
+ }
+
+ // Remove the specified app
+ try {
+ $removeSuccess = $this->installer->removeApp($appId);
+ } catch (Throwable $e) {
+ $removeSuccess = false;
+ $output->writeln('Error: ' . $e->getMessage() . '');
+ $this->logger->error("Failed to remove app '$appId': " . $e->getMessage(), [ 'app' => 'CLI', 'exception' => $e, ]);
+ }
+
+ // Something went wrong during removeApp(); probably no removal took place or incomplete
+ if (!$removeSuccess) {
+ $message = "\nFailed to remove app '$appId' (version $appVersion) - app files/registration were not removed.";
+ $output->writeln($message);
+ $this->logger->error($message, [ 'app' => 'CLI', ]);
+ return self::FAILURE;
+ }
+
+ $message = "Removed app '$appId' (version $appVersion).";
+ $output->writeln($message);
+ $this->logger->info($message, [ 'app' => 'CLI', ]);
+
+ return self::SUCCESS;
}
/**
@@ -117,6 +135,7 @@ class Remove extends Command implements CompletionAwareInterface {
*/
public function completeArgumentValues($argumentName, CompletionContext $context): array {
if ($argumentName === 'app-id') {
+ // TODO: Include disabled apps too
return $this->manager->getEnabledApps();
}
return [];