* wip

* fix plugin uninstall

* marketplace
This commit is contained in:
Saeed Vaziry
2025-06-19 14:07:15 +02:00
committed by GitHub
parent 131b828807
commit 342a3aa4c6
35 changed files with 1973 additions and 934 deletions

View File

@ -0,0 +1,36 @@
<?php
namespace App\Console\Commands\Plugins;
use App\Facades\Plugins;
use Exception;
use Illuminate\Console\Command;
class InstallPluginCommand extends Command
{
protected $signature = 'plugins:install {url} {--branch=} {--tag=}';
protected $description = 'Install a plugin from a repository';
/**
* @throws Exception
*/
public function handle(): void
{
$url = $this->argument('url');
$branch = $this->option('branch');
$tag = $this->option('tag');
$this->info('Installing plugin from '.$url);
try {
Plugins::install($url, $branch, $tag);
} catch (Exception $e) {
$this->output->error($e->getMessage());
return;
}
$this->info('Plugin installed successfully');
}
}

View File

@ -0,0 +1,34 @@
<?php
namespace App\Console\Commands\Plugins;
use App\Facades\Plugins;
use Exception;
use Illuminate\Console\Command;
class LoadPluginsCommand extends Command
{
protected $signature = 'plugins:load';
protected $description = 'Load all plugins from the storage/plugins directory';
/**
* @throws Exception
*/
public function handle(): void
{
$this->info('Loading plugins...');
try {
Plugins::load();
} catch (Exception $e) {
$this->output->error($e->getMessage());
return;
}
Plugins::cleanup();
$this->info('Plugins loaded successfully.');
}
}

View File

@ -0,0 +1,18 @@
<?php
namespace App\Console\Commands\Plugins;
use App\Facades\Plugins;
use Illuminate\Console\Command;
class PluginsListCommand extends Command
{
protected $signature = 'plugins:list';
protected $description = 'List all installed plugins';
public function handle(): void
{
$this->table(['Name', 'Version'], Plugins::all());
}
}

View File

@ -0,0 +1,29 @@
<?php
namespace App\Console\Commands\Plugins;
use App\Facades\Plugins;
use Exception;
use Illuminate\Console\Command;
class UninstallPluginCommand extends Command
{
protected $signature = 'plugins:uninstall {name}';
protected $description = 'Uninstall a plugin by name';
public function handle(): void
{
$this->info('Uninstalling '.$this->argument('name').'...');
try {
Plugins::uninstall($this->argument('name'));
} catch (Exception $e) {
$this->output->error($e->getMessage());
return;
}
$this->info('Plugin uninstalled successfully.');
}
}

20
app/Facades/Plugins.php Normal file
View File

@ -0,0 +1,20 @@
<?php
namespace App\Facades;
use Illuminate\Support\Facades\Facade;
/**
* @method static mixed all()
* @method static string install(string $url, ?string $branch = null, ?string $tag = null)
* @method static string load()
* @method static string uninstall(string $name)
* @method static void cleanup()
*/
class Plugins extends Facade
{
protected static function getFacadeAccessor(): string
{
return 'plugins';
}
}

View File

@ -0,0 +1,89 @@
<?php
namespace App\Http\Controllers;
use App\Facades\Plugins;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
use Spatie\RouteAttributes\Attributes\Delete;
use Spatie\RouteAttributes\Attributes\Get;
use Spatie\RouteAttributes\Attributes\Middleware;
use Spatie\RouteAttributes\Attributes\Post;
use Spatie\RouteAttributes\Attributes\Prefix;
use Throwable;
#[Prefix('settings/plugins')]
#[Middleware(['auth', 'must-be-admin'])]
class PluginController extends Controller
{
#[Get('/', name: 'plugins')]
public function index(): Response
{
$plugins = [];
try {
$plugins = Plugins::all();
} catch (Throwable $e) {
report($e);
}
return Inertia::render('plugins/index', [
'plugins' => $plugins,
]);
}
#[Post('/install', name: 'plugins.install')]
public function install(Request $request): RedirectResponse
{
$this->validate($request, [
'url' => 'required|url',
]);
if (! composer_path() || ! php_path()) {
return back()->with('error', 'Use CLI to install plugins.');
}
$url = $request->input('url');
dispatch(function () use ($url) {
try {
Plugins::install($url);
} catch (Throwable $e) {
//
}
Plugins::cleanup();
})
->onConnection('default');
return back()->with('info', 'Plugin is being installed...');
}
#[Delete('/uninstall', name: 'plugins.uninstall')]
public function uninstall(Request $request): RedirectResponse
{
$this->validate($request, [
'name' => 'required|string',
]);
if (! composer_path() || ! php_path()) {
return back()->with('error', 'Use CLI to uninstall plugins.');
}
$name = $request->input('name');
dispatch(function () use ($name) {
try {
Plugins::uninstall($name);
} catch (Throwable) {
//
}
Plugins::cleanup();
})
->onConnection('default');
return back()->with('warning', 'Plugin is being uninstalled...');
}
}

View File

@ -17,7 +17,7 @@
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use ZipArchive;
#[Prefix('vito')]
#[Prefix('settings/vito')]
#[Middleware(['auth', 'must-be-admin'])]
class VitoSettingController extends Controller
{

151
app/Plugins/Plugins.php Normal file
View File

@ -0,0 +1,151 @@
<?php
namespace App\Plugins;
use Exception;
use Illuminate\Contracts\Filesystem\FileNotFoundException;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Process;
class Plugins
{
/**
* @return array<array<string, string>>
*
* @throws FileNotFoundException
*/
public function all(): array
{
$plugins = [];
foreach (File::directories(plugins_path()) as $vendorDir) {
foreach (File::directories($vendorDir) as $pluginDir) {
$pluginComposer = $pluginDir.'/composer.json';
if (File::exists($pluginComposer)) {
$json = json_decode(File::get($pluginComposer), true);
$plugins[] = [
'name' => $json['name'] ?? 'Unknown',
'version' => $json['version'] ?? 'Unknown',
];
}
}
}
return $plugins;
}
/**
* @throws Exception
*/
public function install(string $url, ?string $branch = null, ?string $tag = null): string
{
$vendor = str($url)->beforeLast('/')->afterLast('/');
$name = str($url)->afterLast('/');
if (is_dir(storage_path("plugins/$vendor/$name"))) {
File::deleteDirectory(storage_path("plugins/$vendor/$name"));
}
$command = "git clone $url ".storage_path("plugins/$vendor/$name");
if ($branch) {
$command .= " --branch $branch";
}
if ($tag) {
$command .= " --tag $tag";
}
$command .= ' --single-branch';
$result = Process::timeout(0)->run($command);
$output = $result->output();
if ($result->failed()) {
throw new Exception($output);
}
$output .= $this->load();
return $output;
}
/**
* @throws Exception
*/
public function load(): string
{
$storagePath = storage_path('plugins');
$composerJson = base_path('composer.json');
$composerLock = base_path('composer.lock');
// Backup composer files
File::copy($composerJson, $composerJson.'.bak');
File::copy($composerLock, $composerLock.'.bak');
$output = '';
foreach (File::directories($storagePath) as $vendorDir) {
foreach (File::directories($vendorDir) as $pluginDir) {
$pluginComposer = $pluginDir.'/composer.json';
if (File::exists($pluginComposer)) {
$json = json_decode(File::get($pluginComposer), true);
if (isset($json['name'])) {
$name = $json['name'];
// name must be in vendor/plugin format
if (! str_contains($name, '/')) {
continue;
}
$phpPath = php_path();
$composerPath = composer_path();
$result = Process::timeout(0)
->env(['PATH' => dirname($phpPath).':'.dirname($composerPath)])
->path(base_path())
->run(composer_path()." require $name");
if ($result->failed()) {
throw new Exception($result->errorOutput());
}
$output .= $result->output();
}
}
}
}
return $output;
}
/**
* @throws Exception
*/
public function uninstall(string $name): string
{
$pluginPath = storage_path('plugins/'.$name);
if (! File::exists($pluginPath)) {
throw new Exception("Plugin not found: $name");
}
$result = Process::timeout(0)
->path(base_path())
->run("composer remove $name");
if ($result->failed()) {
throw new Exception($result->output());
}
File::deleteDirectory($pluginPath);
return $result->output();
}
public function cleanup(): void
{
$composerJson = base_path('composer.json');
$composerLock = base_path('composer.lock');
if (File::exists($composerJson.'.bak')) {
File::move($composerJson.'.bak', $composerJson);
}
if (File::exists($composerLock.'.bak')) {
File::move($composerLock.'.bak', $composerLock);
}
}
}

View File

@ -6,6 +6,7 @@
use App\Helpers\Notifier;
use App\Helpers\SSH;
use App\Models\PersonalAccessToken;
use App\Plugins\Plugins;
use Illuminate\Http\Resources\Json\ResourceCollection;
use Illuminate\Support\ServiceProvider;
use Laravel\Fortify\Fortify;
@ -29,6 +30,7 @@ public function boot(): void
$this->app->bind('ssh', fn (): SSH => new SSH);
$this->app->bind('notifier', fn (): Notifier => new Notifier);
$this->app->bind('ftp', fn (): FTP => new FTP);
$this->app->bind('plugins', fn (): Plugins => new Plugins);
Sanctum::usePersonalAccessTokenModel(PersonalAccessToken::class);
}

View File

@ -0,0 +1,30 @@
<?php
namespace App\Providers;
use App\Console\Commands\Plugins\InstallPluginCommand;
use App\Console\Commands\Plugins\LoadPluginsCommand;
use App\Console\Commands\Plugins\PluginsListCommand;
use App\Plugins\Plugins;
use Illuminate\Support\ServiceProvider;
class PluginsServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->app->bind('plugins', function () {
return new Plugins;
});
}
public function boot(): void
{
if ($this->app->runningInConsole()) {
$this->commands([
InstallPluginCommand::class,
LoadPluginsCommand::class,
PluginsListCommand::class,
]);
}
}
}

View File

@ -5,11 +5,7 @@
use App\DTOs\DynamicField;
use App\DTOs\DynamicForm;
use App\Enums\LoadBalancerMethod;
use App\Plugins\RegisterSiteFeature;
use App\Plugins\RegisterSiteFeatureAction;
use App\Plugins\RegisterSiteType;
use App\SiteFeatures\LaravelOctane\Disable;
use App\SiteFeatures\LaravelOctane\Enable;
use App\SiteTypes\Laravel;
use App\SiteTypes\LoadBalancer;
use App\SiteTypes\PHPBlank;
@ -114,29 +110,6 @@ private function laravel(): void
->default(false),
]))
->register();
RegisterSiteFeature::make('laravel', 'laravel-octane')
->label('Laravel Octane')
->description('Enable Laravel Octane for this site')
->register();
RegisterSiteFeatureAction::make('laravel', 'laravel-octane', 'enable')
->label('Enable')
->form(DynamicForm::make([
DynamicField::make('alert')
->alert()
->label('Alert')
->description('Make sure you have already set the `OCTANE_SERVER` in your `.env` file'),
DynamicField::make('port')
->text()
->label('Octane Port')
->default(8000)
->description('The port on which Laravel Octane will run.'),
]))
->handler(Enable::class)
->register();
RegisterSiteFeatureAction::make('laravel', 'laravel-octane', 'disable')
->label('Disable')
->handler(Disable::class)
->register();
}
public function loadBalancer(): void

View File

@ -1,62 +0,0 @@
<?php
namespace App\SiteFeatures\LaravelOctane;
use App\Actions\Worker\DeleteWorker;
use App\DTOs\DynamicField;
use App\DTOs\DynamicForm;
use App\Models\Worker;
use App\SiteFeatures\Action;
use Illuminate\Http\Request;
class Disable extends Action
{
public function name(): string
{
return 'Disable';
}
public function active(): bool
{
return data_get($this->site->type_data, 'octane', false);
}
public function form(): ?DynamicForm
{
return DynamicForm::make([
DynamicField::make('port')
->text()
->default(8000),
]);
}
public function handle(Request $request): void
{
$typeData = $this->site->type_data ?? [];
/** @var ?Worker $worker */
$worker = $this->site->workers()->where('name', 'laravel-octane')->first();
if ($worker) {
app(DeleteWorker::class)->delete($worker);
}
data_set($typeData, 'octane', false);
data_set($typeData, 'octane_port', $request->input('port'));
$this->site->type_data = $typeData;
$this->site->save();
$webserver = $this->site->webserver()->id();
if ($webserver === 'nginx') {
$this->site->webserver()->updateVHost(
$this->site,
replace: [
'laravel-octane-map' => '',
'laravel-octane' => view('ssh.services.webserver.nginx.vhost-blocks.php', ['site' => $this->site]),
],
);
}
$request->session()->flash('success', 'Laravel Octane has been disabled for this site.');
}
}

View File

@ -1,109 +0,0 @@
<?php
namespace App\SiteFeatures\LaravelOctane;
use App\Actions\Worker\CreateWorker;
use App\Actions\Worker\ManageWorker;
use App\DTOs\DynamicField;
use App\DTOs\DynamicForm;
use App\Exceptions\SSHError;
use App\Models\Worker;
use App\SiteFeatures\Action;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Validator;
use RuntimeException;
class Enable extends Action
{
public function name(): string
{
return 'Enable';
}
public function active(): bool
{
return ! data_get($this->site->type_data, 'octane', false);
}
public function form(): ?DynamicForm
{
return DynamicForm::make([
DynamicField::make('port')
->text()
->default(8000),
]);
}
/**
* @throws SSHError
*/
public function handle(Request $request): void
{
Validator::make($request->all(), [
'port' => 'required|integer|min:1|max:65535',
])->validate();
$this->site->server->ssh()->exec(
__('php :path/artisan octane:install --no-interaction', [
'path' => $this->site->path,
]),
'install-laravel-octane',
);
$command = __('php :path/artisan octane:start --port=:port --host=127.0.0.1', [
'path' => $this->site->path,
'port' => $request->input('port'),
]);
/** @var ?Worker $worker */
$worker = $this->site->workers()->where('name', 'laravel-octane')->first();
if ($worker) {
app(ManageWorker::class)->restart($worker);
} else {
app(CreateWorker::class)->create(
$this->site->server,
[
'name' => 'laravel-octane',
'command' => $command,
'user' => $this->site->user ?? $this->site->server->getSshUser(),
'auto_start' => true,
'auto_restart' => true,
'numprocs' => 1,
],
$this->site,
);
}
$typeData = $this->site->type_data ?? [];
data_set($typeData, 'octane', true);
data_set($typeData, 'octane_port', $request->input('port'));
$this->site->type_data = $typeData;
$this->site->save();
$this->updateVHost();
$request->session()->flash('success', 'Laravel Octane has been enabled for this site.');
}
private function updateVHost(): void
{
$webserver = $this->site->webserver();
if ($webserver->id() === 'nginx') {
$this->site->webserver()->updateVHost(
$this->site,
replace: [
'php' => view('ssh.services.webserver.nginx.vhost-blocks.laravel-octane', ['site' => $this->site]),
'laravel-octane-map' => '',
],
append: [
'header' => view('ssh.services.webserver.nginx.vhost-blocks.laravel-octane-map', ['site' => $this->site]),
]
);
return;
}
throw new RuntimeException('Unsupported webserver: '.$webserver->id());
}
}

View File

@ -227,3 +227,41 @@ function user(): User
return $user;
}
function plugins_path(?string $path = null): string
{
if ($path === null) {
$path = storage_path('plugins');
if (! file_exists($path)) {
mkdir($path, 0755, true);
}
return $path;
}
return storage_path('plugins'.'/'.$path);
}
function composer_path(): ?string
{
$paths = [
'/usr/local/bin/composer',
'/usr/bin/composer',
'/opt/homebrew/bin/composer',
trim((string) shell_exec('which composer')),
];
return array_find($paths, fn ($path) => is_executable($path));
}
function php_path(): ?string
{
$paths = [
'/usr/local/bin/php',
'/usr/bin/php',
'/opt/homebrew/bin/php',
trim((string) shell_exec('which php')),
];
return array_find($paths, fn ($path) => is_executable($path));
}