mirror of
https://github.com/InvoiceShelf/InvoiceShelf.git
synced 2026-09-01 21:00:58 +00:00
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:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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')]),
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
@@ -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(),
|
||||
|
||||
/*
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user