diff --git a/app/Filament/Admin/Pages/Dashboard.php b/app/Filament/Admin/Pages/Dashboard.php
index b462236..2136bc5 100644
--- a/app/Filament/Admin/Pages/Dashboard.php
+++ b/app/Filament/Admin/Pages/Dashboard.php
@@ -16,7 +16,9 @@ use Filament\Forms\Concerns\InteractsWithForms;
use Filament\Forms\Contracts\HasForms;
use Filament\Pages\Page;
use Filament\Schemas\Components\EmbeddedTable;
+use Filament\Schemas\Components\Grid;
use Filament\Schemas\Components\Section;
+use Filament\Schemas\Components\Text;
use Filament\Schemas\Schema;
use Illuminate\Contracts\Support\Htmlable;
@@ -50,6 +52,13 @@ class Dashboard extends Page implements HasActions, HasForms
];
}
+ public function mount(): void
+ {
+ if (! DnsSetting::get('onboarding_completed', false)) {
+ $this->defaultAction = 'onboarding';
+ }
+ }
+
protected function getForms(): array
{
return [
@@ -78,21 +87,63 @@ class Dashboard extends Page implements HasActions, HasForms
->color('gray')
->action(fn () => $this->redirect(request()->url())),
- Action::make('onboarding')
+ Action::make('onboarding')->modalCancelActionLabel('Maybe later')
->label(__('Setup Wizard'))
->icon('heroicon-o-sparkles')
- ->visible(fn () => ! DnsSetting::get('onboarding_completed', false))
+
->modalHeading(__('Welcome to Jabali!'))
->modalDescription(__('Let\'s get your server control panel set up.'))
- ->modalWidth('md')
+ ->modalWidth('2xl')
->form([
+ Section::make(__('Next Steps'))
+ ->description(__('Here is a quick setup path to get your first site online.'))
+ ->icon('heroicon-o-check-circle')
+ ->iconColor('info')
+ ->collapsed(false)
+ ->collapsible(false)
+ ->compact()
+ ->schema([
+ Grid::make(['default' => 1, 'md' => 2])
+ ->schema([
+ Section::make(__('1. Configure Server Settings'))
+ ->description(__('Set hostname, DNS, email, storage, and PHP defaults.'))
+ ->icon('heroicon-o-cog-6-tooth')
+ ->iconColor('info')
+ ->collapsed(false)
+ ->collapsible(false)
+ ->compact(),
+ Section::make(__('2. Create a Hosting Package'))
+ ->description(__('Define limits and features for your plans.'))
+ ->icon('heroicon-o-cube')
+ ->iconColor('info')
+ ->collapsed(false)
+ ->collapsible(false)
+ ->compact(),
+ Section::make(__('3. Create a User'))
+ ->description(__('Assign the hosting package and set credentials.'))
+ ->icon('heroicon-o-user-plus')
+ ->iconColor('info')
+ ->collapsed(false)
+ ->collapsible(false)
+ ->compact(),
+ Section::make(__('4. Add a Domain'))
+ ->description(__('Issue SSL and deploy your site files.'))
+ ->icon('heroicon-o-globe-alt')
+ ->iconColor('info')
+ ->collapsed(false)
+ ->collapsible(false)
+ ->compact(),
+ ]),
+ Text::make(__('Optional: review Services and Server Status to confirm everything is healthy.'))
+ ->color('gray'),
+ ]),
TextInput::make('admin_email')
->label(__('Your Email Address'))
->helperText(__('Enter your email to receive important server notifications.'))
->email()
->placeholder(__('admin@example.com')),
])
- ->modalSubmitActionLabel(__('Get Started'))
+ ->modalSubmitActionLabel(__("Don't show again"))
->action(function (array $data): void {
if (! empty($data['admin_email'])) {
DnsSetting::set('admin_email_recipients', $data['admin_email']);
diff --git a/app/Filament/Admin/Pages/Support.php b/app/Filament/Admin/Pages/Support.php
new file mode 100644
index 0000000..0b9e550
--- /dev/null
+++ b/app/Filament/Admin/Pages/Support.php
@@ -0,0 +1,30 @@
+defaultAction = 'onboarding';
+ }
+ }
+
+ protected function getHeaderActions(): array
+ {
+ return [
+ Action::make('onboarding')->modalCancelActionLabel('Maybe later')
+ ->label(__('Setup Wizard'))
+ ->icon('heroicon-o-sparkles')
+
+ ->modalHeading(__('Welcome to Jabali!'))
+ ->modalDescription(__('Here is a quick path to launch your first site.'))
+ ->modalWidth('2xl')
+ ->form([
+ Section::make(__('Next Steps'))
+ ->description(__('Follow these steps to get online quickly.'))
+ ->icon('heroicon-o-check-circle')
+ ->iconColor('info')
+ ->collapsed(false)
+ ->collapsible(false)
+ ->compact()
+ ->schema([
+ Grid::make(['default' => 1, 'md' => 2])
+ ->schema([
+ Section::make(__('1. Add a Domain'))
+ ->description(__('Point your DNS to this server or update nameservers.'))
+ ->icon('heroicon-o-globe-alt')
+ ->iconColor('info')
+ ->collapsed(false)
+ ->collapsible(false)
+ ->compact(),
+ Section::make(__('2. Issue SSL'))
+ ->description(__('Enable HTTPS for your site with SSL certificates.'))
+ ->icon('heroicon-o-lock-closed')
+ ->iconColor('info')
+ ->collapsed(false)
+ ->collapsible(false)
+ ->compact(),
+ Section::make(__('3. Upload or Install'))
+ ->description(__('Upload files or install WordPress to deploy your site.'))
+ ->icon('heroicon-o-arrow-up-tray')
+ ->iconColor('info')
+ ->collapsed(false)
+ ->collapsible(false)
+ ->compact(),
+ Section::make(__('4. Create Email & Databases'))
+ ->description(__('Set up mailboxes and databases for your app.'))
+ ->icon('heroicon-o-envelope')
+ ->iconColor('info')
+ ->collapsed(false)
+ ->collapsible(false)
+ ->compact(),
+ ]),
+ Text::make(__('Optional: configure backups, cron jobs, and SSH keys for day-to-day operations.'))
+ ->color('gray'),
+ ]),
+ ])
+ ->modalSubmitActionLabel(__("Don't show again"))
+ ->action(function (): void {
+ DnsSetting::set('user_onboarding_completed_'.(string) Auth::id(), '1');
+ DnsSetting::clearCache();
+ }),
+ ];
+ }
+
public function getSubheading(): ?string
{
$user = Auth::user();
diff --git a/app/Filament/Jabali/Pages/Email.php b/app/Filament/Jabali/Pages/Email.php
index ccdc46f..962ae21 100644
--- a/app/Filament/Jabali/Pages/Email.php
+++ b/app/Filament/Jabali/Pages/Email.php
@@ -32,6 +32,7 @@ use Filament\Pages\Page;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Components\Tabs;
use Filament\Schemas\Components\Tabs\Tab;
+use Filament\Schemas\Components\Utilities\Get;
use Filament\Schemas\Components\View;
use Filament\Schemas\Schema;
use Filament\Tables\Columns\TextColumn;
@@ -1017,21 +1018,26 @@ class Email extends Page implements HasActions, HasForms, HasTable
->label(__('Domain'))
->options(fn () => Domain::where('user_id', Auth::id())->pluck('domain', 'id')->toArray())
->required()
- ->searchable(),
+ ->searchable()
+ ->live()
+ ->live(),
TextInput::make('local_part')
->label(__('Email Address'))
- ->required()
+ ->required(fn (Get $get): bool => filled($get('domain_id')))
+ ->visible(fn (Get $get): bool => filled($get('domain_id')))
->regex('/^[a-zA-Z0-9._%+-]+$/')
->maxLength(64)
->helperText(__('The part before the @ symbol')),
TextInput::make('name')
->label(__('Display Name'))
+ ->visible(fn (Get $get): bool => filled($get('domain_id')))
->maxLength(255),
TextInput::make('password')
->label(__('Password'))
->password()
->revealable()
- ->required()
+ ->required(fn (Get $get): bool => filled($get('domain_id')))
+ ->visible(fn (Get $get): bool => filled($get('domain_id')))
->minLength(8)
->rules([
'regex:/[a-z]/', // lowercase
@@ -1063,6 +1069,7 @@ class Email extends Page implements HasActions, HasForms, HasTable
TextInput::make('quota_mb')
->label(__('Quota (MB)'))
->numeric()
+ ->visible(fn (Get $get): bool => filled($get('domain_id')))
->default(1024)
->minValue(100)
->maxValue(10240)
@@ -1236,16 +1243,19 @@ class Email extends Page implements HasActions, HasForms, HasTable
->label(__('Domain'))
->options(fn () => Domain::where('user_id', Auth::id())->pluck('domain', 'id')->toArray())
->required()
- ->searchable(),
+ ->searchable()
+ ->live(),
TextInput::make('local_part')
->label(__('Email Address'))
- ->required()
+ ->required(fn (Get $get): bool => filled($get('domain_id')))
+ ->visible(fn (Get $get): bool => filled($get('domain_id')))
->regex('/^[a-zA-Z0-9._%+-]+$/')
->maxLength(64)
->helperText(__('The part before the @ symbol')),
TextInput::make('destinations')
->label(__('Forward To'))
- ->required()
+ ->required(fn (Get $get): bool => filled($get('domain_id')))
+ ->visible(fn (Get $get): bool => filled($get('domain_id')))
->helperText(__('Comma-separated email addresses to forward to')),
])
->action(function (array $data): void {
diff --git a/app/Filament/Jabali/Pages/Files.php b/app/Filament/Jabali/Pages/Files.php
index 67f45f3..98004bc 100644
--- a/app/Filament/Jabali/Pages/Files.php
+++ b/app/Filament/Jabali/Pages/Files.php
@@ -60,7 +60,7 @@ class Files extends Page implements HasActions, HasForms, HasTable
public function getTitle(): string|Htmlable
{
- return 'File Manager';
+ return __('File Manager');
}
public function getAgent(): AgentClient
diff --git a/app/Filament/Jabali/Pages/Support.php b/app/Filament/Jabali/Pages/Support.php
new file mode 100644
index 0000000..f2cfeed
--- /dev/null
+++ b/app/Filament/Jabali/Pages/Support.php
@@ -0,0 +1,30 @@
+options($domainOptions)
->required()
->searchable()
+ ->live()
->placeholder(__('Select a domain...'))
->helperText(__('The domain where WordPress will be installed')),
Toggle::make('use_www')
->label(__('Use www prefix'))
+ ->visible(fn (Get $get): bool => filled($get('domain')))
->helperText(__('Install on www.domain.com instead of domain.com'))
->default(false),
TextInput::make('path')
->label(__('Directory (optional)'))
+ ->visible(fn (Get $get): bool => filled($get('domain')))
->placeholder(__('Leave empty to install in root'))
->helperText(__('e.g., "blog" to install at domain.com/blog')),
TextInput::make('site_title')
->label(__('Site Title'))
- ->required()
+ ->required(fn (Get $get): bool => filled($get('domain')))
+ ->visible(fn (Get $get): bool => filled($get('domain')))
->default(__('My WordPress Site'))
->helperText(__('The name of your WordPress site')),
TextInput::make('admin_user')
->label(__('Admin Username'))
- ->required()
+ ->required(fn (Get $get): bool => filled($get('domain')))
+ ->visible(fn (Get $get): bool => filled($get('domain')))
->default('admin')
->alphaNum()
->helperText(__('Username for the WordPress admin account')),
@@ -473,7 +479,8 @@ class WordPress extends Page implements HasActions, HasForms, HasTable
->label(__('Admin Password'))
->password()
->revealable()
- ->required()
+ ->required(fn (Get $get): bool => filled($get('domain')))
+ ->visible(fn (Get $get): bool => filled($get('domain')))
->default(fn () => $this->generateSecurePassword())
->minLength(8)
->rules([
@@ -504,7 +511,8 @@ class WordPress extends Page implements HasActions, HasForms, HasTable
->helperText(__('Minimum 8 characters with uppercase, lowercase, and numbers')),
TextInput::make('admin_email')
->label(__('Admin Email'))
- ->required()
+ ->required(fn (Get $get): bool => filled($get('domain')))
+ ->visible(fn (Get $get): bool => filled($get('domain')))
->email()
->default(Auth::user()->email ?? '')
->helperText(__('Email address for the WordPress admin account')),
@@ -538,14 +546,17 @@ class WordPress extends Page implements HasActions, HasForms, HasTable
])
->default('en_US')
->searchable()
- ->required()
+ ->required(fn (Get $get): bool => filled($get('domain')))
+ ->visible(fn (Get $get): bool => filled($get('domain')))
->helperText(__('Default language for WordPress admin and content')),
Toggle::make('enable_cache')
->label(__('Enable Jabali Cache'))
+ ->visible(fn (Get $get): bool => filled($get('domain')))
->helperText(__('Install Redis object caching for better performance'))
->default(true),
Toggle::make('enable_auto_update')
->label(__('Enable Auto-Updates'))
+ ->visible(fn (Get $get): bool => filled($get('domain')))
->helperText(__('Automatically update WordPress, plugins, and themes'))
->default(false),
])
diff --git a/docs/docs-index.md b/docs/docs-index.md
index f986ff9..5748b79 100644
--- a/docs/docs-index.md
+++ b/docs/docs-index.md
@@ -1,6 +1,6 @@
# Jabali Documentation Index
-Last updated: 2026-02-06
+Last updated: 2026-02-09
## Top-Level Docs
- /var/www/jabali/README.md - Product overview, features, install, upgrade, and architecture summary.
@@ -11,7 +11,7 @@ Last updated: 2026-02-06
- /var/www/jabali/TODO.md - Active checklist items.
## Docs Folder
-- /var/www/jabali/docs/installation.md - Debian package install path and Filament notifications patch.
+- /var/www/jabali/docs/installation.md - Debian package install path, Filament notifications patch, and deploy script usage.
- /var/www/jabali/docs/architecture/control-panel-blueprint.md - High-level blueprint for a hosting panel.
- /var/www/jabali/docs/archive-notes.md - Archived files and restore notes.
- /var/www/jabali/docs/screenshots/README.md - Screenshot generation instructions.
diff --git a/docs/docs-summary.md b/docs/docs-summary.md
index 0df8be1..ddcbbc4 100644
--- a/docs/docs-summary.md
+++ b/docs/docs-summary.md
@@ -1,6 +1,6 @@
# Documentation Summary (Jabali Panel)
-Last updated: 2026-02-06
+Last updated: 2026-02-09
## Product Overview
Jabali Panel is a modern web hosting control panel for WordPress and general PHP hosting. It provides an admin panel for server-wide operations and a user panel for per-tenant management. The core goals are safe automation, clean multi-tenant isolation, and operational clarity.
@@ -41,6 +41,7 @@ DNSSEC can be enabled per domain and generates KSK/ZSK keys, DS records, and sig
- Target OS: Fresh Debian 12/13 install with no pre-existing web/mail stack.
- Installer: install.sh, builds assets as www-data and ensures permissions.
- Upgrade: php artisan jabali:upgrade manages dependencies, caches, and permissions for public/build and node_modules.
+- Deploy helper: scripts/deploy.sh syncs code to a server, runs composer/npm, migrations, and caches, and can push to Gitea/GitHub with automatic VERSION bump.
## Packaging
Debian packaging is supported via scripts:
diff --git a/docs/installation.md b/docs/installation.md
index a2093f7..c333b40 100644
--- a/docs/installation.md
+++ b/docs/installation.md
@@ -110,6 +110,51 @@ What is included:
If you update or rebuild assets, keep the guard in place and hard‑refresh the
browser (Ctrl+Shift+R) after deployment.
+## Deploy script
+
+The repository ships with a deploy helper at `scripts/deploy.sh`. It syncs the
+project to a remote server over SSH, then runs composer/npm, migrations, and
+cache rebuilds as the web user.
+
+Defaults (override via flags or env vars):
+- Host: `192.168.100.50`
+- User: `root`
+- Path: `/var/www/jabali`
+- Web user: `www-data`
+
+Common usage:
+```
+# Basic deploy to the default host
+scripts/deploy.sh
+
+# Target a different host/path/user
+scripts/deploy.sh --host 192.168.100.50 --user root --path /var/www/jabali --www-user www-data
+
+# Dry-run rsync only
+scripts/deploy.sh --dry-run
+
+# Skip npm build and cache steps
+scripts/deploy.sh --skip-npm --skip-cache
+```
+
+Push to Git remotes (optional):
+```
+# Push to Gitea and/or GitHub before deploying
+scripts/deploy.sh --push-gitea --push-github
+
+# Push to explicit URLs
+scripts/deploy.sh --push-gitea --gitea-url http://192.168.100.100:3001/shukivaknin/jabali-panel.git \
+ --push-github --github-url git@github.com:shukiv/jabali-panel.git
+```
+
+Notes:
+- `--push-gitea` / `--push-github` require a clean worktree.
+- When pushing, the script bumps `VERSION`, updates the `install.sh` fallback,
+ and commits the version bump before pushing.
+- Rsync excludes `.env`, `storage/`, `vendor/`, `node_modules/`, `public/build/`,
+ `bootstrap/cache/`, and SQLite DB files. Handle those separately if needed.
+- `--delete` passes `--delete` to rsync (dangerous).
+
## Testing after changes
After every change, run a test to make sure there are no errors.
diff --git a/lang/ar.json b/lang/ar.json
index 14ae250..b0886d6 100644
--- a/lang/ar.json
+++ b/lang/ar.json
@@ -219,6 +219,9 @@
"Disk Usage": "استخدام القرص",
"Display Name": "الاسم المعروض",
"Document Root": "المجلد الجذر",
+ "Documentation": "التوثيق",
+ "Open Documentation": "فتح التوثيق",
+ "Support Chat": "دردشة الدعم",
"Domain": "نطاق",
"Domain Name": "اسم النطاق",
"Domain Verification Code (optional)": "رمز التحقق من النطاق (اختياري)",
@@ -879,4 +882,4 @@
"results": "نتائج",
"selected": "محدد",
"to": "إلى"
-}
\ No newline at end of file
+}
diff --git a/lang/en.json b/lang/en.json
index 9c9d0ec..bdba888 100644
--- a/lang/en.json
+++ b/lang/en.json
@@ -782,6 +782,8 @@
"Display Name": "Display Name",
"Document Root": "Document Root",
"Documentation": "Documentation",
+ "Open Documentation": "Open Documentation",
+ "Support Chat": "Support Chat",
"Domain": "Domain",
"Domain Aliases": "Domain Aliases",
"Domain Certificates": "Domain Certificates",
diff --git a/lang/es.json b/lang/es.json
index 5ca043c..979866e 100644
--- a/lang/es.json
+++ b/lang/es.json
@@ -308,6 +308,9 @@
"Disk Usage": "Uso de disco",
"Display Name": "Nombre para mostrar",
"Document Root": "Raíz del documento",
+ "Documentation": "Documentación",
+ "Open Documentation": "Abrir documentación",
+ "Support Chat": "Chat de soporte",
"Domain": "Dominio",
"Domain Configuration": "Configuración de dominio",
"Domain Count": "Cantidad de dominios",
@@ -1170,4 +1173,4 @@
"results": "resultados",
"selected": "seleccionado(s)",
"to": "a"
-}
\ No newline at end of file
+}
diff --git a/lang/fr.json b/lang/fr.json
index 0139fe1..f6e902d 100644
--- a/lang/fr.json
+++ b/lang/fr.json
@@ -220,6 +220,9 @@
"Disk Usage": "Utilisation du disque",
"Display Name": "Nom d'affichage",
"Document Root": "Racine du document",
+ "Documentation": "Documentation",
+ "Open Documentation": "Ouvrir la documentation",
+ "Support Chat": "Chat de support",
"Domain": "Domaine",
"Domain Name": "Nom de domaine",
"Domain Verification Code (optional)": "Code de verification du domaine (optionnel)",
@@ -886,4 +889,4 @@
"results": "resultats",
"selected": "selectionne(s)",
"to": "a"
-}
\ No newline at end of file
+}
diff --git a/lang/he.json b/lang/he.json
index 48ab050..ddc8804 100644
--- a/lang/he.json
+++ b/lang/he.json
@@ -219,6 +219,9 @@
"Disk Usage": "שימוש בדיסק",
"Display Name": "שם תצוגה",
"Document Root": "תיקיית שורש",
+ "Documentation": "תיעוד",
+ "Open Documentation": "פתח תיעוד",
+ "Support Chat": "צ'אט תמיכה",
"Domain": "דומיין",
"Domain Name": "שם הדומיין",
"Domain Verification Code (optional)": "קוד אימות דומיין (אופציונלי)",
@@ -879,4 +882,4 @@
"results": "תוצאות",
"selected": "נבחרו",
"to": "עד"
-}
\ No newline at end of file
+}
diff --git a/lang/pt.json b/lang/pt.json
index 94c1c02..0c6591a 100644
--- a/lang/pt.json
+++ b/lang/pt.json
@@ -219,6 +219,9 @@
"Disk Usage": "Uso de Disco",
"Display Name": "Nome de Exibição",
"Document Root": "Raiz do Documento",
+ "Documentation": "Documentação",
+ "Open Documentation": "Abrir documentação",
+ "Support Chat": "Chat de suporte",
"Domain": "Domínio",
"Domain Name": "Nome do Domínio",
"Domain Verification Code (optional)": "Código de Verificação do Domínio (opcional)",
@@ -879,4 +882,4 @@
"results": "resultados",
"selected": "selecionado(s)",
"to": "até"
-}
\ No newline at end of file
+}
diff --git a/lang/ru.json b/lang/ru.json
index e4a6100..e91a829 100644
--- a/lang/ru.json
+++ b/lang/ru.json
@@ -220,6 +220,9 @@
"Disk Usage": "Использование диска",
"Display Name": "Отображаемое имя",
"Document Root": "Корневая директория",
+ "Documentation": "Документация",
+ "Open Documentation": "Открыть документацию",
+ "Support Chat": "Чат поддержки",
"Domain": "Домен",
"Domain Name": "Имя домена",
"Domain Verification Code (optional)": "Код подтверждения домена (необязательно)",
@@ -886,4 +889,4 @@
"results": "результатов",
"selected": "выбрано",
"to": "до"
-}
\ No newline at end of file
+}
diff --git a/resources/css/app.css b/resources/css/app.css
index 196bbc4..2709ec2 100644
--- a/resources/css/app.css
+++ b/resources/css/app.css
@@ -7,3 +7,4 @@
[x-cloak] {
display: none;
}
+
diff --git a/resources/views/filament/admin/pages/support.blade.php b/resources/views/filament/admin/pages/support.blade.php
new file mode 100644
index 0000000..de07595
--- /dev/null
+++ b/resources/views/filament/admin/pages/support.blade.php
@@ -0,0 +1,32 @@
+
+
+ {{ __('Documentation') }}
+
+
+ {{ __('Open Documentation') }}
+
+
+
+
+ {{ __('Support Chat') }}
+
+
+
+
+ @script
+
+ @endscript
+
diff --git a/resources/views/filament/jabali/pages/support.blade.php b/resources/views/filament/jabali/pages/support.blade.php
new file mode 100644
index 0000000..de07595
--- /dev/null
+++ b/resources/views/filament/jabali/pages/support.blade.php
@@ -0,0 +1,32 @@
+
+
+ {{ __('Documentation') }}
+
+
+ {{ __('Open Documentation') }}
+
+
+
+
+ {{ __('Support Chat') }}
+
+
+
+
+ @script
+
+ @endscript
+
diff --git a/scripts/deploy.sh b/scripts/deploy.sh
new file mode 100755
index 0000000..1254b3c
--- /dev/null
+++ b/scripts/deploy.sh
@@ -0,0 +1,306 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+
+DEPLOY_HOST="${DEPLOY_HOST:-192.168.100.50}"
+DEPLOY_USER="${DEPLOY_USER:-root}"
+DEPLOY_PATH="${DEPLOY_PATH:-/var/www/jabali}"
+WWW_USER="${WWW_USER:-www-data}"
+GITEA_REMOTE="${GITEA_REMOTE:-gitea}"
+GITEA_URL="${GITEA_URL:-}"
+GITHUB_REMOTE="${GITHUB_REMOTE:-origin}"
+GITHUB_URL="${GITHUB_URL:-}"
+PUSH_BRANCH="${PUSH_BRANCH:-}"
+
+SKIP_SYNC=0
+SKIP_COMPOSER=0
+SKIP_NPM=0
+SKIP_MIGRATE=0
+SKIP_CACHE=0
+DELETE_REMOTE=0
+DRY_RUN=0
+PUSH_GITEA=0
+PUSH_GITHUB=0
+SET_VERSION=""
+
+usage() {
+ cat <<'EOF'
+Usage: scripts/deploy.sh [options]
+
+Options:
+ --host HOST Remote host (default: 192.168.100.50)
+ --user USER SSH user (default: root)
+ --path PATH Remote path (default: /var/www/jabali)
+ --www-user USER Remote runtime user (default: www-data)
+ --skip-sync Skip rsync sync step
+ --skip-composer Skip composer install
+ --skip-npm Skip npm install/build
+ --skip-migrate Skip php artisan migrate
+ --skip-cache Skip cache clear/rebuild
+ --delete Pass --delete to rsync (dangerous)
+ --dry-run Dry-run rsync only
+ --push-gitea Push current branch to Gitea before deploy
+ --gitea-remote NAME Gitea git remote name (default: gitea)
+ --gitea-url URL Push to this URL instead of a named remote
+ --push-github Push current branch to GitHub before deploy
+ --github-remote NAME GitHub git remote name (default: origin)
+ --github-url URL Push to this URL instead of a named remote
+ --version VALUE Set VERSION to a specific value before push
+ -h, --help Show this help
+
+Environment overrides:
+ DEPLOY_HOST, DEPLOY_USER, DEPLOY_PATH, WWW_USER, GITEA_REMOTE, GITEA_URL, GITHUB_REMOTE, GITHUB_URL, PUSH_BRANCH
+EOF
+}
+
+while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --host)
+ DEPLOY_HOST="$2"
+ shift 2
+ ;;
+ --user)
+ DEPLOY_USER="$2"
+ shift 2
+ ;;
+ --path)
+ DEPLOY_PATH="$2"
+ shift 2
+ ;;
+ --www-user)
+ WWW_USER="$2"
+ shift 2
+ ;;
+ --skip-sync)
+ SKIP_SYNC=1
+ shift
+ ;;
+ --skip-composer)
+ SKIP_COMPOSER=1
+ shift
+ ;;
+ --skip-npm)
+ SKIP_NPM=1
+ shift
+ ;;
+ --skip-migrate)
+ SKIP_MIGRATE=1
+ shift
+ ;;
+ --skip-cache)
+ SKIP_CACHE=1
+ shift
+ ;;
+ --delete)
+ DELETE_REMOTE=1
+ shift
+ ;;
+ --dry-run)
+ DRY_RUN=1
+ shift
+ ;;
+ --push-gitea)
+ PUSH_GITEA=1
+ shift
+ ;;
+ --gitea-remote)
+ GITEA_REMOTE="$2"
+ shift 2
+ ;;
+ --gitea-url)
+ GITEA_URL="$2"
+ shift 2
+ ;;
+ --push-github)
+ PUSH_GITHUB=1
+ shift
+ ;;
+ --github-remote)
+ GITHUB_REMOTE="$2"
+ shift 2
+ ;;
+ --github-url)
+ GITHUB_URL="$2"
+ shift 2
+ ;;
+ --version)
+ SET_VERSION="$2"
+ shift 2
+ ;;
+ -h|--help)
+ usage
+ exit 0
+ ;;
+ *)
+ echo "Unknown option: $1"
+ usage
+ exit 1
+ ;;
+ esac
+done
+
+REMOTE="${DEPLOY_USER}@${DEPLOY_HOST}"
+
+ensure_clean_worktree() {
+ if ! git -C "$ROOT_DIR" diff --quiet || ! git -C "$ROOT_DIR" diff --cached --quiet; then
+ echo "Working tree is dirty. Commit or stash changes before pushing."
+ exit 1
+ fi
+}
+
+get_current_version() {
+ sed -n 's/^VERSION=//p' "$ROOT_DIR/VERSION"
+}
+
+bump_version() {
+ local current new base num
+ current="$(get_current_version)"
+
+ if [[ -n "$SET_VERSION" ]]; then
+ new="$SET_VERSION"
+ else
+ if [[ "$current" =~ ^(.+-rc)([0-9]+)?$ ]]; then
+ base="${BASH_REMATCH[1]}"
+ num="${BASH_REMATCH[2]}"
+ if [[ -z "$num" ]]; then
+ num=1
+ else
+ num=$((num + 1))
+ fi
+ new="${base}${num}"
+ elif [[ "$current" =~ ^(.+?)([0-9]+)$ ]]; then
+ new="${BASH_REMATCH[1]}$((BASH_REMATCH[2] + 1))"
+ else
+ echo "Cannot auto-bump VERSION from '$current'. Use --version to set it explicitly."
+ exit 1
+ fi
+ fi
+
+ if [[ "$new" == "$current" ]]; then
+ echo "VERSION is already '$current'. Use --version to set a new value."
+ exit 1
+ fi
+
+ printf 'VERSION=%s\n' "$new" > "$ROOT_DIR/VERSION"
+ perl -0pi -e "s/JABALI_VERSION=\\\"\\$\\{JABALI_VERSION:-[^\\\"]+\\}\\\"/JABALI_VERSION=\\\"\\$\\{JABALI_VERSION:-$new\\}\\\"/g" "$ROOT_DIR/install.sh"
+
+ git -C "$ROOT_DIR" add VERSION install.sh
+ if ! git -C "$ROOT_DIR" diff --cached --quiet; then
+ git -C "$ROOT_DIR" commit -m "Bump VERSION to $new"
+ fi
+}
+
+prepare_push() {
+ ensure_clean_worktree
+ bump_version
+}
+
+push_remote() {
+ local label="$1"
+ local remote_name="$2"
+ local remote_url="$3"
+ local target
+
+ if [[ -n "$remote_url" ]]; then
+ target="$remote_url"
+ else
+ if ! git -C "$ROOT_DIR" remote get-url "$remote_name" >/dev/null 2>&1; then
+ echo "$label remote '$remote_name' not found. Use --${label,,}-url or --${label,,}-remote."
+ exit 1
+ fi
+ target="$remote_name"
+ fi
+
+ if [[ -z "$PUSH_BRANCH" ]]; then
+ PUSH_BRANCH="$(git -C "$ROOT_DIR" rev-parse --abbrev-ref HEAD)"
+ fi
+
+ git -C "$ROOT_DIR" push "$target" "$PUSH_BRANCH"
+}
+
+rsync_project() {
+ local -a rsync_opts
+ rsync_opts=(-az --info=progress2)
+ if [[ "$DELETE_REMOTE" -eq 1 ]]; then
+ rsync_opts+=(--delete)
+ fi
+ if [[ "$DRY_RUN" -eq 1 ]]; then
+ rsync_opts+=(--dry-run)
+ fi
+
+ rsync "${rsync_opts[@]}" \
+ --exclude ".git/" \
+ --exclude "node_modules/" \
+ --exclude "vendor/" \
+ --exclude "storage/" \
+ --exclude "bootstrap/cache/" \
+ --exclude "public/build/" \
+ --exclude ".env" \
+ --exclude ".env.*" \
+ --exclude "database/*.sqlite" \
+ --exclude "database/*.sqlite-wal" \
+ --exclude "database/*.sqlite-shm" \
+ "$ROOT_DIR/" \
+ "${REMOTE}:${DEPLOY_PATH}/"
+}
+
+remote_run() {
+ ssh -o StrictHostKeyChecking=no "$REMOTE" "bash -lc '$1'"
+}
+
+remote_run_www() {
+ ssh -o StrictHostKeyChecking=no "$REMOTE" "bash -lc 'cd \"$DEPLOY_PATH\" && sudo -u \"$WWW_USER\" -H bash -lc \"$1\"'"
+}
+
+echo "Deploying to ${REMOTE}:${DEPLOY_PATH}"
+
+if [[ "$PUSH_GITEA" -eq 1 ]]; then
+ prepare_push
+ echo "Pushing to Gitea..."
+ push_remote "Gitea" "$GITEA_REMOTE" "$GITEA_URL"
+fi
+
+if [[ "$PUSH_GITHUB" -eq 1 ]]; then
+ if [[ "$PUSH_GITEA" -ne 1 ]]; then
+ prepare_push
+ fi
+ echo "Pushing to GitHub..."
+ push_remote "GitHub" "$GITHUB_REMOTE" "$GITHUB_URL"
+fi
+
+if [[ "$SKIP_SYNC" -eq 0 ]]; then
+ echo "Syncing project files..."
+ rsync_project
+fi
+
+if [[ "$DRY_RUN" -eq 1 ]]; then
+ echo "Dry run complete. No remote commands executed."
+ exit 0
+fi
+
+if [[ "$SKIP_COMPOSER" -eq 0 ]]; then
+ echo "Installing composer dependencies..."
+ remote_run_www "composer install --no-interaction --prefer-dist --optimize-autoloader"
+fi
+
+if [[ "$SKIP_NPM" -eq 0 ]]; then
+ echo "Building frontend assets..."
+ remote_run_www "npm ci"
+ remote_run_www "npm run build"
+fi
+
+if [[ "$SKIP_MIGRATE" -eq 0 ]]; then
+ echo "Running migrations..."
+ remote_run_www "php artisan migrate --force"
+fi
+
+if [[ "$SKIP_CACHE" -eq 0 ]]; then
+ echo "Refreshing caches..."
+ remote_run_www "php artisan optimize:clear"
+ remote_run_www "php artisan config:cache"
+ remote_run_www "php artisan route:cache"
+ remote_run_www "php artisan view:cache"
+fi
+
+echo "Deploy complete."
diff --git a/tests/Feature/Filament/SupportPagesTest.php b/tests/Feature/Filament/SupportPagesTest.php
new file mode 100644
index 0000000..86b7383
--- /dev/null
+++ b/tests/Feature/Filament/SupportPagesTest.php
@@ -0,0 +1,41 @@
+admin()->create();
+
+ $this->actingAs($admin);
+
+ Livewire::test(AdminSupport::class)
+ ->assertStatus(200)
+ ->assertSee('Open Documentation')
+ ->assertSee('jabali-support-chat', false);
+ }
+
+ public function test_user_support_page_renders_docs_and_chat(): void
+ {
+ $user = User::factory()->create();
+
+ $this->actingAs($user);
+
+ Livewire::test(UserSupport::class)
+ ->assertStatus(200)
+ ->assertSee('Open Documentation')
+ ->assertSee('jabali-support-chat', false);
+ }
+}