{"record":{"id":"af2f6194ca3cbffc","repo":"Leantime/leantime","slug":"notification-plugin-cant-remove","errorCode":null,"errorMessage":"notification.plugin_cant_remove","messagePattern":"notification\\.plugin_cant_remove","errorType":"exception","errorClass":"Exception","httpStatus":null,"severity":"error","filePath":"app/Domain/Plugins/Services/Plugins.php","lineNumber":725,"sourceCode":"        $filename = $response->header('Content-Disposition');\n        $filename = substr($filename, strpos($filename, 'filename=') + 9);\n        $foldername = Str::studly(basename($filename, '.zip'));\n        $filename = Str::finish($foldername, '.zip');\n\n        if (\n            ! file_put_contents(\n                $temporaryFile = Str::finish(sys_get_temp_dir(), '/').$filename,\n                $response->body()\n            )\n        ) {\n            throw new \\Exception(__('notification.plugin_cant_download'));\n        }\n\n        if (\n            is_dir($pluginDir = \"{$this->pluginDirectory}{$foldername}\")\n            && ! File::deleteDirectory($pluginDir)\n        ) {\n            throw new \\Exception(__('notification.plugin_cant_remove'));\n        }\n\n        if (! mkdir($pluginDir) && ! is_dir($pluginDir)) {\n            throw new \\RuntimeException(sprintf('Directory \"%s\" was not created', $pluginDir));\n        }\n\n        $zip = new \\ZipArchive;\n\n        match ($zip->open($temporaryFile)) {\n            \\ZipArchive::ER_EXISTS => throw new \\Exception(__('notification.plugin_zip_exists')),\n            \\ZipArchive::ER_INCONS => throw new \\Exception(__('notification.plugin_zip_inconsistent')),\n            \\ZipArchive::ER_INVAL => throw new \\Exception(__('notification.plugin_zip_invalid_arg')),\n            \\ZipArchive::ER_MEMORY => throw new \\Exception(__('notification.plugin_zip_malloc')),\n            \\ZipArchive::ER_NOENT => throw new \\Exception(__('notification.plugin_zip_no_file')),\n            \\ZipArchive::ER_NOZIP => throw new \\Exception(__('notification.plugin_zip_not_zip')),\n            \\ZipArchive::ER_OPEN => throw new \\Exception(__('notification.plugin_zip_cant_open')),\n            \\ZipArchive::ER_READ => throw new \\Exception(__('notification.plugin_zip_read_err')),\n            \\ZipArchive::ER_SEEK => throw new \\Exception(__('notification.plugin_zip_seek_err')),","sourceCodeStart":707,"sourceCodeEnd":743,"githubUrl":"https://github.com/Leantime/leantime/blob/9a9f49f1008f4782b30f6723c54228f4f992e636/app/Domain/Plugins/Services/Plugins.php#L707-L743","documentation":"During marketplace plugin install/update, the code first removes an existing installation: if app/Plugins/{Folder} is a directory and Laravel's File::deleteDirectory() returns false, it throws the translated 'Could not remove existing plugin'. The marketplace download already succeeded at this point; the failure is purely a filesystem deletion failure on the previous plugin version's directory.","triggerScenarios":"installMarketplacePlugin() for a plugin whose folder already exists under app/Plugins AND deletion fails: files owned by a different user than the PHP process (e.g. created by root, web server runs as www-data); the app/Plugins volume mounted read-only in a container; restrictive directory permissions (directory not writable, so child files cannot be unlinked); an NFS/bind mount with broken delete semantics.","commonSituations":"Plugin folders created by a CLI/artisan run as root, then updated from the web UI; docker-compose volume for app/Plugins mounted ':ro'; permissions tightened after initial install.","solutions":["Fix ownership: chown -R <webuser>: app/Plugins/<Folder> (and the parent), ensure the directory itself is writable (chmod u+w).","Remove the stale directory manually: rm -rf app/Plugins/<Folder>, then retry the install from the marketplace UI.","Ensure the app/Plugins mount is writable (remove ':ro' from the docker volume).","Disable the plugin in Leantime before updating so no process holds files open."],"exampleFix":null,"handlingStrategy":"validation","validationCode":"// before updating, confirm we can actually remove the previous install\n$dir = $pluginDirectory.$foldername;\nif (is_dir($dir) && ! is_writable($dir) && ! chmod($dir, 0755)) {\n    throw new RuntimeException(\"Cannot remove previous install at {$dir}: fix ownership first\");\n}","typeGuard":null,"tryCatchPattern":"try {\n    $plugins->installMarketplacePlugin($plugin, $version);\n} catch (\\Exception $e) {\n    if (str_contains($e->getMessage(), 'remove')) {\n        // previous install is stuck: surface an actionable ops message\n        return back()->with('error', \"Remove app/Plugins/{$plugin->identifier} manually (permissions), then retry\");\n    }\n    throw $e;\n}","preventionTips":["Keep app/Plugins owned by the web server user at all times.","Disable a plugin before updating it.","Never create plugin files as root (run artisan plugin commands as the web user).","Keep the app/Plugins volume mounted read-write."],"tags":["plugins","filesystem","permissions","update"],"backgroundTag":"directory-delete-permission-denied","analyzedSha":"9a9f49f1008f4782b30f6723c54228f4f992e636","analyzedAt":"2026-08-21T02:37:38.966Z","schemaVersion":2},"datasetVersion":"2026-08-21T03:17:12.404Z"}