feat(modules): add safe module uninstall lifecycle (#746)

* fix(modules): recover from missing runtime files

* feat(modules): add safe uninstall lifecycle

* build(deps): lock module SDK 3.2.0

* test(modules): expect SDK 3.2 scaffold constraint
This commit is contained in:
Darko Gjorgjijoski
2026-08-05 12:15:29 +02:00
committed by GitHub
parent 8579e4f85f
commit a34bf0909f
27 changed files with 1251 additions and 82 deletions
@@ -0,0 +1,41 @@
<?php
namespace App\Console\Commands;
use App\Services\Marketplace\MarketplaceUninstaller;
use Illuminate\Console\Command;
class UninstallModuleCommand extends Command
{
protected $signature = 'module:uninstall
{module : The installed module name}
{--remove-data : Run developer cleanup, reset reversible migrations, then remove module settings}
{--force : Skip the interactive safety confirmation}';
protected $description = 'Safely uninstall a marketplace module';
public function handle(MarketplaceUninstaller $uninstaller): int
{
$module = (string) $this->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;
}
}
+17
View File
@@ -0,0 +1,17 @@
<?php
namespace App\Events;
use App\Models\Module;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class ModuleUninstalledEvent
{
use Dispatchable;
use InteractsWithSockets;
use SerializesModels;
public function __construct(public Module $module) {}
}
@@ -4,7 +4,9 @@ namespace App\Http\Controllers\Admin\Modules;
use App\Http\Controllers\Controller;
use App\Http\Requests\InstallMarketplaceModuleRequest;
use App\Http\Requests\UninstallMarketplaceModuleRequest;
use App\Services\Marketplace\MarketplaceInstaller;
use App\Services\Marketplace\MarketplaceUninstaller;
use Illuminate\Http\JsonResponse;
class ModuleInstallationController extends Controller
@@ -21,4 +23,23 @@ class ModuleInstallationController extends Controller
return response()->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);
}
}
@@ -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(),
]);
}
}
@@ -0,0 +1,29 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class UninstallMarketplaceModuleRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/** @return array<string, list<string|\Stringable>> */
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')]),
],
];
}
}
+38
View File
@@ -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;
}
}
}
@@ -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
@@ -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);
@@ -0,0 +1,46 @@
<?php
namespace App\Services\Marketplace;
use App\Models\MarketplaceOperation;
use Throwable;
class MarketplaceOperationLease
{
public function acquire(?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;
}
}
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(),
]);
}
}
@@ -0,0 +1,300 @@
<?php
namespace App\Services\Marketplace;
use App\Events\ModuleUninstalledEvent;
use App\Models\CompanySetting;
use App\Models\Module as InstalledModule;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\File;
use Illuminate\Support\ServiceProvider;
use Illuminate\Support\Str;
use Nwidart\Modules\Facades\Module;
use RuntimeException;
use Throwable;
class MarketplaceUninstaller
{
public function __construct(private MarketplaceOperationLease $operations) {}
/**
* @return array{success: bool, operation_id?: int, error?: string}
*/
public function uninstall(string $name, bool $removeData, ?string $confirmation = null): array
{
$module = InstalledModule::query()->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<string> */
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<string, mixed> */
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);
}
}
+1 -1
View File
@@ -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",
Generated
+13 -9
View File
@@ -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",
+1 -1
View File
@@ -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.
+4 -1
View File
@@ -1,7 +1,9 @@
<?php
use App\Console\Commands\UninstallModuleCommand;
use App\Services\Marketplace\DatabaseActivator;
use Nwidart\Modules\Activators\FileActivator;
use Nwidart\Modules\Commands\Actions\ModuleDeleteCommand;
use Nwidart\Modules\Providers\ConsoleServiceProvider;
return [
@@ -169,8 +171,9 @@ return [
|
*/
'commands' => ConsoleServiceProvider::defaultCommands()
->reject(fn (string $command): bool => $command === ModuleDeleteCommand::class)
->merge([
// InvoiceShelf-specific module commands go here
UninstallModuleCommand::class,
])->toArray(),
/*
+7
View File
@@ -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",
+1
View File
@@ -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
+1 -1
View File
@@ -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'
@@ -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
},
}
@@ -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)
},
},
})
@@ -136,6 +136,15 @@
{{ $t('modules.enable') }}
</BaseButton>
</div>
<BaseButton
variant="primary-outline"
class="mt-3 w-full flex items-center justify-center"
@click="showUninstallModal = true"
>
<BaseIcon name="TrashIcon" class="mr-1.5 h-4 w-4" />
{{ $t('modules.uninstall') }}
</BaseButton>
</template>
<!-- Installation Steps -->
@@ -326,6 +335,59 @@
</div>
<div class="p-6" />
<BaseModal :show="showUninstallModal" @close="closeUninstallModal">
<template #header>
<div class="flex w-full items-center justify-between">
{{ $t('modules.uninstall') }} {{ moduleData.name }}
<BaseIcon name="XMarkIcon" class="h-5 w-5 cursor-pointer text-muted" @click="closeUninstallModal" />
</div>
</template>
<div class="space-y-4 p-6">
<p class="text-sm text-muted">{{ $t('modules.uninstall_warning') }}</p>
<template v-if="moduleData.supports_data_cleanup">
<label class="flex items-start gap-3 text-sm text-heading">
<input v-model="removeModuleData" type="checkbox" class="mt-1 h-4 w-4" />
<span>
<span class="font-medium">{{ $t('modules.remove_data') }}</span>
<span class="block text-muted">{{ $t('modules.remove_data_warning') }}</span>
</span>
</label>
<label v-if="removeModuleData" class="block text-sm font-medium text-heading">
{{ $t('modules.confirm_module_name', { name: moduleData.module_name }) }}
<input
v-model="uninstallConfirmation"
type="text"
class="mt-2 w-full rounded-md border border-line-default bg-surface px-3 py-2 text-heading"
:placeholder="moduleData.module_name"
/>
</label>
</template>
<p v-else class="rounded-md bg-surface-tertiary p-3 text-sm text-muted">
{{ $t('modules.legacy_uninstall_notice') }}
</p>
</div>
<template #footer>
<div class="flex justify-end gap-3 border-t border-line-default px-6 py-4">
<BaseButton variant="primary-outline" @click="closeUninstallModal">
{{ $t('general.cancel') }}
</BaseButton>
<BaseButton
variant="danger"
:loading="isUninstalling"
:disabled="isUninstalling || (removeModuleData && uninstallConfirmation !== moduleData.module_name)"
@click="handleUninstall"
>
{{ $t('modules.uninstall') }}
</BaseButton>
</div>
</template>
</BaseModal>
</BasePage>
</template>
@@ -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<boolean>(true)
const isInstalling = ref<boolean>(false)
const isEnabling = ref<boolean>(false)
const isDisabling = ref<boolean>(false)
const isUninstalling = ref<boolean>(false)
const showUninstallModal = ref<boolean>(false)
const removeModuleData = ref<boolean>(false)
const uninstallConfirmation = ref<string>('')
const displayVideo = ref<boolean>(false)
const expandedImage = ref<string | null>(null)
const thumbnail = ref<string | null>(null)
@@ -469,44 +538,92 @@ async function handleInstall(): Promise<void> {
}
}
function handleDisable(): void {
async function handleDisable(): Promise<void> {
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<void> {
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<void> {
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
+1
View File
@@ -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
@@ -100,6 +100,7 @@ const ERROR_TRANSLATION_MAP: Record<string, string> = {
'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',
+1
View File
@@ -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']);
@@ -0,0 +1,72 @@
<?php
use App\Events\ModuleDisabledEvent;
use App\Events\ModuleEnabledEvent;
use App\Models\Module;
use App\Models\User;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Event;
use Laravel\Sanctum\Sanctum;
use function Pest\Laravel\postJson;
beforeEach(function () {
Artisan::call('db:seed', ['--class' => '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',
]);
}
@@ -0,0 +1,369 @@
<?php
use App\Events\ModuleUninstalledEvent;
use App\Http\Resources\ModuleResource;
use App\Models\Company;
use App\Models\MarketplaceOperation;
use App\Models\Module;
use App\Models\User;
use App\Services\Marketplace\ModuleRuntimeAutoloader;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Schema;
use Laravel\Sanctum\Sanctum;
use Nwidart\Modules\Facades\Module as RuntimeModule;
use function Pest\Laravel\postJson;
beforeEach(function () {
Artisan::call('db:seed', ['--class' => '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 "<?php
namespace Modules\\UninstallRuntime\\Providers;
use Illuminate\\Support\\Facades\\DB;
use Illuminate\\Support\\ServiceProvider;
use RuntimeException;
class UninstallRuntimeServiceProvider extends ServiceProvider{$contract}
{{$cleanup}
}
";
}
function migrationSource(string $step): string
{
return "<?php
use Illuminate\\Database\\Migrations\\Migration;
use Illuminate\\Support\\Facades\\DB;
return new class extends Migration
{
public function up(): void {}
public function down(): void
{
if (config('uninstall_runtime.down_throws')) {
throw new RuntimeException('migration reset failed');
}
DB::table('uninstall_runtime_trace')->insert(['step' => 'down:{$step}']);
}
};
";
}
function requireSdk32(): void
{
if (! interface_exists('InvoiceShelf\\Modules\\Contracts\\DataCleanup')) {
test()->markTestSkipped('Requires invoiceshelf/modules ^3.2.');
}
}
@@ -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 () {
@@ -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);