diff --git a/app/Actions/NodeJS/ChangeDefaultCli.php b/app/Actions/NodeJS/ChangeDefaultCli.php new file mode 100644 index 0000000..16b41c0 --- /dev/null +++ b/app/Actions/NodeJS/ChangeDefaultCli.php @@ -0,0 +1,32 @@ +validate($server, $input); + $service = $server->nodejs($input['version']); + /** @var NodeJS $handler */ + $handler = $service->handler(); + $handler->setDefaultCli(); + $server->defaultService('nodejs')->update(['is_default' => 0]); + $service->update(['is_default' => 1]); + $service->update(['status' => ServiceStatus::READY]); + } + + public function validate(Server $server, array $input): void + { + if (! isset($input['version']) || ! in_array($input['version'], $server->installedNodejsVersions())) { + throw ValidationException::withMessages( + ['version' => __('This version is not installed')] + ); + } + } +} diff --git a/app/Actions/NodeJS/InstallNewNodeJsVersion.php b/app/Actions/NodeJS/InstallNewNodeJsVersion.php new file mode 100755 index 0000000..e5c0eba --- /dev/null +++ b/app/Actions/NodeJS/InstallNewNodeJsVersion.php @@ -0,0 +1,45 @@ + $server->id, + 'type' => 'nodejs', + 'type_data' => [], + 'name' => 'nodejs', + 'version' => $input['version'], + 'status' => ServiceStatus::INSTALLING, + 'is_default' => false, + ]); + $nodejs->save(); + + dispatch(function () use ($nodejs) { + $nodejs->handler()->install(); + $nodejs->status = ServiceStatus::READY; + $nodejs->save(); + })->catch(function () use ($nodejs) { + $nodejs->delete(); + })->onConnection('ssh'); + } + + public static function rules(Server $server): array + { + return [ + 'version' => [ + 'required', + Rule::in(config('core.nodejs_versions')), + Rule::notIn(array_merge($server->installedNodejsVersions(), [NodeJS::NONE])), + ], + ]; + } +} diff --git a/app/Actions/NodeJS/UninstallNodeJS.php b/app/Actions/NodeJS/UninstallNodeJS.php new file mode 100755 index 0000000..8e5bd5a --- /dev/null +++ b/app/Actions/NodeJS/UninstallNodeJS.php @@ -0,0 +1,53 @@ +validate($server, $input); + + /** @var Service $nodejs */ + $nodejs = $server->nodejs($input['version']); + $nodejs->status = ServiceStatus::UNINSTALLING; + $nodejs->save(); + + dispatch(function () use ($nodejs) { + $nodejs->handler()->uninstall(); + $nodejs->delete(); + })->catch(function () use ($nodejs) { + $nodejs->status = ServiceStatus::FAILED; + $nodejs->save(); + })->onConnection('ssh'); + } + + /** + * @throws ValidationException + */ + private function validate(Server $server, array $input): void + { + Validator::make($input, [ + 'version' => 'required|string', + ])->validate(); + + if (! in_array($input['version'], $server->installedNodejsVersions())) { + throw ValidationException::withMessages( + ['version' => __('This version is not installed')] + ); + } + + $hasSite = $server->sites()->where('nodejs_version', $input['version'])->first(); + if ($hasSite) { + throw ValidationException::withMessages( + ['version' => __('Cannot uninstall this version because some sites are using it!')] + ); + } + } +} diff --git a/app/Enums/NodeJS.php b/app/Enums/NodeJS.php new file mode 100644 index 0000000..fd6d308 --- /dev/null +++ b/app/Enums/NodeJS.php @@ -0,0 +1,32 @@ +services()->where('type', 'nodejs')->get(['version']); + foreach ($nodes as $node) { + $versions[] = $node->version; + } + + return $versions; + } + public function type(): ServerType { $typeClass = config('core.server_types_class')[$this->type]; @@ -377,6 +388,15 @@ public function php(?string $version = null): ?Service return $this->service('php', $version); } + public function nodejs(?string $version = null): ?Service + { + if (! $version) { + return $this->defaultService('nodejs'); + } + + return $this->service('nodejs', $version); + } + public function memoryDatabase(?string $version = null): ?Service { if (! $version) { diff --git a/app/Models/Site.php b/app/Models/Site.php index 028668c..0852df8 100755 --- a/app/Models/Site.php +++ b/app/Models/Site.php @@ -43,6 +43,8 @@ * @property ?Ssl $activeSsl * @property string $ssh_key_name * @property ?SourceControl $sourceControl + * + * @TODO: Add nodejs_version column */ class Site extends AbstractModel { diff --git a/app/Policies/ServicePolicy.php b/app/Policies/ServicePolicy.php index d14e896..eaddbd9 100644 --- a/app/Policies/ServicePolicy.php +++ b/app/Policies/ServicePolicy.php @@ -35,4 +35,29 @@ public function delete(User $user, Service $service): bool { return ($user->isAdmin() || $service->server->project->users->contains($user)) && $service->server->isReady(); } + + public function start(User $user, Service $service): bool + { + return $this->update($user, $service) && $service->unit; + } + + public function stop(User $user, Service $service): bool + { + return $this->update($user, $service) && $service->unit; + } + + public function restart(User $user, Service $service): bool + { + return $this->update($user, $service) && $service->unit; + } + + public function disable(User $user, Service $service): bool + { + return $this->update($user, $service) && $service->unit; + } + + public function enable(User $user, Service $service): bool + { + return $this->update($user, $service) && $service->unit; + } } diff --git a/app/SSH/Services/NodeJS/NodeJS.php b/app/SSH/Services/NodeJS/NodeJS.php new file mode 100644 index 0000000..1413f6c --- /dev/null +++ b/app/SSH/Services/NodeJS/NodeJS.php @@ -0,0 +1,77 @@ + [ + 'required', + Rule::in(config('core.nodejs_versions')), + Rule::notIn([\App\Enums\NodeJS::NONE]), + Rule::unique('services', 'version') + ->where('type', 'nodejs') + ->where('server_id', $this->service->server_id), + ], + ]; + } + + public function deletionRules(): array + { + return [ + 'service' => [ + function (string $attribute, mixed $value, Closure $fail) { + $hasSite = $this->service->server->sites() + ->where('nodejs_version', $this->service->version) + ->exists(); + if ($hasSite) { + $fail('Some sites are using this NodeJS version.'); + } + }, + ], + ]; + } + + public function install(): void + { + $server = $this->service->server; + $server->ssh()->exec( + $this->getScript('install-nodejs.sh', [ + 'version' => $this->service->version, + 'user' => $server->getSshUser(), + ]), + 'install-nodejs-'.$this->service->version + ); + $this->service->server->os()->cleanup(); + } + + public function uninstall(): void + { + $this->service->server->ssh()->exec( + $this->getScript('uninstall-nodejs.sh', [ + 'version' => $this->service->version, + ]), + 'uninstall-nodejs-'.$this->service->version + ); + $this->service->server->os()->cleanup(); + } + + public function setDefaultCli(): void + { + $this->service->server->ssh()->exec( + $this->getScript('change-default-nodejs.sh', [ + 'version' => $this->service->version, + ]), + 'change-default-nodejs' + ); + } +} diff --git a/app/SSH/Services/NodeJS/scripts/change-default-nodejs.sh b/app/SSH/Services/NodeJS/scripts/change-default-nodejs.sh new file mode 100755 index 0000000..fa8b39b --- /dev/null +++ b/app/SSH/Services/NodeJS/scripts/change-default-nodejs.sh @@ -0,0 +1,13 @@ +export NVM_DIR="$HOME/.nvm" +[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh" + +if ! nvm alias default __version__; then + echo 'VITO_SSH_ERROR' && exit 1 +fi + +if ! nvm use default; then + echo 'VITO_SSH_ERROR' && exit 1 +fi + +echo "Default Node.js is now:" +node -v diff --git a/app/SSH/Services/NodeJS/scripts/install-nodejs.sh b/app/SSH/Services/NodeJS/scripts/install-nodejs.sh new file mode 100755 index 0000000..53a8998 --- /dev/null +++ b/app/SSH/Services/NodeJS/scripts/install-nodejs.sh @@ -0,0 +1,68 @@ +# Download NVM, if not already downloaded +if [ ! -d "$HOME/.nvm" ]; then + if ! git clone https://github.com/nvm-sh/nvm.git "$HOME/.nvm"; then + echo 'VITO_SSH_ERROR' && exit 1 + fi +fi + +# Checkout the latest stable version of NVM +if ! git -C "$HOME/.nvm" checkout v0.40.1; then + echo 'VITO_SSH_ERROR' && exit 1 +fi + +# Load NVM +export NVM_DIR="$HOME/.nvm" +[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh" + +# Define the NVM initialization script +NVM_INIT=' +export NVM_DIR="$HOME/.nvm" +[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh" +' + +# List of potential configuration files +CONFIG_FILES=("$HOME/.bash_profile" "$HOME/.bash_login" "$HOME/.profile") + +# Flag to track if at least one file exists +FILE_EXISTS=false + +# Loop through each configuration file and check if it exists +for config_file in "${CONFIG_FILES[@]}"; do + if [ -f "$config_file" ]; then + FILE_EXISTS=true + # Check if the NVM initialization is already present + if ! grep -q 'export NVM_DIR="$HOME/.nvm"' "$config_file"; then + echo "Adding NVM initialization to $config_file" + echo "$NVM_INIT" >> "$config_file" + else + echo "NVM initialization already exists in $config_file" + fi + fi +done + +# If no file exists, fallback to .profile +if [ "$FILE_EXISTS" = false ]; then + FALLBACK_FILE="$HOME/.profile" + echo "No configuration files found. Creating $FALLBACK_FILE and adding NVM initialization." + echo "$NVM_INIT" >> "$FALLBACK_FILE" +fi + +echo "NVM initialization process completed." + +# Install NVM if not already installed +if ! command -v nvm > /dev/null 2>&1; then + if ! bash "$HOME/.nvm/install.sh"; then + echo 'VITO_SSH_ERROR' && exit 1 + fi +fi + +# Install the requested Node.js version +if ! nvm install __version__; then + echo 'VITO_SSH_ERROR' && exit 1 +fi + +echo "Node.js version __version__ installed successfully!" + +echo "Node version:" && node -v +echo "NPM version:" && npm -v +echo "NPX version:" && npx -v diff --git a/app/SSH/Services/NodeJS/scripts/uninstall-nodejs.sh b/app/SSH/Services/NodeJS/scripts/uninstall-nodejs.sh new file mode 100755 index 0000000..adbed0b --- /dev/null +++ b/app/SSH/Services/NodeJS/scripts/uninstall-nodejs.sh @@ -0,0 +1,6 @@ +export NVM_DIR="$HOME/.nvm" +[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh" + +if ! nvm uninstall __version__; then + echo 'VITO_SSH_ERROR' && exit 1 +fi diff --git a/app/Web/Pages/Servers/NodeJS/Index.php b/app/Web/Pages/Servers/NodeJS/Index.php new file mode 100644 index 0000000..6387374 --- /dev/null +++ b/app/Web/Pages/Servers/NodeJS/Index.php @@ -0,0 +1,66 @@ +authorize('viewAny', [Service::class, $this->server]); + } + + public function getWidgets(): array + { + return [ + [NodeJSList::class, ['server' => $this->server]], + ]; + } + + protected function getHeaderActions(): array + { + $installedNodeVersions = $this->server->installedNodejsVersions(); + + return [ + Action::make('install') + ->authorize(fn () => auth()->user()?->can('create', [Service::class, $this->server])) + ->label('Install Node') + ->icon('heroicon-o-archive-box-arrow-down') + ->modalWidth(MaxWidth::Large) + ->form([ + Select::make('version') + ->options( + collect(config('core.nodejs_versions')) + ->filter(fn ($version) => ! in_array($version, array_merge($installedNodeVersions, [NodeJS::NONE]))) + ->mapWithKeys(fn ($version) => [$version => $version]) + ->toArray() + ) + ->rules(InstallNewNodeJsVersion::rules($this->server)['version']), + ]) + ->modalSubmitActionLabel('Install') + ->action(function (array $data) { + app(InstallNewNodeJsVersion::class)->install($this->server, $data); + + Notification::make() + ->success() + ->title('Installing Node...') + ->send(); + + $this->dispatch('$refresh'); + }), + ]; + } +} diff --git a/app/Web/Pages/Servers/NodeJS/Widgets/NodeJSList.php b/app/Web/Pages/Servers/NodeJS/Widgets/NodeJSList.php new file mode 100644 index 0000000..262a0be --- /dev/null +++ b/app/Web/Pages/Servers/NodeJS/Widgets/NodeJSList.php @@ -0,0 +1,117 @@ +where('type', 'nodejs')->where('server_id', $this->server->id); + } + + protected function getTableColumns(): array + { + return [ + TextColumn::make('version') + ->sortable(), + TextColumn::make('status') + ->label('Status') + ->badge() + ->color(fn (Service $service) => Service::$statusColors[$service->status]) + ->sortable(), + TextColumn::make('is_default') + ->label('Default Cli') + ->badge() + ->color(fn (Service $service) => $service->is_default ? 'primary' : 'gray') + ->state(fn (Service $service) => $service->is_default ? 'Yes' : 'No') + ->sortable(), + TextColumn::make('created_at') + ->label('Installed At') + ->formatStateUsing(fn ($record) => $record->created_at_by_timezone), + ]; + } + + /** + * @throws Exception + */ + public function table(Table $table): Table + { + return $table + ->heading(null) + ->query($this->getTableQuery()) + ->columns($this->getTableColumns()) + ->actions([ + ActionGroup::make([ + $this->defaultNodeJsCliAction(), + $this->uninstallAction(), + ]), + ]); + } + + private function defaultNodeJsCliAction(): Action + { + return Action::make('default-nodejs-cli') + ->authorize(fn (Service $nodejs) => auth()->user()?->can('update', $nodejs)) + ->label('Make Default CLI') + ->hidden(fn (Service $service) => $service->is_default) + ->action(function (Service $service) { + try { + app(ChangeDefaultCli::class)->change($this->server, ['version' => $service->version]); + + Notification::make() + ->success() + ->title('Default NodeJS CLI changed!') + ->send(); + } catch (Exception $e) { + Notification::make() + ->danger() + ->title($e->getMessage()) + ->send(); + + throw $e; + } + + $this->dispatch('$refresh'); + }); + } + + private function uninstallAction(): Action + { + return Action::make('uninstall') + ->authorize(fn (Service $nodejs) => auth()->user()?->can('update', $nodejs)) + ->label('Uninstall') + ->color('danger') + ->requiresConfirmation() + ->action(function (Service $service) { + try { + app(Uninstall::class)->uninstall($service); + } catch (Exception $e) { + Notification::make() + ->danger() + ->title($e->getMessage()) + ->send(); + + throw $e; + } + + $this->dispatch('$refresh'); + }); + } +} diff --git a/app/Web/Pages/Servers/Page.php b/app/Web/Pages/Servers/Page.php index 01b9e3b..c082cda 100644 --- a/app/Web/Pages/Servers/Page.php +++ b/app/Web/Pages/Servers/Page.php @@ -18,6 +18,7 @@ use App\Web\Pages\Servers\Firewall\Index as FirewallIndex; use App\Web\Pages\Servers\Logs\Index as LogsIndex; use App\Web\Pages\Servers\Metrics\Index as MetricsIndex; +use App\Web\Pages\Servers\NodeJS\Index as NodeJsIndex; use App\Web\Pages\Servers\PHP\Index as PHPIndex; use App\Web\Pages\Servers\Services\Index as ServicesIndex; use App\Web\Pages\Servers\Settings as ServerSettings; @@ -60,11 +61,18 @@ public function getSubNavigation(): array if (auth()->user()->can('viewAny', [Service::class, $this->server])) { $items[] = NavigationItem::make(PHPIndex::getNavigationLabel()) - ->icon('heroicon-o-code-bracket') + ->icon('icon-php-alt') ->isActiveWhen(fn () => request()->routeIs(PHPIndex::getRouteName().'*')) ->url(PHPIndex::getUrl(parameters: ['server' => $this->server])); } + if (auth()->user()->can('viewAny', [Service::class, $this->server])) { + $items[] = NavigationItem::make(NodeJsIndex::getNavigationLabel()) + ->icon('icon-nodejs-alt') + ->isActiveWhen(fn () => request()->routeIs(NodeJsIndex::getRouteName().'*')) + ->url(NodeJsIndex::getUrl(parameters: ['server' => $this->server])); + } + if (auth()->user()->can('viewAny', [FirewallRule::class, $this->server])) { $items[] = NavigationItem::make(FirewallIndex::getNavigationLabel()) ->icon('heroicon-o-fire') diff --git a/app/Web/Pages/Servers/Services/Widgets/ServicesList.php b/app/Web/Pages/Servers/Services/Widgets/ServicesList.php index 095bfdf..e4d9a50 100644 --- a/app/Web/Pages/Servers/Services/Widgets/ServicesList.php +++ b/app/Web/Pages/Servers/Services/Widgets/ServicesList.php @@ -75,7 +75,7 @@ public function table(Table $table): Table private function serviceAction(string $type, string $icon): Action { return Action::make($type) - ->authorize(fn (Service $service) => auth()->user()?->can('update', $service)) + ->authorize(fn (Service $service) => auth()->user()?->can($type, $service)) ->label(ucfirst($type).' Service') ->icon($icon) ->action(function (Service $service) use ($type) { diff --git a/config/core.php b/config/core.php index 934214c..0ca948b 100755 --- a/config/core.php +++ b/config/core.php @@ -40,6 +40,19 @@ \App\Enums\PHP::V83, \App\Enums\PHP::V84, ], + 'nodejs_versions' => [ + \App\Enums\NodeJS::NONE, + \App\Enums\NodeJS::V4, + \App\Enums\NodeJS::V6, + \App\Enums\NodeJS::V8, + \App\Enums\NodeJS::V10, + \App\Enums\NodeJS::V12, + \App\Enums\NodeJS::V14, + \App\Enums\NodeJS::V16, + \App\Enums\NodeJS::V18, + \App\Enums\NodeJS::V20, + \App\Enums\NodeJS::V22, + ], 'databases' => [ \App\Enums\Database::NONE, \App\Enums\Database::MYSQL57, @@ -162,6 +175,7 @@ 'postgresql' => 'database', 'redis' => 'memory_database', 'php' => 'php', + 'nodejs' => 'nodejs', 'ufw' => 'firewall', 'supervisor' => 'process_manager', 'vito-agent' => 'monitoring', @@ -174,6 +188,7 @@ 'postgresql' => \App\SSH\Services\Database\Postgresql::class, 'redis' => \App\SSH\Services\Redis\Redis::class, 'php' => \App\SSH\Services\PHP\PHP::class, + 'nodejs' => \App\SSH\Services\NodeJS\NodeJS::class, 'ufw' => \App\SSH\Services\Firewall\Ufw::class, 'supervisor' => \App\SSH\Services\ProcessManager\Supervisor::class, 'vito-agent' => \App\SSH\Services\Monitoring\VitoAgent\VitoAgent::class, @@ -204,6 +219,18 @@ 'redis' => [ 'latest', ], + 'nodejs' => [ + '4', + '6', + '8', + '10', + '12', + '14', + '16', + '18', + '20', + '22', + ], 'php' => [ '5.6', '7.0', diff --git a/resources/svg/nodejs-alt.svg b/resources/svg/nodejs-alt.svg new file mode 100644 index 0000000..f05a36b --- /dev/null +++ b/resources/svg/nodejs-alt.svg @@ -0,0 +1,7 @@ + + + + + diff --git a/resources/svg/nodejs.svg b/resources/svg/nodejs.svg new file mode 100644 index 0000000..2163bb7 --- /dev/null +++ b/resources/svg/nodejs.svg @@ -0,0 +1,2 @@ + + diff --git a/resources/svg/php-alt.svg b/resources/svg/php-alt.svg new file mode 100644 index 0000000..1cf1246 --- /dev/null +++ b/resources/svg/php-alt.svg @@ -0,0 +1,5 @@ + + + + diff --git a/tests/Feature/NodeJSTest.php b/tests/Feature/NodeJSTest.php new file mode 100644 index 0000000..f5ed652 --- /dev/null +++ b/tests/Feature/NodeJSTest.php @@ -0,0 +1,94 @@ +actingAs($this->user); + + Livewire::test(Index::class, ['server' => $this->server]) + ->callAction('install', [ + 'version' => NodeJS::V16, + ]) + ->assertSuccessful(); + + $this->assertDatabaseHas('services', [ + 'server_id' => $this->server->id, + 'type' => 'nodejs', + 'version' => NodeJS::V16, + 'status' => ServiceStatus::READY, + ]); + } + + public function test_uninstall_nodejs(): void + { + SSH::fake(); + + $this->actingAs($this->user); + + $php = new Service([ + 'server_id' => $this->server->id, + 'type' => 'nodejs', + 'type_data' => [], + 'name' => 'nodejs', + 'version' => NodeJS::V16, + 'status' => ServiceStatus::READY, + 'is_default' => true, + ]); + $php->save(); + + Livewire::test(NodeJSList::class, [ + 'server' => $this->server, + ]) + ->callTableAction('uninstall', $php->id) + ->assertSuccessful(); + + $this->assertDatabaseMissing('services', [ + 'id' => $php->id, + ]); + } + + public function test_change_default_nodejs_cli(): void + { + SSH::fake(); + + $this->actingAs($this->user); + + /** @var Service $service */ + $service = Service::factory()->create([ + 'server_id' => $this->server->id, + 'type' => 'nodejs', + 'type_data' => [], + 'name' => 'nodejs', + 'version' => NodeJS::V16, + 'status' => ServiceStatus::READY, + 'is_default' => false, + ]); + + Livewire::test(NodeJSList::class, [ + 'server' => $this->server, + ]) + ->callTableAction('default-nodejs-cli', $service->id) + ->assertSuccessful(); + + $service->refresh(); + + $this->assertTrue($service->is_default); + } +} diff --git a/tests/Feature/ServicesTest.php b/tests/Feature/ServicesTest.php index 0cc55f8..4227c55 100644 --- a/tests/Feature/ServicesTest.php +++ b/tests/Feature/ServicesTest.php @@ -318,6 +318,11 @@ public static function installData(): array 'php', '7.4', ], + [ + 'nodejs', + 'nodejs', + '16', + ], [ 'supervisor', 'process_manager',