diff --git a/app/Console/Commands/UninstallModuleCommand.php b/app/Console/Commands/UninstallModuleCommand.php new file mode 100644 index 00000000..679a87a2 --- /dev/null +++ b/app/Console/Commands/UninstallModuleCommand.php @@ -0,0 +1,41 @@ +argument('module'); + $removeData = (bool) $this->option('remove-data'); + + if (! $this->option('force') && ! $this->confirm( + $removeData + ? "This permanently removes {$module} and its data. Continue?" + : "This removes {$module}'s runtime files but preserves its data. Continue?", + )) { + return self::FAILURE; + } + + $result = $uninstaller->uninstall($module, $removeData, $removeData ? $module : null); + if (! $result['success']) { + $this->error('Module uninstall failed: '.$result['error']); + + return self::FAILURE; + } + + $this->info('Module uninstalled successfully.'); + + return self::SUCCESS; + } +} diff --git a/app/Events/ModuleUninstalledEvent.php b/app/Events/ModuleUninstalledEvent.php new file mode 100644 index 00000000..3ab6df9c --- /dev/null +++ b/app/Events/ModuleUninstalledEvent.php @@ -0,0 +1,17 @@ +json($response, $response['success'] ? 200 : 422); } + + public function uninstall(string $module, UninstallMarketplaceModuleRequest $request, MarketplaceUninstaller $uninstaller): JsonResponse + { + $this->authorize('manage modules'); + + $response = $uninstaller->uninstall( + $module, + $request->boolean('remove_data'), + $request->string('confirmation')->toString() ?: null, + ); + + $status = match ($response['error'] ?? null) { + 'module_not_installed' => 404, + 'operation_in_progress', 'module_runtime_missing', 'dependent_modules_installed' => 409, + default => $response['success'] ? 200 : 422, + }; + + return response()->json($response, $status); + } } diff --git a/app/Http/Controllers/Admin/Modules/ModulesController.php b/app/Http/Controllers/Admin/Modules/ModulesController.php index 18f224a8..15c4c43a 100644 --- a/app/Http/Controllers/Admin/Modules/ModulesController.php +++ b/app/Http/Controllers/Admin/Modules/ModulesController.php @@ -7,9 +7,9 @@ use App\Events\ModuleEnabledEvent; use App\Http\Controllers\Controller; use App\Http\Resources\ModuleResource; use App\Models\Module as ModelsModule; +use App\Services\Marketplace\DatabaseActivator; use App\Services\Marketplace\MarketplaceClient; use Illuminate\Http\JsonResponse; -use Illuminate\Http\Request; use Nwidart\Modules\Facades\Module; class ModulesController extends Controller @@ -52,31 +52,63 @@ class ModulesController extends Controller ]]); } - public function enable(Request $request, string $module): JsonResponse + public function enable(string $module): JsonResponse { $this->authorize('manage modules'); - $module = ModelsModule::where('name', $module)->first(); - $module->update(['enabled' => true]); + $module = ModelsModule::query() + ->where('name', $module) + ->where('installed', true) + ->firstOrFail(); $installedModule = Module::find($module->name); + + if ($installedModule === null) { + $this->markRuntimeMissing($module); + + return response()->json([ + 'success' => false, + 'error' => 'module_runtime_missing', + ], 409); + } + $installedModule->enable(); + $module->refresh(); ModuleEnabledEvent::dispatch($module); return response()->json(['success' => true]); } - public function disable(Request $request, string $module): JsonResponse + public function disable(string $module, DatabaseActivator $activator): JsonResponse { $this->authorize('manage modules'); - $module = ModelsModule::where('name', $module)->first(); - $module->update(['enabled' => false]); - $installedModule = Module::find($module->name); - $installedModule->disable(); + $module = ModelsModule::query() + ->where('name', $module) + ->where('installed', true) + ->firstOrFail(); + + $activator->setActiveByName($module->name, false); + + if (Module::find($module->name) === null) { + $this->markRuntimeMissing($module); + } else { + $module->refresh(); + } ModuleDisabledEvent::dispatch($module); return response()->json(['success' => true]); } + + private function markRuntimeMissing(ModelsModule $module): void + { + $module->update([ + 'installed' => false, + 'enabled' => false, + 'state' => 'failed', + 'last_error' => 'module_runtime_missing', + 'last_failed_at' => now(), + ]); + } } diff --git a/app/Http/Requests/UninstallMarketplaceModuleRequest.php b/app/Http/Requests/UninstallMarketplaceModuleRequest.php new file mode 100644 index 00000000..e41548be --- /dev/null +++ b/app/Http/Requests/UninstallMarketplaceModuleRequest.php @@ -0,0 +1,29 @@ +> */ + public function rules(): array + { + return [ + 'remove_data' => ['required', 'boolean'], + 'confirmation' => [ + 'nullable', + 'string', + 'max:100', + 'required_if:remove_data,true', + Rule::in([(string) $this->route('module')]), + ], + ]; + } +} diff --git a/app/Http/Resources/ModuleResource.php b/app/Http/Resources/ModuleResource.php index 1c53c637..1818daf7 100644 --- a/app/Http/Resources/ModuleResource.php +++ b/app/Http/Resources/ModuleResource.php @@ -6,6 +6,7 @@ use App\Models\Module as ModelsModule; use Illuminate\Contracts\Support\Arrayable; use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\JsonResource; +use Illuminate\Support\Facades\File; class ModuleResource extends JsonResource { @@ -64,6 +65,7 @@ class ModuleResource extends JsonResource 'author_avatar' => data_get($this->resource, 'author_avatar') ?? data_get($this->resource, 'author.avatar'), 'installed' => $this->moduleInstalled($installedModule), 'enabled' => $this->moduleEnabled($installedModule), + 'supports_data_cleanup' => $this->supportsDataCleanup($installedModule), 'update_available' => $this->updateAvailable($installedModule, $latestVersion), 'video_link' => data_get($this->resource, 'video_link') ?? data_get($this->resource, 'video.url'), 'video_thumbnail' => data_get($this->resource, 'video_thumbnail') ?? data_get($this->resource, 'video.thumbnail'), @@ -111,4 +113,40 @@ class ModuleResource extends JsonResource return version_compare($installedModule->version, $latestVersion, '<'); } + + public function supportsDataCleanup(?ModelsModule $installedModule): bool + { + if (! $installedModule?->installed) { + return false; + } + + $path = base_path('Modules/'.$installedModule->name.'/module.json'); + $metadata = File::isFile($path) ? json_decode((string) File::get($path), true) : null; + $uninstall = is_array($metadata) ? ($metadata['uninstall'] ?? null) : null; + $cleanup = is_array($uninstall) ? ($uninstall['data_cleanup'] ?? null) : null; + $contract = 'InvoiceShelf\\Modules\\Contracts\\DataCleanup'; + + if (! (is_array($metadata) + && ($metadata['schema_version'] ?? null) === 2 + && ($metadata['migration_policy'] ?? null) === 'reversible' + && is_array($uninstall) + && array_keys($uninstall) === ['data_cleanup'] + && is_string($cleanup) + && interface_exists($contract))) { + return false; + } + + try { + $reflection = new \ReflectionClass($cleanup); + + return ! $reflection->isAbstract() + && $reflection->isInstantiable() + && is_a($cleanup, $contract, true) + && $reflection->hasMethod('cleanup') + && $reflection->getMethod('cleanup')->isPublic() + && ! $reflection->getMethod('cleanup')->isStatic(); + } catch (\Throwable) { + return false; + } + } } diff --git a/app/Services/Marketplace/DatabaseActivator.php b/app/Services/Marketplace/DatabaseActivator.php index 3052a017..ae5c2c36 100644 --- a/app/Services/Marketplace/DatabaseActivator.php +++ b/app/Services/Marketplace/DatabaseActivator.php @@ -85,7 +85,7 @@ class DatabaseActivator implements ActivatorInterface return; } - DB::table('modules')->where('name', $module->getName())->update(['enabled' => false, 'updated_at' => now()]); + throw new \LogicException('Direct module deletion is disabled. Use module:uninstall instead.'); } public function reset(): void diff --git a/app/Services/Marketplace/MarketplaceInstaller.php b/app/Services/Marketplace/MarketplaceInstaller.php index a775a9aa..2446231e 100644 --- a/app/Services/Marketplace/MarketplaceInstaller.php +++ b/app/Services/Marketplace/MarketplaceInstaller.php @@ -4,7 +4,6 @@ namespace App\Services\Marketplace; use App\Events\ModuleEnabledEvent; use App\Events\ModuleInstalledEvent; -use App\Models\MarketplaceOperation; use App\Models\Module as InstalledModule; use App\Models\Setting; use Composer\Semver\Semver; @@ -18,14 +17,17 @@ use ZipArchive; class MarketplaceInstaller { - public function __construct(private MarketplaceClient $client) {} + public function __construct( + private MarketplaceClient $client, + private MarketplaceOperationLease $operations, + ) {} /** * @return array{success: bool, operation_id?: int, error?: string} */ public function install(string $slug, string $version, string $channel): array { - $operation = $this->acquireLease($slug, $version, $channel); + $operation = $this->operations->acquire($slug, $version, $channel); if ($operation === null) { return ['success' => false, 'error' => 'Another marketplace installation is in progress.']; } @@ -89,7 +91,7 @@ class MarketplaceInstaller Artisan::call('queue:restart --no-interaction'); ModuleInstalledEvent::dispatch($record); ModuleEnabledEvent::dispatch($record); - $this->finish($operation, 'completed'); + $this->operations->finish($operation, 'completed'); $this->clean($workspace, $backup); return ['success' => true, 'operation_id' => $operation->id]; @@ -103,7 +105,7 @@ class MarketplaceInstaller if (is_string($workspace) && File::isDirectory($workspace)) { File::deleteDirectory($workspace); } - $this->finish($operation, 'failed', $exception->getMessage()); + $this->operations->finish($operation, 'failed', $exception->getMessage()); return ['success' => false, 'operation_id' => $operation->id, 'error' => $exception->getMessage()]; } @@ -395,7 +397,7 @@ class MarketplaceInstaller $moduleJson = $destination.'/'.$moduleName.'/module.json'; $metadata = json_decode((string) File::get($moduleJson), true); - if (! is_array($metadata) || ($metadata['schema_version'] ?? null) !== 1 || ($metadata['name'] ?? null) !== $moduleName || ! is_array($metadata['providers'] ?? null) + if (! is_array($metadata) || ! in_array($metadata['schema_version'] ?? null, [1, 2], true) || ($metadata['name'] ?? null) !== $moduleName || ! is_array($metadata['providers'] ?? null) || ($manifest['slug'] ?? null) !== $slug || ($manifest['module_name'] ?? null) !== $moduleName || ($manifest['version'] ?? null) !== $version) { throw new RuntimeException('Module metadata does not match the signed release manifest.'); } @@ -406,6 +408,9 @@ class MarketplaceInstaller } $this->assertModuleManifest($metadata, $moduleName, $slug); + if (($metadata['schema_version'] ?? null) === 2) { + $this->assertSchemaV2WithSdk($destination.'/'.$moduleName); + } foreach ($metadata['providers'] as $provider) { if (! is_string($provider) || ! str_starts_with($provider, "Modules\\{$moduleName}\\") @@ -457,6 +462,10 @@ class MarketplaceInstaller private function assertModuleManifest(array $metadata, string $moduleName, string $slug): void { $expected = ['name', 'alias', 'description', 'keywords', 'priority', 'providers', 'aliases', 'files', 'requires', 'schema_version', 'slug', 'version', 'license', 'compatibility', 'module_dependencies', 'migration_policy', 'dependency_policy', 'assets']; + if (($metadata['schema_version'] ?? null) === 2) { + $expected[] = 'uninstall'; + } + if (array_diff(array_keys($metadata), $expected) !== [] || array_diff($expected, array_keys($metadata)) !== [] || ! is_string($metadata['alias'] ?? null) || preg_match('/^[a-z][a-z0-9_]*$/', $metadata['alias']) !== 1 || ! is_string($metadata['description'] ?? null) || ! is_int($metadata['priority'] ?? null) || $metadata['priority'] < 0 @@ -467,10 +476,21 @@ class MarketplaceInstaller || ! is_array($metadata['requires'] ?? null) || (! empty($metadata['requires']) && array_is_list($metadata['requires'])) || ! is_array($metadata['files'] ?? null) || ! array_is_list($metadata['files']) || ! is_array($metadata['assets'] ?? null) || ! array_is_list($metadata['assets']) - || ($metadata['migration_policy'] ?? null) !== 'forward-only' || ($metadata['dependency_policy'] ?? null) !== 'host-provided-only') { + || ! in_array($metadata['schema_version'] ?? null, [1, 2], true) + || ($metadata['migration_policy'] ?? null) !== (($metadata['schema_version'] ?? null) === 2 ? 'reversible' : 'forward-only') + || ($metadata['dependency_policy'] ?? null) !== 'host-provided-only') { throw new RuntimeException('Module package manifest has an invalid schema.'); } + if (($metadata['schema_version'] ?? null) === 2) { + $uninstall = $metadata['uninstall'] ?? null; + $cleanup = is_array($uninstall) ? ($uninstall['data_cleanup'] ?? null) : null; + if (! is_array($uninstall) || array_keys($uninstall) !== ['data_cleanup'] || ! is_string($cleanup) + || preg_match('/^Modules\\\\'.preg_quote($moduleName, '/').'\\\\[A-Za-z][A-Za-z0-9]*(?:\\\\[A-Za-z][A-Za-z0-9]*)*$/', $cleanup) !== 1) { + throw new RuntimeException('Module uninstall metadata has an invalid schema.'); + } + } + foreach ($metadata['keywords'] as $keyword) { if (! is_string($keyword)) { throw new RuntimeException('Module package keywords must be strings.'); @@ -500,7 +520,11 @@ class MarketplaceInstaller private function assertSafeMigrations(string $modulePath): void { $metadata = json_decode((string) File::get($modulePath.'/module.json'), true); - if (($metadata['migration_policy'] ?? null) !== 'forward-only') { + if (($metadata['schema_version'] ?? null) === 2 && ($metadata['migration_policy'] ?? null) === 'reversible') { + return; + } + + if (($metadata['schema_version'] ?? null) !== 1 || ($metadata['migration_policy'] ?? null) !== 'forward-only') { throw new RuntimeException('Module migrations must declare the forward-only policy.'); } foreach (File::glob($modulePath.'/database/migrations/*.php') as $migration) { @@ -511,6 +535,20 @@ class MarketplaceInstaller } } + private function assertSchemaV2WithSdk(string $modulePath): void + { + $validator = 'InvoiceShelf\\Modules\\Manifest\\ManifestValidator'; + if (! class_exists($validator) || ! method_exists($validator, 'package')) { + throw new RuntimeException('Schema-v2 modules require invoiceshelf/modules ^3.2.'); + } + + try { + $validator::package($modulePath); + } catch (Throwable $exception) { + throw new RuntimeException('Module schema-v2 validation failed: '.$exception->getMessage(), previous: $exception); + } + } + private function safeZipPath(string $name): bool { return $name !== '' && ! str_contains($name, "\0") && ! str_starts_with($name, '/') && ! preg_match('/^[A-Za-z]:/', $name) @@ -599,30 +637,6 @@ class MarketplaceInstaller ]); } - private function acquireLease(string $slug, string $version, string $channel): ?MarketplaceOperation - { - $lock = 'marketplace-install'; - MarketplaceOperation::query()->where('lock_name', $lock)->where('expires_at', '<', now())->update([ - 'lock_name' => null, - 'status' => 'failed', - 'error' => 'Marketplace operation lease expired.', - 'finished_at' => now(), - ]); - try { - return MarketplaceOperation::query()->create([ - 'lock_name' => $lock, 'slug' => $slug, 'version' => $version, 'channel' => $channel, - 'status' => 'running', 'started_at' => now(), 'expires_at' => now()->addSeconds((int) config('invoiceshelf.marketplace.lease_seconds')), - ]); - } catch (Throwable) { - return null; - } - } - - private function finish(MarketplaceOperation $operation, string $status, ?string $error = null): void - { - $operation->update(['lock_name' => null, 'status' => $status, 'error' => $error, 'finished_at' => now(), 'expires_at' => now()]); - } - private function clean(string $workspace, ?string $backup): void { File::deleteDirectory($workspace); diff --git a/app/Services/Marketplace/MarketplaceOperationLease.php b/app/Services/Marketplace/MarketplaceOperationLease.php new file mode 100644 index 00000000..980bdc21 --- /dev/null +++ b/app/Services/Marketplace/MarketplaceOperationLease.php @@ -0,0 +1,46 @@ +where('lock_name', $lock)->where('expires_at', '<', now())->update([ + 'lock_name' => null, + 'status' => 'failed', + 'error' => 'Marketplace operation lease expired.', + 'finished_at' => now(), + ]); + + try { + return MarketplaceOperation::query()->create([ + 'lock_name' => $lock, + 'slug' => $slug, + 'version' => $version, + 'channel' => $channel, + 'status' => 'running', + 'started_at' => now(), + 'expires_at' => now()->addSeconds((int) config('invoiceshelf.marketplace.lease_seconds')), + ]); + } catch (Throwable) { + return null; + } + } + + public function finish(MarketplaceOperation $operation, string $status, ?string $error = null): void + { + $operation->update([ + 'lock_name' => null, + 'status' => $status, + 'error' => $error, + 'finished_at' => now(), + 'expires_at' => now(), + ]); + } +} diff --git a/app/Services/Marketplace/MarketplaceUninstaller.php b/app/Services/Marketplace/MarketplaceUninstaller.php new file mode 100644 index 00000000..e5aa6618 --- /dev/null +++ b/app/Services/Marketplace/MarketplaceUninstaller.php @@ -0,0 +1,300 @@ +where('name', $name)->where('installed', true)->first(); + if ($module === null) { + return $this->failure('module_not_installed'); + } + + if ($removeData && $confirmation !== $module->name) { + return $this->failure('module_confirmation_mismatch'); + } + + $operation = $this->operations->acquire($module->slug, $module->version, 'uninstall'); + if ($operation === null) { + return $this->failure('operation_in_progress'); + } + + $backup = null; + $mutationStarted = false; + + try { + if ($dependents = $this->installedDependents($module)) { + throw new ModuleUninstallException('dependent_modules_installed', implode(', ', $dependents)); + } + + $runtime = Module::find($module->name); + if ($runtime === null) { + if ($removeData) { + $mutationStarted = true; + throw new ModuleUninstallException('module_runtime_missing'); + } + + $mutationStarted = true; + $this->removeCodeOnly($module); + $this->operations->finish($operation, 'completed'); + ModuleUninstalledEvent::dispatch($module); + + return ['success' => true, 'operation_id' => $operation->id]; + } + + $manifest = $removeData ? $this->cleanupManifest($module) : null; + $cleanup = $removeData ? $this->resolveDataCleanup($manifest['data_cleanup']) : null; + $mutationStarted = true; + $runtime->disable(); + $module->refresh(); + + if ($removeData) { + $this->runDataCleanup($cleanup); + $this->resetMigrations($module); + CompanySetting::query()->where('option', 'like', 'module.'.$manifest['slug'].'.%')->delete(); + } + + $backup = $this->backUpRuntime($module, (string) $operation->id); + $this->markUninstalled($module); + $this->refreshRuntimeCaches(); + ModuleUninstalledEvent::dispatch($module); + $this->operations->finish($operation, 'completed'); + $this->removeBackupBestEffort($backup); + + return ['success' => true, 'operation_id' => $operation->id]; + } catch (Throwable $exception) { + if (! $exception instanceof ModuleUninstallException || $exception->errorCode === 'uninstall_failed') { + report($exception); + } + if ($mutationStarted) { + $this->restoreRuntime($module, $backup); + } + $error = $exception instanceof ModuleUninstallException + ? $exception->errorCode + : 'uninstall_failed'; + if ($mutationStarted) { + $this->markFailed($module, $error, $exception); + } + $this->operations->finish($operation, 'failed', $error); + + return $this->failure($error, $operation->id); + } + } + + /** @return list */ + private function installedDependents(InstalledModule $module): array + { + $slug = $module->slug ?? $this->moduleSlug($module->name); + if ($slug === null) { + return []; + } + + return InstalledModule::query()->where('installed', true)->where('name', '!=', $module->name) + ->get() + ->filter(function (InstalledModule $candidate) use ($slug): bool { + $metadata = $this->metadata($candidate->name); + + return is_array($metadata) + && array_key_exists($slug, $metadata['module_dependencies'] ?? []); + }) + ->pluck('name') + ->values() + ->all(); + } + + /** @return array{data_cleanup: string, slug: string} */ + private function cleanupManifest(InstalledModule $module): array + { + $metadata = $this->metadata($module->name); + $uninstall = is_array($metadata) ? ($metadata['uninstall'] ?? null) : null; + $cleanup = is_array($uninstall) ? ($uninstall['data_cleanup'] ?? null) : null; + $slug = is_array($metadata) ? ($metadata['slug'] ?? null) : null; + + if (($metadata['schema_version'] ?? null) !== 2 + || ($metadata['migration_policy'] ?? null) !== 'reversible' + || ! is_array($uninstall) + || array_keys($uninstall) !== ['data_cleanup'] + || ! is_string($cleanup) + || ! is_string($slug) + || preg_match('/^[a-z0-9]+(?:-[a-z0-9]+)*$/', $slug) !== 1 + || preg_match('/^Modules\\\\'.preg_quote($module->name, '/').'\\\\[A-Za-z][A-Za-z0-9]*(?:\\\\[A-Za-z][A-Za-z0-9]*)*$/', $cleanup) !== 1) { + throw new ModuleUninstallException('cleanup_not_supported'); + } + + return ['data_cleanup' => $cleanup, 'slug' => $slug]; + } + + private function resetMigrations(InstalledModule $module): void + { + $exitCode = Artisan::call('module:migrate-reset', [ + 'module' => $module->name, + '--force' => true, + ]); + + if ($exitCode !== 0) { + throw new ModuleUninstallException('uninstall_failed', trim(Artisan::output())); + } + } + + private function resolveDataCleanup(string $cleanupClass): object + { + $contract = 'InvoiceShelf\\Modules\\Contracts\\DataCleanup'; + if (! interface_exists($contract)) { + throw new ModuleUninstallException('cleanup_not_supported'); + } + + try { + $parameters = is_subclass_of($cleanupClass, ServiceProvider::class) + ? ['app' => app()] + : []; + $cleanup = app()->make($cleanupClass, $parameters); + if (! is_a($cleanup, $contract)) { + throw new ModuleUninstallException('cleanup_not_supported'); + } + } catch (ModuleUninstallException $exception) { + throw $exception; + } catch (Throwable $exception) { + throw new ModuleUninstallException('cleanup_not_supported', $exception->getMessage()); + } + + return $cleanup; + } + + private function runDataCleanup(object $cleanup): void + { + try { + $cleanup->cleanup(); + } catch (Throwable $exception) { + throw new ModuleUninstallException('uninstall_failed', $exception->getMessage()); + } + } + + private function removeCodeOnly(InstalledModule $module): void + { + $runtime = base_path('Modules/'.$module->name); + if (File::isDirectory($runtime) && ! File::deleteDirectory($runtime)) { + throw new RuntimeException('Could not remove module runtime files.'); + } + $this->markUninstalled($module); + $this->refreshRuntimeCaches(); + } + + private function backUpRuntime(InstalledModule $module, string $operationId): ?string + { + $runtime = base_path('Modules/'.$module->name); + if (! File::isDirectory($runtime)) { + throw new ModuleUninstallException('module_runtime_missing'); + } + + $backup = base_path('Modules/.backups/uninstall-'.$operationId.'-'.$module->name); + File::ensureDirectoryExists(dirname($backup)); + if (! rename($runtime, $backup)) { + throw new RuntimeException('Could not move module runtime to the uninstall backup.'); + } + + return $backup; + } + + private function restoreRuntime(InstalledModule $module, ?string $backup): void + { + if ($backup === null || ! File::isDirectory($backup)) { + return; + } + + $runtime = base_path('Modules/'.$module->name); + File::deleteDirectory($runtime); + rename($backup, $runtime); + } + + private function removeBackupBestEffort(?string $backup): void + { + if ($backup !== null && File::isDirectory($backup) && ! File::deleteDirectory($backup)) { + report(new RuntimeException('Could not remove module uninstall backup.')); + } + } + + private function markUninstalled(InstalledModule $module): void + { + $module->update([ + 'installed' => false, + 'enabled' => false, + 'state' => 'uninstalled', + 'last_error' => null, + 'last_failed_at' => null, + ]); + } + + private function refreshRuntimeCaches(): void + { + if (Artisan::call('optimize:clear --no-interaction') !== 0 + || Artisan::call('queue:restart --no-interaction') !== 0) { + throw new RuntimeException('Could not refresh module runtime caches.'); + } + } + + private function markFailed(InstalledModule $module, string $error, Throwable $exception): void + { + $module->update([ + 'installed' => true, + 'enabled' => false, + 'state' => 'failed', + 'last_error' => Str::limit($error.': '.$exception->getMessage(), 65000), + 'last_failed_at' => now(), + ]); + } + + private function moduleSlug(string $name): ?string + { + $metadata = $this->metadata($name); + + return is_array($metadata) && is_string($metadata['slug'] ?? null) ? $metadata['slug'] : null; + } + + /** @return ?array */ + private function metadata(string $name): ?array + { + $path = base_path('Modules/'.$name.'/module.json'); + if (! File::isFile($path)) { + return null; + } + + $metadata = json_decode((string) File::get($path), true); + + return is_array($metadata) ? $metadata : null; + } + + /** @return array{success: false, error: string, operation_id?: int} */ + private function failure(string $error, ?int $operationId = null): array + { + return array_filter([ + 'success' => false, + 'error' => $error, + 'operation_id' => $operationId, + ], static fn (mixed $value): bool => $value !== null); + } +} + +class ModuleUninstallException extends RuntimeException +{ + public function __construct(public readonly string $errorCode, string $message = '') + { + parent::__construct($message ?: $errorCode); + } +} diff --git a/composer.json b/composer.json index 637539d8..a8d3c928 100644 --- a/composer.json +++ b/composer.json @@ -14,7 +14,7 @@ "gotenberg/gotenberg-php": "^2.8", "guzzlehttp/guzzle": "^7.9", "hashids/hashids": "^5.0", - "invoiceshelf/modules": "^3.0.3", + "invoiceshelf/modules": "^3.2", "laravel/framework": "^13.0", "laravel/helpers": "^1.7", "laravel/sanctum": "^4.0", diff --git a/composer.lock b/composer.lock index 57fc1fc6..58b50675 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "0330848b7c96620b5ee47fcb585f719c", + "content-hash": "39311493e2ff6efae8c6be98b23a5687", "packages": [ { "name": "aws/aws-crt-php", @@ -1735,27 +1735,31 @@ }, { "name": "invoiceshelf/modules", - "version": "3.0.3", + "version": "3.2.0", "source": { "type": "git", "url": "https://github.com/InvoiceShelf/modules.git", - "reference": "9e508914ca64839ef1a1c7d749cc677342dae648" + "reference": "bd6ae29a25bdbe3f395572b95c59843dcc52ac50" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/InvoiceShelf/modules/zipball/9e508914ca64839ef1a1c7d749cc677342dae648", - "reference": "9e508914ca64839ef1a1c7d749cc677342dae648", + "url": "https://api.github.com/repos/InvoiceShelf/modules/zipball/bd6ae29a25bdbe3f395572b95c59843dcc52ac50", + "reference": "bd6ae29a25bdbe3f395572b95c59843dcc52ac50", "shasum": "" }, "require": { + "nikic/php-parser": "^5.0", "nwidart/laravel-modules": "^13.0", "php": "^8.3" }, "require-dev": { "laravel/pint": "^1.16", - "orchestra/testbench": "^10.0", + "orchestra/testbench": "^11.0", "phpunit/phpunit": "^12.0" }, + "bin": [ + "bin/invoiceshelf-module" + ], "type": "library", "extra": { "laravel": { @@ -1779,7 +1783,7 @@ "vendor/bin/phpunit" ], "lint": [ - "vendor/bin/pint" + "vendor/bin/pint --test" ] }, "license": [ @@ -1792,10 +1796,10 @@ "modules" ], "support": { - "source": "https://github.com/InvoiceShelf/modules/tree/3.0.3", + "source": "https://github.com/InvoiceShelf/modules/tree/3.2.0", "issues": "https://github.com/InvoiceShelf/modules/issues" }, - "time": "2026-06-05T00:06:47+00:00" + "time": "2026-08-05T09:35:08+00:00" }, { "name": "laravel/framework", diff --git a/config/invoiceshelf.php b/config/invoiceshelf.php index d6772570..7f0dbfcc 100644 --- a/config/invoiceshelf.php +++ b/config/invoiceshelf.php @@ -67,7 +67,7 @@ return [ */ 'marketplace' => [ 'channel' => env('MARKETPLACE_CHANNEL', 'stable'), - 'module_api_version' => (string) env('MARKETPLACE_MODULE_API_VERSION', '1.0.0'), + 'module_api_version' => (string) env('MARKETPLACE_MODULE_API_VERSION', '1.1.0'), // JSON object: {"key-id":"base64-ed25519-public-key"}. Keys add to // (or replace values in) the built-in pinned map. Key identity is part // of the signed release and must match this trusted map. diff --git a/config/modules.php b/config/modules.php index 201b7486..44e727d1 100644 --- a/config/modules.php +++ b/config/modules.php @@ -1,7 +1,9 @@ ConsoleServiceProvider::defaultCommands() + ->reject(fn (string $command): bool => $command === ModuleDeleteCommand::class) ->merge([ - // InvoiceShelf-specific module commands go here + UninstallModuleCommand::class, ])->toArray(), /* diff --git a/lang/en.json b/lang/en.json index 63e67baa..397e3cef 100644 --- a/lang/en.json +++ b/lang/en.json @@ -841,6 +841,7 @@ "no_reviews_found": "There are no reviews for this module yet!", "module_not_purchased": "Module Not Purchased", "module_not_found": "Module Not Found", + "runtime_missing": "The module files are missing. Install the module again before enabling it.", "version_not_supported": "The minimum required version for this module does not match. Please upgrade your invoiceshelf app to version: {version} to proceed.", "last_updated": "Last Updated On", "connect_installation": "Connect your installation", @@ -851,6 +852,12 @@ "installed": "Installed", "no_modules_installed": "No Modules Installed Yet!", "disable_warning": "All the settings for this particular will be reverted.", + "uninstall": "Uninstall", + "uninstall_warning": "Uninstalling removes this module's runtime files. Its data and settings are preserved unless you explicitly choose to remove them.", + "remove_data": "Remove module data", + "remove_data_warning": "This permanently runs the module's developer cleanup hook, resets its reversible migrations, and removes its settings for every company.", + "confirm_module_name": "Type {name} to confirm data removal", + "legacy_uninstall_notice": "This legacy module supports code-only uninstall. Its data, migrations, and settings will be preserved.", "what_you_get": "What you get", "screenshots": "Screenshots", "sign_up_and_get_token": "Sign up & Get Token", diff --git a/resources/scripts/api/endpoints.ts b/resources/scripts/api/endpoints.ts index 175ddb82..b12bf3ab 100644 --- a/resources/scripts/api/endpoints.ts +++ b/resources/scripts/api/endpoints.ts @@ -170,6 +170,7 @@ export const API = { // Modules MODULES: '/api/v1/modules', MODULES_INSTALL: '/api/v1/modules/install', + MODULES_UNINSTALL: '/api/v1/modules', MODULES_PAIRING: '/api/v1/modules/pairing', // Self Update diff --git a/resources/scripts/api/services/index.ts b/resources/scripts/api/services/index.ts index 1975037d..3efbf3c4 100644 --- a/resources/scripts/api/services/index.ts +++ b/resources/scripts/api/services/index.ts @@ -52,7 +52,7 @@ export type { CreateTaxTypePayload } from './tax-type.service' export type { CustomFieldListParams, CreateCustomFieldPayload } from './custom-field.service' export type { CreateNotePayload } from './note.service' export type { CreateExchangeRateProviderPayload, BulkUpdatePayload, ExchangeRateResponse, ActiveProviderResponse } from './exchange-rate.service' -export type { Module, ModuleInstallPayload } from './module.service' +export type { Module, ModuleInstallPayload, ModuleUninstallPayload } from './module.service' export type { Backup, BackupListResponse, CreateBackupPayload, DeleteBackupParams } from './backup.service' export type { MailConfig, CompanyMailConfig, MailDriver, TestMailPayload } from '@/scripts/types/mail-config' export type { PdfConfig, PdfConfigResponse, PdfDriver, DomPdfConfig, GotenbergConfig } from './pdf.service' diff --git a/resources/scripts/api/services/module.service.ts b/resources/scripts/api/services/module.service.ts index 9c530afc..bead9c8a 100644 --- a/resources/scripts/api/services/module.service.ts +++ b/resources/scripts/api/services/module.service.ts @@ -30,6 +30,11 @@ export interface ModuleInstallPayload { channel?: 'stable' | 'insider' } +export interface ModuleUninstallPayload { + remove_data: boolean + confirmation?: string +} + export interface ModuleDetailResponse { data: Module meta: ModuleDetailMeta @@ -80,4 +85,9 @@ export const moduleService = { const { data } = await client.post(API.MODULES_INSTALL, payload) return data }, + + async uninstall(module: string, payload: ModuleUninstallPayload): Promise<{ success: boolean; error?: string }> { + const { data } = await client.post(`${API.MODULES_UNINSTALL}/${module}/uninstall`, payload) + return data + }, } diff --git a/resources/scripts/features/admin/modules/store.ts b/resources/scripts/features/admin/modules/store.ts index d2a38040..cc0c1a0b 100644 --- a/resources/scripts/features/admin/modules/store.ts +++ b/resources/scripts/features/admin/modules/store.ts @@ -6,6 +6,7 @@ import type { MarketplacePairingStatus, ModuleDetailResponse, ModuleInstallPayload, + ModuleUninstallPayload, } from '../../../api/services/module.service' export type { ModuleDetailResponse, ModuleDetailMeta } from '../../../api/services/module.service' @@ -108,6 +109,13 @@ export const useModuleStore = defineStore('modules', { return false } }, + + async uninstallModule( + moduleName: string, + payload: ModuleUninstallPayload, + ): Promise<{ success: boolean; error?: string }> { + return moduleService.uninstall(moduleName, payload) + }, }, }) diff --git a/resources/scripts/features/admin/modules/views/ModuleDetailView.vue b/resources/scripts/features/admin/modules/views/ModuleDetailView.vue index 039c4a64..271ccf57 100644 --- a/resources/scripts/features/admin/modules/views/ModuleDetailView.vue +++ b/resources/scripts/features/admin/modules/views/ModuleDetailView.vue @@ -136,6 +136,15 @@ {{ $t('modules.enable') }} + + + + {{ $t('modules.uninstall') }} + @@ -326,6 +335,59 @@
+ + + + +
+

{{ $t('modules.uninstall_warning') }}

+ + + +

+ {{ $t('modules.legacy_uninstall_notice') }} +

+
+ + +
@@ -337,7 +399,9 @@ import { useModuleStore } from '../store' import type { InstallationStep } from '../store' import ModuleCard from '../components/ModuleCard.vue' import { useDialogStore } from '../../../../stores/dialog.store' +import { useNotificationStore } from '../../../../stores/notification.store' import type { Module, ModuleLink } from '../../../../types/domain/module' +import { getErrorTranslationKey, handleApiError } from '../../../../utils/error-handling' interface ModuleLinkItem { icon: string @@ -352,6 +416,7 @@ interface TabItem { const moduleStore = useModuleStore() const dialogStore = useDialogStore() +const notificationStore = useNotificationStore() const route = useRoute() const router = useRouter() const { t } = useI18n() @@ -360,6 +425,10 @@ const isFetchingInitialData = ref(true) const isInstalling = ref(false) const isEnabling = ref(false) const isDisabling = ref(false) +const isUninstalling = ref(false) +const showUninstallModal = ref(false) +const removeModuleData = ref(false) +const uninstallConfirmation = ref('') const displayVideo = ref(false) const expandedImage = ref(null) const thumbnail = ref(null) @@ -469,44 +538,92 @@ async function handleInstall(): Promise { } } -function handleDisable(): void { +async function handleDisable(): Promise { if (!moduleData.value) return - dialogStore - .openDialog({ - title: t('general.are_you_sure'), - message: t('modules.disable_warning'), - yesLabel: t('general.ok'), - noLabel: t('general.cancel'), - variant: 'danger', - hideNoButton: false, - size: 'lg', - }) - .then(async (res: boolean) => { - if (res) { - isDisabling.value = true - const response = await moduleStore.disableModule(moduleData.value!.module_name) - isDisabling.value = false + const confirmed = await dialogStore.openDialog({ + title: t('general.are_you_sure'), + message: t('modules.disable_warning'), + yesLabel: t('general.ok'), + noLabel: t('general.cancel'), + variant: 'danger', + hideNoButton: false, + size: 'lg', + }) - if (response.success) { - setTimeout(() => location.reload(), 1500) - } - } - }) + if (!confirmed) return + + isDisabling.value = true + try { + const response = await moduleStore.disableModule(moduleData.value.module_name) + if (response.success) { + setTimeout(() => location.reload(), 1500) + } + } catch (error: unknown) { + showModuleActionError(error) + } finally { + isDisabling.value = false + } } async function handleEnable(): Promise { if (!moduleData.value) return isEnabling.value = true - const res = await moduleStore.enableModule(moduleData.value.module_name) - isEnabling.value = false - - if (res.success) { - setTimeout(() => location.reload(), 1500) + try { + const res = await moduleStore.enableModule(moduleData.value.module_name) + if (res.success) { + setTimeout(() => location.reload(), 1500) + } + } catch (error: unknown) { + showModuleActionError(error) + } finally { + isEnabling.value = false } } +function closeUninstallModal(): void { + showUninstallModal.value = false + removeModuleData.value = false + uninstallConfirmation.value = '' +} + +async function handleUninstall(): Promise { + if (!moduleData.value) return + + isUninstalling.value = true + try { + const response = await moduleStore.uninstallModule(moduleData.value.module_name, { + remove_data: removeModuleData.value, + confirmation: removeModuleData.value ? uninstallConfirmation.value : undefined, + }) + + if (response.success) { + closeUninstallModal() + setTimeout(() => location.reload(), 500) + } + } catch (error: unknown) { + showModuleActionError(error) + } finally { + isUninstalling.value = false + } +} + +function showModuleActionError(error: unknown): void { + const normalizedError = handleApiError(error) + const translationKey = getErrorTranslationKey(normalizedError.message) + + if (normalizedError.message === 'module_runtime_missing' && moduleData.value) { + moduleData.value.installed = false + moduleData.value.enabled = false + } + + notificationStore.showNotification({ + type: 'error', + message: translationKey ?? normalizedError.message, + }) +} + function setDisplayImage(url: string): void { displayVideo.value = false expandedImage.value = url diff --git a/resources/scripts/types/domain/module.ts b/resources/scripts/types/domain/module.ts index 9fac9a68..fa287c88 100644 --- a/resources/scripts/types/domain/module.ts +++ b/resources/scripts/types/domain/module.ts @@ -75,6 +75,7 @@ export interface Module { author_avatar: string installed: boolean enabled: boolean + supports_data_cleanup: boolean update_available: boolean video_link: string | null video_thumbnail: string | null diff --git a/resources/scripts/utils/error-handling.ts b/resources/scripts/utils/error-handling.ts index 1107ba3b..8f115f93 100644 --- a/resources/scripts/utils/error-handling.ts +++ b/resources/scripts/utils/error-handling.ts @@ -100,6 +100,7 @@ const ERROR_TRANSLATION_MAP: Record = { 'invalid_format': 'errors.invalid_format', 'api_error': 'errors.api_error', 'feature_not_enabled': 'errors.feature_not_enabled', + 'module_runtime_missing': 'modules.runtime_missing', 'request_limit_met': 'errors.request_limit_met', 'address_incomplete': 'errors.address_incomplete', 'invalid_address': 'errors.invalid_address', diff --git a/routes/api.php b/routes/api.php index f6ec51ed..d3ca5cc9 100644 --- a/routes/api.php +++ b/routes/api.php @@ -529,6 +529,7 @@ Route::prefix('/v1')->group(function () { Route::get('/{module}', [ModulesController::class, 'show']); Route::post('/{module}/enable', [ModulesController::class, 'enable']); Route::post('/{module}/disable', [ModulesController::class, 'disable']); + Route::post('/{module}/uninstall', [ModuleInstallationController::class, 'uninstall']); Route::post('/install', [ModuleInstallationController::class, 'install']); diff --git a/tests/Feature/Admin/Modules/ModuleLifecycleTest.php b/tests/Feature/Admin/Modules/ModuleLifecycleTest.php new file mode 100644 index 00000000..a0444e4d --- /dev/null +++ b/tests/Feature/Admin/Modules/ModuleLifecycleTest.php @@ -0,0 +1,72 @@ + 'DatabaseSeeder', '--force' => true]); + + $user = User::findOrFail(1); + $this->withHeader('company', $user->companies()->firstOrFail()->id); + Sanctum::actingAs($user, ['*']); +}); + +it('disables a module when its runtime files are missing', function () { + Event::fake([ModuleDisabledEvent::class]); + $module = missingRuntimeModule(enabled: true); + + postJson("/api/v1/modules/{$module->name}/disable") + ->assertOk() + ->assertJsonPath('success', true); + + $module->refresh(); + + expect($module->installed)->toBeFalse() + ->and($module->enabled)->toBeFalse() + ->and($module->state)->toBe('failed') + ->and($module->last_error)->toBe('module_runtime_missing') + ->and($module->last_failed_at)->not->toBeNull(); + + Event::assertDispatched(ModuleDisabledEvent::class); +}); + +it('returns a conflict and repairs state when enabling a module with missing runtime files', function () { + Event::fake([ModuleEnabledEvent::class]); + $module = missingRuntimeModule(enabled: false); + + postJson("/api/v1/modules/{$module->name}/enable") + ->assertConflict() + ->assertJson([ + 'success' => false, + 'error' => 'module_runtime_missing', + ]); + + $module->refresh(); + + expect($module->installed)->toBeFalse() + ->and($module->enabled)->toBeFalse() + ->and($module->state)->toBe('failed') + ->and($module->last_error)->toBe('module_runtime_missing') + ->and($module->last_failed_at)->not->toBeNull(); + + Event::assertNotDispatched(ModuleEnabledEvent::class); +}); + +function missingRuntimeModule(bool $enabled): Module +{ + return Module::query()->create([ + 'name' => 'DefinitelyMissingRuntime', + 'slug' => 'definitely-missing-runtime', + 'version' => '1.0.0', + 'installed' => true, + 'enabled' => $enabled, + 'state' => 'installed', + ]); +} diff --git a/tests/Feature/Admin/Modules/ModuleUninstallTest.php b/tests/Feature/Admin/Modules/ModuleUninstallTest.php new file mode 100644 index 00000000..01592ee6 --- /dev/null +++ b/tests/Feature/Admin/Modules/ModuleUninstallTest.php @@ -0,0 +1,369 @@ + 'DatabaseSeeder', '--force' => true]); + + $user = User::findOrFail(1); + $this->withHeader('company', $user->companies()->firstOrFail()->id); + Sanctum::actingAs($user, ['*']); +}); + +afterEach(function () { + File::deleteDirectory(base_path('Modules/UninstallDependent')); + File::deleteDirectory(base_path('Modules/UninstallRuntime')); + File::deleteDirectory(base_path('Modules/.backups')); + Schema::dropIfExists('uninstall_runtime_trace'); +}); + +it('removes a real runtime while preserving code-only uninstall data and migration rows', function () { + $module = runtimeFixture(schemaVersion: 1); + $migration = '2026_08_05_000001_uninstall_runtime_first'; + DB::table('migrations')->insert(['migration' => $migration, 'batch' => 1]); + DB::table('company_settings')->insert([ + 'company_id' => User::findOrFail(1)->companies()->firstOrFail()->id, + 'option' => 'module.uninstall-runtime.enabled', + 'value' => '1', + ]); + + postJson("/api/v1/modules/{$module->name}/uninstall", ['remove_data' => false]) + ->assertOk() + ->assertJsonPath('success', true); + + expect(base_path('Modules/UninstallRuntime'))->not->toBeDirectory() + ->and(DB::table('migrations')->where('migration', $migration)->exists())->toBeTrue() + ->and(DB::table('company_settings')->where('option', 'module.uninstall-runtime.enabled')->exists())->toBeTrue(); +}); + +it('reconciles a missing runtime through a code-only uninstall', function () { + Event::fake([ModuleUninstalledEvent::class]); + $module = installedModuleForUninstall(); + + postJson("/api/v1/modules/{$module->name}/uninstall", ['remove_data' => false]) + ->assertOk() + ->assertJsonPath('success', true); + + $module->refresh(); + + expect($module->installed)->toBeFalse() + ->and($module->enabled)->toBeFalse() + ->and($module->state)->toBe('uninstalled'); + + Event::assertDispatched(ModuleUninstalledEvent::class); +}); + +it('fails destructive uninstall safely when the module runtime is missing', function () { + $module = installedModuleForUninstall(); + + postJson("/api/v1/modules/{$module->name}/uninstall", [ + 'remove_data' => true, + 'confirmation' => $module->name, + ]) + ->assertConflict() + ->assertJsonPath('error', 'module_runtime_missing'); + + $module->refresh(); + + expect($module->installed)->toBeTrue() + ->and($module->enabled)->toBeFalse() + ->and($module->state)->toBe('failed') + ->and($module->last_error)->toStartWith('module_runtime_missing:'); +}); + +it('requires an exact module name before destructive uninstall', function () { + $module = installedModuleForUninstall(); + + postJson("/api/v1/modules/{$module->name}/uninstall", [ + 'remove_data' => true, + 'confirmation' => 'wrong-name', + ]) + ->assertUnprocessable() + ->assertJsonValidationErrors('confirmation'); +}); + +it('blocks uninstall when an installed module depends on it', function () { + $module = installedModuleForUninstall(); + File::ensureDirectoryExists(base_path('Modules/UninstallDependent')); + File::put(base_path('Modules/UninstallDependent/module.json'), json_encode([ + 'slug' => 'uninstall-dependent', + 'module_dependencies' => ['uninstall-probe' => '^1.0.0'], + ], JSON_THROW_ON_ERROR)); + Module::query()->create([ + 'name' => 'UninstallDependent', + 'slug' => 'uninstall-dependent', + 'version' => '1.0.0', + 'installed' => true, + 'enabled' => true, + 'state' => 'installed', + ]); + + postJson("/api/v1/modules/{$module->name}/uninstall", ['remove_data' => false]) + ->assertConflict() + ->assertJsonPath('error', 'dependent_modules_installed'); + + $module->refresh(); + + expect($module->installed)->toBeTrue() + ->and($module->enabled)->toBeTrue() + ->and($module->state)->toBe('installed'); +}); + +it('does not start an uninstall while another marketplace operation holds the lease', function () { + $module = installedModuleForUninstall(); + MarketplaceOperation::query()->create([ + 'lock_name' => 'marketplace-install', + 'slug' => 'other-module', + 'version' => '1.0.0', + 'channel' => 'stable', + 'status' => 'running', + 'started_at' => now(), + 'expires_at' => now()->addMinute(), + ]); + + postJson("/api/v1/modules/{$module->name}/uninstall", ['remove_data' => false]) + ->assertConflict() + ->assertJsonPath('error', 'operation_in_progress'); + + $module->refresh(); + + expect($module->enabled)->toBeTrue() + ->and($module->state)->toBe('installed'); +}); + +it('exposes cleanup capability only for schema-v2 cleanup modules', function () { + requireSdk32(); + $module = runtimeFixture(schemaVersion: 2, cleanupClass: 'Modules\\UninstallRuntime\\Providers\\UninstallRuntimeServiceProvider'); + $payload = (object) [ + 'module_name' => $module->name, + 'slug' => $module->slug, + 'name' => 'Uninstall Runtime', + ]; + + $data = (new ModuleResource($payload))->toArray(request()); + + expect($data['supports_data_cleanup'])->toBeTrue(); +}); + +it('cleans data before resetting all migrations and removes all module settings', function () { + requireSdk32(); + $module = runtimeFixture(schemaVersion: 2, cleanupClass: 'Modules\\UninstallRuntime\\Providers\\UninstallRuntimeServiceProvider'); + Schema::create('uninstall_runtime_trace', fn ($table) => $table->string('step')); + DB::table('migrations')->insert(['migration' => '2026_08_05_000001_uninstall_runtime_first', 'batch' => 1]); + DB::table('migrations')->insert(['migration' => '2026_08_05_000002_uninstall_runtime_second', 'batch' => 2]); + $firstCompany = User::findOrFail(1)->companies()->firstOrFail(); + $secondCompany = Company::factory()->create(); + DB::table('company_settings')->insert([ + 'company_id' => $firstCompany->id, + 'option' => 'module.uninstall-runtime.enabled', + 'value' => '1', + ]); + DB::table('company_settings')->insert([ + 'company_id' => $secondCompany->id, + 'option' => 'module.uninstall-runtime.enabled', + 'value' => '1', + ]); + DB::table('company_settings')->insert([ + 'company_id' => $firstCompany->id, + 'option' => 'module.unrelated-module.enabled', + 'value' => 'keep', + ]); + + postJson("/api/v1/modules/{$module->name}/uninstall", [ + 'remove_data' => true, + 'confirmation' => $module->name, + ])->assertOk(); + + expect(DB::table('uninstall_runtime_trace')->pluck('step')->all())->toBe(['cleanup', 'down:second', 'down:first']) + ->and(DB::table('migrations')->where('migration', 'like', '%uninstall_runtime%')->exists())->toBeFalse() + ->and(DB::table('company_settings')->where('option', 'like', 'module.uninstall-runtime.%')->exists())->toBeFalse() + ->and(DB::table('company_settings')->where('option', 'module.unrelated-module.enabled')->value('value'))->toBe('keep') + ->and(base_path('Modules/UninstallRuntime'))->not->toBeDirectory(); +}); + +it('restores a failed cleanup runtime and allows a retry', function () { + requireSdk32(); + $module = runtimeFixture(schemaVersion: 2, cleanupClass: 'Modules\\UninstallRuntime\\Providers\\UninstallRuntimeServiceProvider'); + Schema::create('uninstall_runtime_trace', fn ($table) => $table->string('step')); + config()->set('uninstall_runtime.cleanup_throws', true); + + postJson("/api/v1/modules/{$module->name}/uninstall", [ + 'remove_data' => true, + 'confirmation' => $module->name, + ])->assertUnprocessable()->assertJsonPath('error', 'uninstall_failed'); + + $module->refresh(); + expect(base_path('Modules/UninstallRuntime'))->toBeDirectory() + ->and($module->installed)->toBeTrue() + ->and($module->enabled)->toBeFalse() + ->and($module->state)->toBe('failed'); + + config()->set('uninstall_runtime.cleanup_throws', false); + postJson("/api/v1/modules/{$module->name}/uninstall", [ + 'remove_data' => true, + 'confirmation' => $module->name, + ])->assertOk(); +}); + +it('restores a failed migration reset runtime and allows a retry', function () { + requireSdk32(); + $module = runtimeFixture(schemaVersion: 2, cleanupClass: 'Modules\\UninstallRuntime\\Providers\\UninstallRuntimeServiceProvider'); + Schema::create('uninstall_runtime_trace', fn ($table) => $table->string('step')); + DB::table('migrations')->insert(['migration' => '2026_08_05_000001_uninstall_runtime_first', 'batch' => 1]); + config()->set('uninstall_runtime.down_throws', true); + + postJson("/api/v1/modules/{$module->name}/uninstall", [ + 'remove_data' => true, + 'confirmation' => $module->name, + ])->assertUnprocessable()->assertJsonPath('error', 'uninstall_failed'); + + $module->refresh(); + expect(base_path('Modules/UninstallRuntime'))->toBeDirectory() + ->and($module->installed)->toBeTrue() + ->and($module->enabled)->toBeFalse() + ->and($module->state)->toBe('failed'); + + config()->set('uninstall_runtime.down_throws', false); + postJson("/api/v1/modules/{$module->name}/uninstall", [ + 'remove_data' => true, + 'confirmation' => $module->name, + ])->assertOk(); +}); + +it('registers only the safe module uninstall command', function () { + expect(Artisan::all())->toHaveKey('module:uninstall') + ->not->toHaveKey('module:delete'); +}); + +function installedModuleForUninstall(): Module +{ + return Module::query()->create([ + 'name' => 'UninstallProbe', + 'slug' => 'uninstall-probe', + 'version' => '1.0.0', + 'installed' => true, + 'enabled' => true, + 'state' => 'installed', + ]); +} + +function runtimeFixture(int $schemaVersion, ?string $cleanupClass = null): Module +{ + $path = base_path('Modules/UninstallRuntime'); + File::ensureDirectoryExists($path.'/app/Providers'); + File::ensureDirectoryExists($path.'/database/migrations'); + + $manifest = [ + 'name' => 'UninstallRuntime', + 'alias' => 'uninstall_runtime', + 'description' => 'Uninstall runtime test fixture', + 'keywords' => [], + 'priority' => 0, + 'providers' => ['Modules\\UninstallRuntime\\Providers\\UninstallRuntimeServiceProvider'], + 'aliases' => [], + 'files' => [], + 'requires' => [], + 'schema_version' => $schemaVersion, + 'slug' => 'uninstall-runtime', + 'version' => '1.0.0', + 'license' => 'AGPL-3.0-only', + 'compatibility' => ['invoiceshelf' => '^3.0.0', 'module_api' => '^1.1.0', 'php' => '^8.4.0', 'extensions' => []], + 'module_dependencies' => [], + 'migration_policy' => $schemaVersion === 2 ? 'reversible' : 'forward-only', + 'dependency_policy' => 'host-provided-only', + 'assets' => [], + ]; + if ($schemaVersion === 2) { + $manifest['uninstall'] = ['data_cleanup' => $cleanupClass]; + } + File::put($path.'/module.json', json_encode($manifest, JSON_THROW_ON_ERROR)); + File::put($path.'/app/Providers/UninstallRuntimeServiceProvider.php', runtimeProviderSource($schemaVersion)); + foreach (['first', 'second'] as $step) { + File::put($path.'/database/migrations/2026_08_05_00000'.($step === 'first' ? '1' : '2').'_uninstall_runtime_'.$step.'.php', migrationSource($step)); + } + + ModuleRuntimeAutoloader::register('UninstallRuntime'); + RuntimeModule::register(); + + return Module::query()->updateOrCreate(['name' => 'UninstallRuntime'], [ + 'slug' => 'uninstall-runtime', + 'version' => '1.0.0', + 'installed' => true, + 'enabled' => true, + 'state' => 'installed', + ]); +} + +function runtimeProviderSource(int $schemaVersion): string +{ + $contract = $schemaVersion === 2 ? ' implements \\InvoiceShelf\\Modules\\Contracts\\DataCleanup' : ''; + $cleanup = $schemaVersion === 2 ? <<<'PHP' + + public function cleanup(): void + { + if (config('uninstall_runtime.cleanup_throws')) { + throw new RuntimeException('cleanup failed'); + } + + DB::table('uninstall_runtime_trace')->insert(['step' => 'cleanup']); + } +PHP : ''; + + return "insert(['step' => 'down:{$step}']); + } +}; +"; +} + +function requireSdk32(): void +{ + if (! interface_exists('InvoiceShelf\\Modules\\Contracts\\DataCleanup')) { + test()->markTestSkipped('Requires invoiceshelf/modules ^3.2.'); + } +} diff --git a/tests/Feature/Company/Modules/ModuleMakeStubTest.php b/tests/Feature/Company/Modules/ModuleMakeStubTest.php index e30f8003..ca6857e4 100644 --- a/tests/Feature/Company/Modules/ModuleMakeStubTest.php +++ b/tests/Feature/Company/Modules/ModuleMakeStubTest.php @@ -54,7 +54,7 @@ test('module:make generates a composer.json that requires invoiceshelf/modules', $manifest = json_decode(File::get($composerPath), true); expect($manifest['require'] ?? [])->toHaveKey('invoiceshelf/modules'); - expect($manifest['require']['invoiceshelf/modules'])->toBe('^3.0'); + expect($manifest['require']['invoiceshelf/modules'])->toBe('^3.2'); }); test('module:make generates starter lang files for menu and settings', function () { diff --git a/tests/Feature/Marketplace/MarketplaceInstallerTest.php b/tests/Feature/Marketplace/MarketplaceInstallerTest.php index 3c22425a..06cb8dba 100644 --- a/tests/Feature/Marketplace/MarketplaceInstallerTest.php +++ b/tests/Feature/Marketplace/MarketplaceInstallerTest.php @@ -23,6 +23,43 @@ it('installs an exact signed marketplace archive', function () { ->and(Module::query()->where('name', 'SecureProbe')->value('version'))->toBe('1.0.0'); }); +it('reinstalls the same version after a code-only uninstall record', function () { + Module::query()->create([ + 'name' => 'SecureProbe', + 'slug' => 'secure-probe', + 'version' => '1.0.0', + 'installed' => false, + 'enabled' => false, + 'state' => 'uninstalled', + ]); + [$archive, $manifest, $keypair] = marketplaceRelease(); + fakeMarketplaceRelease($archive, $manifest, $keypair); + + $result = app(MarketplaceInstaller::class)->install('secure-probe', '1.0.0', 'stable'); + + expect($result['success'])->toBeTrue() + ->and(Module::query()->where('name', 'SecureProbe')->value('installed'))->toBeTrue() + ->and(Module::query()->where('name', 'SecureProbe')->value('version'))->toBe('1.0.0'); +}); + +it('rejects reinstalling the same version while it remains installed', function () { + Module::query()->create([ + 'name' => 'SecureProbe', + 'slug' => 'secure-probe', + 'version' => '1.0.0', + 'installed' => true, + 'enabled' => false, + 'state' => 'installed', + ]); + [$archive, $manifest, $keypair] = marketplaceRelease(); + fakeMarketplaceRelease($archive, $manifest, $keypair); + + $result = app(MarketplaceInstaller::class)->install('secure-probe', '1.0.0', 'stable'); + + expect($result['success'])->toBeFalse() + ->and($result['error'])->toContain('reinstalling the same release'); +}); + it('rejects a release signed by an unknown key before downloading an artifact', function () { [$archive, $manifest, $keypair] = marketplaceRelease(); fakeMarketplaceRelease($archive, $manifest, $keypair);