From 9563a9ece296d5fcc4e0a623d1c017f03a112cbd Mon Sep 17 00:00:00 2001 From: plusemon Date: Wed, 22 Jul 2026 15:04:40 +0600 Subject: [PATCH 1/4] fix: update site menu to open configuration files directly --- linux/app/bhserve/pages.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/linux/app/bhserve/pages.py b/linux/app/bhserve/pages.py index 0862d90..1bc9efd 100644 --- a/linux/app/bhserve/pages.py +++ b/linux/app/bhserve/pages.py @@ -169,7 +169,7 @@ def item(label, icon, cb, destructive=False): srv = s.get("server", "nginx") _conf_path = f"{os.path.expanduser('~/.bhserve')}/{'apache' if srv == 'apache' else 'nginx'}/sites/{name}.conf" item(f"Open {'Apache' if srv == 'apache' else 'nginx'} config", "text-editor-symbolic", - lambda: _open_editor(_conf_path)) + lambda: _open(_conf_path)) item("Delete site…", "user-trash-symbolic", lambda: win.confirm( f"Delete site “{name}”?", "Removes the vhost. Tick purge in the next step to also drop files + DB.", lambda: win.run_verb(["site", "rm", name], f"Removing {name}…")), destructive=True) From e282ddaf1376948e1b1a14c3dabd3ccd57be18b3 Mon Sep 17 00:00:00 2001 From: plusemon Date: Thu, 23 Jul 2026 09:58:21 +0600 Subject: [PATCH 2/4] feat: site create modal add root directory location browse option --- docs/WINDOWS-PORT.md | 2 +- engine/bhserve | 11 ++-- linux/app/bhserve/window.py | 63 ++++++++++++++++--- linux/build.sh | 0 macos/Sources/BHServe/AppState.swift | 2 +- macos/Sources/BHServe/SitesView.swift | 2 + .../src/BHServe.App/Views/NodePage.xaml.cs | 21 ++++++- .../src/BHServe.App/Views/PythonPage.xaml.cs | 19 +++++- windows/src/BHServe.App/Views/SitesPage.xaml | 11 ++-- .../src/BHServe.App/Views/SitesPage.xaml.cs | 24 ++++++- windows/src/BHServe.Cli/Program.cs | 2 +- windows/src/BHServe.Core/Engine.cs | 15 +++-- 12 files changed, 141 insertions(+), 31 deletions(-) mode change 100644 => 100755 linux/build.sh diff --git a/docs/WINDOWS-PORT.md b/docs/WINDOWS-PORT.md index 13db28d..fd3e8c5 100644 --- a/docs/WINDOWS-PORT.md +++ b/docs/WINDOWS-PORT.md @@ -93,7 +93,7 @@ Port each Mac engine verb to a `BHServe.Core` method + a `bhserve.exe` subcomman - [ ] `init` — create `%LOCALAPPDATA%\BHServe\{config,nginx\sites,bin,run,logs,sites,certs,tmp}` - [ ] `install ` — download+extract a pinned portable build (php@x.y / nginx / mariadb / node / mkcert / mailpit / adminer / phpmyadmin) - [ ] `update ` / `uninstall ` -- [ ] `site add [--php 8.4] [--root] [--server nginx] [--type wordpress|php|others]` — render vhost, add hosts line, auto-create DB, WP download for `--type wordpress` +- [ ] `site add [--php 8.4] [--root] [--server nginx] [--type wordpress|php|laravel|others]` — render vhost, add hosts line, auto-create DB, WP download for `--type wordpress` - [ ] `site list|rm|php|server|root` - [ ] `secure ` — mkcert into Windows store + re-render vhost ssl block - [ ] `dns` — hosts-file management (and/or Acrylic config) diff --git a/engine/bhserve b/engine/bhserve index b0cd2c5..a4178c9 100755 --- a/engine/bhserve +++ b/engine/bhserve @@ -1261,7 +1261,7 @@ vhost_server(){ local s; s="$(sed -n 's/.*server=\([^ ]*\).*/\1/p' "$1" | head - site_add() { local name="" root="" phpv="" server="" type="others" - [ $# -ge 1 ] || die "usage: bhserve site add [--php 8.4] [--root path] [--server nginx|apache] [--type wordpress|php|others]" + [ $# -ge 1 ] || die "usage: bhserve site add [--php 8.4] [--root path] [--server nginx|apache] [--type wordpress|php|laravel|others]" name="$1"; shift while [ $# -gt 0 ]; do case "$1" in --php) phpv="$2"; shift 2;; @@ -1271,12 +1271,14 @@ site_add() { *) die "unknown flag: $1";; esac; done valid_site_name "$name" - case "$type" in wordpress|php|others) ;; *) die "--type must be wordpress|php|others" ;; esac + case "$type" in wordpress|php|laravel|others) ;; *) die "--type must be wordpress|php|laravel|others" ;; esac local tld; tld="$(jget tld test)" local domain="$name.$tld" [ -n "$root" ] || root="$(jget sites_root "$HOME/BHServe/www")/$name" [ -n "$server" ] || server="$(jget default_web nginx)" local phpkey; phpkey="$(php_key "$phpv")" + local vhost_root="$root" + [ "$type" = "laravel" ] && vhost_root="$root/public" mkdir -p "$root" # Branded default landing page (identical to the Windows build). Quoted heredoc @@ -1317,7 +1319,7 @@ PHP render_fpm_pool "$phpkey" || true fpm_start "$phpkey" >/dev/null 2>&1 || true # ensure the site's PHP pool is up (no 502) - render_site_vhost "$name" "$domain" "$root" "$phpkey" "$server" + render_site_vhost "$name" "$domain" "$vhost_root" "$phpkey" "$server" # apache_start no-ops when httpd is already up, so the FIRST apache site worked but every # LATER one was never loaded (requests fell through to the first vhost). Reload when running. if [ "$server" = apache ]; then @@ -1326,6 +1328,7 @@ PHP hdr "Site '$name' added" info "domain : http://$domain" info "root : $root" + [ "$vhost_root" != "$root" ] && info "vhost : $vhost_root" info "php : $phpkey server: $server type: $type" site_provision "$name" "$type" "$root" "$domain" @@ -2684,7 +2687,7 @@ BHServe — usage: bhserve install brew install a service bhserve update brew upgrade a service bhserve uninstall brew uninstall a service - bhserve site add [--php 8.4] [--root path] [--server nginx|apache] [--type wordpress|php|others] + bhserve site add [--php 8.4] [--root path] [--server nginx|apache] [--type wordpress|php|laravel|others] bhserve site list|rm bhserve site php switch a site's PHP version bhserve db {list|create|drop|passwd} [name] [--engine mysql|pg] [--password PW] diff --git a/linux/app/bhserve/window.py b/linux/app/bhserve/window.py index 7d7f86d..25ddb93 100644 --- a/linux/app/bhserve/window.py +++ b/linux/app/bhserve/window.py @@ -318,8 +318,12 @@ def add_site_dialog(self, default_type="wordpress") -> None: dlg = Adw.MessageDialog(transient_for=self, heading="Add a website", body="Creates the site folder, vhost and *.test domain.") form = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8) + form.set_margin_top(12) + form.set_margin_bottom(12) + form.set_margin_start(12) + form.set_margin_end(12) name = Gtk.Entry(placeholder_text="site name (e.g. myshop)") - typ = Gtk.DropDown.new_from_strings(["wordpress", "php", "others"]) + typ = Gtk.DropDown.new_from_strings(["wordpress", "php", "laravel", "others"]) # Offer only the PHP versions actually installed (so you can't pick one that isn't there); # fall back to the full list if none installed yet. installed_php = [s["key"].replace("php@", "") for s in self.last_data.get("services", []) @@ -336,12 +340,34 @@ def add_site_dialog(self, default_type="wordpress") -> None: "on .htaccess changes and supports the LiteSpeed Cache plugin " "(installed automatically on first use).") ssl = Gtk.CheckButton(label="Enable trusted HTTPS (mkcert)", active=True) - for w, lab in ((name, "Name"), (typ, "Type"), (php, "PHP"), (srv, "Web server")): + dir_entry = Gtk.Entry(placeholder_text="Default folder (optional)", hexpand=True) + dir_box = Gtk.Box(spacing=6) + dir_box.append(dir_entry) + browse_btn = Gtk.Button(label="Browse…", valign=Gtk.Align.CENTER) + browse_btn.set_tooltip_text("Select project directory") + dir_box.append(browse_btn) + + for w, lab in ((name, "Name"), (typ, "Type"), (php, "PHP"), (srv, "Web server"), (dir_box, "Location")): row = Gtk.Box(spacing=10) row.append(Gtk.Label(label=lab, width_chars=10, xalign=0)) w.set_hexpand(True) row.append(w) form.append(row) + + def _pick_dir(btn, entry): + def on_pick(dialog, result): + try: + f = dialog.select_folder_finish(result) + if f: + entry.set_text(f.get_path()) + except Exception: + pass + + dlg = Gtk.FileDialog() + dlg.set_title("Select project directory") + dlg.select_folder(self, None, on_pick) + + browse_btn.connect("clicked", _pick_dir, dir_entry) form.append(ssl) dlg.set_extra_child(form) dlg.add_response("cancel", "Cancel") @@ -356,9 +382,12 @@ def resp(d, r): self.toast("Enter a site name") return args = ["site", "add", nm, - "--type", ["wordpress", "php", "others"][typ.get_selected()], + "--type", ["wordpress", "php", "laravel", "others"][typ.get_selected()], "--php", php_choices[php.get_selected()], "--server", ["nginx", "apache", "ols"][srv.get_selected()]] + d = dir_entry.get_text().strip() + if d: + args += ["--root", d] tld = self.last_data.get("config", {}).get("tld", "test") self._run_add_site(nm, args, ssl.get_active(), tld) @@ -433,17 +462,37 @@ def _app_dialog(self, kind) -> None: dlg = Adw.MessageDialog(transient_for=self, heading=title, body="A managed, supervised app served behind a *.test reverse proxy.") form = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8) - name = Gtk.Entry(placeholder_text="app name") - folder = Gtk.Entry(placeholder_text="/path/to/project") + folder = Gtk.Entry(placeholder_text="/path/to/project", hexpand=True) + folder_box = Gtk.Box(spacing=8) + folder_box.append(folder) + browse_btn = Gtk.Button(label="Browse…", valign=Gtk.Align.CENTER) + + def _pick_folder(btn, entry): + def on_pick(dialog, result): + try: + f = dialog.select_folder_finish(result) + if f: + entry.set_text(f.get_path()) + except Exception: + pass + + dlg = Gtk.FileDialog() + dlg.set_title("Select project directory") + dlg.select_folder(self, None, on_pick) + + browse_btn.connect("clicked", _pick_folder, folder) + folder_box.append(browse_btn) + cmd = Gtk.Entry(text="python app.py" if kind == "py" else "npm run dev") port = Gtk.SpinButton.new_with_range(1024, 65535, 1) port.set_value(8000 if kind == "py" else 3000) venv = Gtk.CheckButton(label="Create a virtualenv (.venv)", active=True) - rows = [(name, "Name"), (folder, "Folder"), (cmd, "Command"), (port, "Port")] + rows = [(name, "Name"), (folder_box, "Folder"), (cmd, "Command"), (port, "Port")] for w, lab in rows: row = Gtk.Box(spacing=10) row.append(Gtk.Label(label=lab, width_chars=10, xalign=0)) - w.set_hexpand(True) + if w is not folder_box: + w.set_hexpand(True) row.append(w) form.append(row) if kind == "py": diff --git a/linux/build.sh b/linux/build.sh old mode 100644 new mode 100755 diff --git a/macos/Sources/BHServe/AppState.swift b/macos/Sources/BHServe/AppState.swift index 2e3ef53..7208a82 100644 --- a/macos/Sources/BHServe/AppState.swift +++ b/macos/Sources/BHServe/AppState.swift @@ -615,7 +615,7 @@ final class AppState { // :8080 for .htaccess), so it needs BOTH — an Apache-only setup has nothing on :80. reqs.append(.init(key: "nginx", label: "nginx")) if server == "apache" { reqs.append(.init(key: "httpd", label: "Apache")) } - if type == "php" || type == "wordpress" { + if type is "php" or "laravel" or "wordpress" { reqs.append(.init(key: php, label: "PHP \(php.replacingOccurrences(of: "php@", with: ""))")) reqs.append(.init(key: mysqlServiceKey, label: mysqlLabel)) } diff --git a/macos/Sources/BHServe/SitesView.swift b/macos/Sources/BHServe/SitesView.swift index d009c63..ab098d0 100644 --- a/macos/Sources/BHServe/SitesView.swift +++ b/macos/Sources/BHServe/SitesView.swift @@ -200,6 +200,7 @@ struct AddSiteSheet: View { Picker("Type", selection: $type) { Text("WordPress").tag("wordpress") Text("PHP").tag("php") + Text("Laravel").tag("laravel") Text("Others (static)").tag("others") Text("Node app").tag("node") Text("Python app").tag("python") @@ -236,6 +237,7 @@ struct AddSiteSheet: View { switch type { case "wordpress": Text("Creates a database, downloads WordPress, and pre-fills wp-config (DB user root, no password). Just finish the title + admin step.") case "php": Text("Creates a database named after the site (DB user root, no password).") + case "laravel": Text("Creates a database named after the site; vhost points to site/public (DB user root, no password).") default: Text("Just sets up the domain — no database.") } } diff --git a/windows/src/BHServe.App/Views/NodePage.xaml.cs b/windows/src/BHServe.App/Views/NodePage.xaml.cs index ffa4bcc..6b795f5 100644 --- a/windows/src/BHServe.App/Views/NodePage.xaml.cs +++ b/windows/src/BHServe.App/Views/NodePage.xaml.cs @@ -145,18 +145,35 @@ private void OpenApp_Click(object s, RoutedEventArgs e) private static string Tag(object s) => (s as FrameworkElement)?.Tag as string ?? ""; + private static Grid WithBrowse(TextBox box) + { + var grid = new Grid { ColumnSpacing = 8 }; + grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); + grid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); + grid.Children.Add(box); + Grid.SetColumn(box, 0); + var btn = new Button { Content = "Browse…", VerticalAlignment = VerticalAlignment.Bottom }; + btn.Click += async (_, _) => { + var path = await Picker.FolderAsync(); + if (!string.IsNullOrEmpty(path)) box.Text = path; + }; + grid.Children.Add(btn); + Grid.SetColumn(btn, 1); + return grid; + } + private async void AddApp_Click(object s, RoutedEventArgs e) { var name = new TextBox { Header = "Name", PlaceholderText = "myapp" }; var feDir = new TextBox { Header = "Frontend folder", PlaceholderText = @"C:\path\to\frontend" }; var feCmd = new TextBox { Header = "Frontend command", Text = "npm run dev" }; var fePort = new NumberBox { Header = "Frontend port", Value = 3000, SpinButtonPlacementMode = NumberBoxSpinButtonPlacementMode.Inline }; - var beDir = new TextBox { Header = "Backend folder (optional)" }; + var beDir = new TextBox { Header = "Backend folder (optional)", PlaceholderText = @"C:\path\to\backend" }; var beCmd = new TextBox { Header = "Backend command (optional)", PlaceholderText = "npm start" }; var bePort = new NumberBox { Header = "Backend port (optional)", Value = double.NaN, SpinButtonPlacementMode = NumberBoxSpinButtonPlacementMode.Inline }; var api = new TextBox { Header = "API path → backend", Text = "/api" }; var panel = new StackPanel { Spacing = 8 }; - foreach (var c in new FrameworkElement[] { name, feDir, feCmd, fePort, beDir, beCmd, bePort, api }) panel.Children.Add(c); + foreach (var c in new FrameworkElement[] { name, WithBrowse(feDir), feCmd, fePort, WithBrowse(beDir), beCmd, bePort, api }) panel.Children.Add(c); var dlg = new ContentDialog { diff --git a/windows/src/BHServe.App/Views/PythonPage.xaml.cs b/windows/src/BHServe.App/Views/PythonPage.xaml.cs index 84380f3..5b51f59 100644 --- a/windows/src/BHServe.App/Views/PythonPage.xaml.cs +++ b/windows/src/BHServe.App/Views/PythonPage.xaml.cs @@ -160,6 +160,23 @@ private void TerminalApp_Click(object s, RoutedEventArgs e) private Task Info(string title, string body) => new ContentDialog { Title = title, Content = body, CloseButtonText = "OK", XamlRoot = this.XamlRoot }.ShowAsync().AsTask(); + private static Grid WithBrowse(TextBox box) + { + var grid = new Grid { ColumnSpacing = 8 }; + grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); + grid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); + grid.Children.Add(box); + Grid.SetColumn(box, 0); + var btn = new Button { Content = "Browse…", VerticalAlignment = VerticalAlignment.Bottom }; + btn.Click += async (_, _) => { + var path = await Picker.FolderAsync(); + if (!string.IsNullOrEmpty(path)) box.Text = path; + }; + grid.Children.Add(btn); + Grid.SetColumn(btn, 1); + return grid; + } + private async void AddApp_Click(object s, RoutedEventArgs e) { // Python must be installed for the app to run / venv to build — offer to install it first. @@ -185,7 +202,7 @@ private async void AddApp_Click(object s, RoutedEventArgs e) var port = new NumberBox { Header = "Port", Value = 8000, SpinButtonPlacementMode = NumberBoxSpinButtonPlacementMode.Inline }; var venv = new ToggleSwitch { Header = "Create a virtualenv (.venv)", IsOn = true }; var panel = new StackPanel { Spacing = 8 }; - foreach (var c in new FrameworkElement[] { name, dir, cmd, port, venv }) panel.Children.Add(c); + foreach (var c in new FrameworkElement[] { name, WithBrowse(dir), cmd, port, venv }) panel.Children.Add(c); var dlg = new ContentDialog { diff --git a/windows/src/BHServe.App/Views/SitesPage.xaml b/windows/src/BHServe.App/Views/SitesPage.xaml index 704ccdc..bcf44d1 100644 --- a/windows/src/BHServe.App/Views/SitesPage.xaml +++ b/windows/src/BHServe.App/Views/SitesPage.xaml @@ -21,11 +21,12 @@ - - - - - + + + + + + diff --git a/windows/src/BHServe.App/Views/SitesPage.xaml.cs b/windows/src/BHServe.App/Views/SitesPage.xaml.cs index cf6681d..75cb448 100644 --- a/windows/src/BHServe.App/Views/SitesPage.xaml.cs +++ b/windows/src/BHServe.App/Views/SitesPage.xaml.cs @@ -57,6 +57,7 @@ private async void Refresh() { "WordPress" => "wordpress", "PHP" => "php", + "Laravel" => "laravel", "Node app" => "node", "Python app" => "python", _ => "others", @@ -163,18 +164,35 @@ private void ClearCustomRoot() ToolTipService.SetToolTip(RootBtn, "Site root folder (optional — defaults to the Sites root)"); } + private static Grid WithBrowse(TextBox box) + { + var grid = new Grid { ColumnSpacing = 8 }; + grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); + grid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); + grid.Children.Add(box); + Grid.SetColumn(box, 0); + var btn = new Button { Content = "Browse…", VerticalAlignment = VerticalAlignment.Bottom }; + btn.Click += async (_, _) => { + var path = await Picker.FolderAsync(); + if (!string.IsNullOrEmpty(path)) box.Text = path; + }; + grid.Children.Add(btn); + Grid.SetColumn(btn, 1); + return grid; + } + /// Node-app setup sheet (revealed when Type = Node app), then create + show the result. private async Task AddNodeApp(string name) { var feDir = new TextBox { Header = "Frontend folder", PlaceholderText = @"C:\path\to\frontend" }; var feCmd = new TextBox { Header = "Frontend command", Text = "npm run dev" }; var fePort = new NumberBox { Header = "Frontend port", Value = 3000, SpinButtonPlacementMode = NumberBoxSpinButtonPlacementMode.Inline }; - var beDir = new TextBox { Header = "Backend folder (optional)" }; + var beDir = new TextBox { Header = "Backend folder (optional)", PlaceholderText = @"C:\path\to\backend" }; var beCmd = new TextBox { Header = "Backend command (optional)", PlaceholderText = "npm start" }; var bePort = new NumberBox { Header = "Backend port (optional)", Value = double.NaN, SpinButtonPlacementMode = NumberBoxSpinButtonPlacementMode.Inline }; var api = new TextBox { Header = "API path → backend", Text = "/api" }; var panel = new StackPanel { Spacing = 8 }; - foreach (var c in new FrameworkElement[] { feDir, feCmd, fePort, beDir, beCmd, bePort, api }) panel.Children.Add(c); + foreach (var c in new FrameworkElement[] { WithBrowse(feDir), feCmd, fePort, WithBrowse(beDir), beCmd, bePort, api }) panel.Children.Add(c); var dlg = new ContentDialog { @@ -208,7 +226,7 @@ private async Task AddPythonApp(string name) var venv = new ToggleSwitch { Header = "Create a virtualenv (.venv)", IsOn = true }; var hint = new TextBlock { Text = "Your app gets a PORT env var — read os.environ['PORT'] in code, or use %PORT% in the command (e.g. gunicorn app:app -b 127.0.0.1:%PORT%).", TextWrapping = TextWrapping.Wrap, Opacity = 0.7, FontSize = 12 }; var panel = new StackPanel { Spacing = 8 }; - foreach (var c in new FrameworkElement[] { dir, cmd, port, venv, hint }) panel.Children.Add(c); + foreach (var c in new FrameworkElement[] { WithBrowse(dir), cmd, port, venv, hint }) panel.Children.Add(c); var dlg = new ContentDialog { diff --git a/windows/src/BHServe.Cli/Program.cs b/windows/src/BHServe.Cli/Program.cs index 06a1c86..c77032d 100644 --- a/windows/src/BHServe.Cli/Program.cs +++ b/windows/src/BHServe.Cli/Program.cs @@ -237,7 +237,7 @@ bhserve init | doctor | status | api bhserve install bhserve start|stop|restart [svc|all] (svc: nginx|mariadb|mailpit|php@X) bhserve enable|disable - bhserve site add [--php 8.4] [--root path] [--server nginx|apache] [--type wordpress|php|others] + bhserve site add [--php 8.4] [--root path] [--server nginx|apache] [--type wordpress|php|laravel|others] bhserve site rm|list bhserve site php | site server bhserve site subdomain {list|add|rm} [label-or-host] diff --git a/windows/src/BHServe.Core/Engine.cs b/windows/src/BHServe.Core/Engine.cs index 47baf39..8c0e398 100644 --- a/windows/src/BHServe.Core/Engine.cs +++ b/windows/src/BHServe.Core/Engine.cs @@ -96,8 +96,8 @@ private List RequiredServices(string type, string php, string server) // owns :80/:443 (+ TLS + *.test) and proxies to it. So "Apache" needs BOTH nginx AND apache — // otherwise an Apache-only install has nothing serving :80 and the site is dead. var req = server == "apache" ? new List { "nginx", "apache" } : new List { "nginx" }; - if (type is "php" or "wordpress") req.Add(Services.PhpKey(php, cfg)); - if (type == "wordpress") + if (type is "php" or "laravel" or "wordpress") req.Add(Services.PhpKey(php, cfg)); + if (type is "wordpress" or "laravel") req.Add(Services.Installed("mysql", cfg) && !Services.Installed("mariadb", cfg) ? "mysql" : "mariadb"); return req; } @@ -529,6 +529,8 @@ public void SiteAdd(string name, string php = "", string? root = null, string se // which Laragon supports. Must start and end with an alphanumeric. if (!Regex.IsMatch(name, "^[a-z0-9]([a-z0-9.-]*[a-z0-9])?$")) throw new BhException($"invalid site name '{name}' (lowercase letters, digits, hyphens, dots)"); + if (type is not ("wordpress" or "php" or "laravel" or "others")) + throw new BhException("--type must be wordpress|php|laravel|others"); var cfg = Config.Load(); var domain = $"{name}.{cfg.Tld}"; @@ -538,6 +540,7 @@ public void SiteAdd(string name, string php = "", string? root = null, string se if (server == "apache" && !Apache.Available) throw new BhException("apache backend needs httpd — install Apache from the Services page first"); var phpKey = Services.PhpKey(php, cfg); var version = Services.PhpVersion(phpKey, cfg); + var vhostRoot = type == "laravel" ? Path.Combine(root, "public") : root; Directory.CreateDirectory(root); if (!File.Exists(Path.Combine(root, "index.php")) && !File.Exists(Path.Combine(root, "index.html"))) @@ -548,15 +551,15 @@ public void SiteAdd(string name, string php = "", string? root = null, string se if (server == "apache") { - Apache.RenderVhost(name, domain, root, phpKey, cfg); + Apache.RenderVhost(name, domain, vhostRoot, phpKey, cfg); // Apache.Start() NO-OPS when httpd is already running — so the SECOND (and every later) // Apache-backed site was never loaded into the running Apache: its requests fell through // to the first loaded vhost (another site!) until a manual restart. Reload when running. if (Apache.Running()) { Apache.Reload(); Ok("apache reloaded (new vhost)"); } else { var (aok, amsg) = Apache.Start(); if (aok) Ok(amsg); else Warn(amsg); } - NginxConfig.RenderApacheFront(name, domain, root, phpKey, Apache.Port, cfg); + NginxConfig.RenderApacheFront(name, domain, vhostRoot, phpKey, Apache.Port, cfg); } - else NginxConfig.RenderPhpVhost(name, domain, root, phpKey, cfg); + else NginxConfig.RenderPhpVhost(name, domain, vhostRoot, phpKey, cfg); Ok($"site vhost: {Path.Combine(Paths.NginxSites, name + ".conf")} (server={server})"); // Serve it first (so the site works immediately), then map the hostname. @@ -575,7 +578,7 @@ public void SiteAdd(string name, string php = "", string? root = null, string se /// Per-type setup: WordPress (DB + files + wp-config) or php (DB only). private void Provision(string name, string type, string root) { - if (type is not ("php" or "wordpress")) return; + if (type is not ("php" or "laravel" or "wordpress")) return; // This site type needs a database. Make sure the server is installed + running, // installing it on demand (a fresh BHServe has no services yet). if (Tools.MysqldExe() is null) From c23ecc1fe1e925dbacb891a081c7e15fb0cf0f03 Mon Sep 17 00:00:00 2001 From: plusemon Date: Thu, 23 Jul 2026 10:19:39 +0600 Subject: [PATCH 3/4] feat: add site root change option and restart nginx on config change --- engine/bhserve | 14 +++++++------- engine/platform-linux.sh | 20 ++++++++++---------- linux/app/bhserve/pages.py | 18 ++++++++++++++++++ windows/src/BHServe.Core/Nginx.cs | 8 ++++++-- 4 files changed, 41 insertions(+), 19 deletions(-) diff --git a/engine/bhserve b/engine/bhserve index a4178c9..024c651 100755 --- a/engine/bhserve +++ b/engine/bhserve @@ -1653,18 +1653,18 @@ nginx_start(){ grep -q "syntax is ok" <<<"$t" || { printf '%s\n' "$t"; die "nginx config test failed"; } $pre "$bin" -c "$BH_HOME/nginx/nginx.conf" -p "$BH_HOME/nginx" && ok "nginx started${pre:+ (sudo)}" || no "nginx failed to start" } -# Graceful reload of the running nginx (picks up new/changed vhosts). nginx_reload(){ nginx_running || return 0 - local bin pre=""; bin="$(NGINX_BIN)"; needs_root_ports && pre="sudo" - $pre "$bin" -s reload -c "$BH_HOME/nginx/nginx.conf" -p "$BH_HOME/nginx" 2>/dev/null \ - && ok "nginx reloaded" || warn "reload failed — run: bhserve restart nginx" + nginx_restart >/dev/null 2>&1 && ok "nginx restarted" || { + local bin pre=""; bin="$(NGINX_BIN)"; needs_root_ports && pre="sudo" + $pre "$bin" -s reload -c "$BH_HOME/nginx/nginx.conf" -p "$BH_HOME/nginx" 2>/dev/null \ + && ok "nginx reloaded" || warn "reload failed — run: bhserve restart nginx" + } } -# Auto-apply config changes: reload interactively (one sudo prompt); from the GUI -# (no tty) just note it — the GUI issues its own privileged restart. +# Auto-apply config changes maybe_reload_nginx(){ nginx_running || return 0 - if [ -t 1 ]; then nginx_reload; else info "reload nginx to serve changes (bhserve restart nginx)"; fi + nginx_reload } nginx_stop(){ diff --git a/engine/platform-linux.sh b/engine/platform-linux.sh index a77382e..fc668f3 100644 --- a/engine/platform-linux.sh +++ b/engine/platform-linux.sh @@ -661,21 +661,21 @@ hosts_sync_all(){ # go through here; in non-tty (GUI) mode it still syncs hosts before deferring the reload. maybe_reload_nginx(){ hosts_sync_all - # ALWAYS (re)load nginx when a site changes. The old `[ -t 1 ]` tty gate meant the GUI (which runs - # us non-interactively) never actually reloaded — so a GUI-added site's vhost wasn't served until a - # manual restart. And if nginx is down, START it (else the new site 502s / doesn't load at all). - if nginx_running; then nginx_reload; else nginx_start >/dev/null 2>&1 && ok "nginx started" || warn "start nginx to serve the site (bhserve start nginx)"; fi + if nginx_running; then + nginx_restart >/dev/null 2>&1 || nginx_reload + else + nginx_start >/dev/null 2>&1 && ok "nginx started" || warn "start nginx to serve the site (bhserve start nginx)" + fi } -# nodesite/pysite add + `secure` call nginx_reload DIRECTLY (not via maybe_reload_nginx), -# so sync /etc/hosts here too — this is the real choke point every reload passes through. -# hosts_sync_all is idempotent (no-op + no sudo when nothing changed), so double calls are free. nginx_reload(){ hosts_sync_all nginx_running || return 0 - local bin pre=""; bin="$(NGINX_BIN)"; needs_root_ports && pre="sudo" - $pre "$bin" -s reload -c "$BH_HOME/nginx/nginx.conf" -p "$BH_HOME/nginx" 2>/dev/null \ - && ok "nginx reloaded" || warn "reload failed — run: bhserve restart nginx" + nginx_restart >/dev/null 2>&1 && ok "nginx restarted" || { + local bin pre=""; bin="$(NGINX_BIN)"; needs_root_ports && pre="sudo" + $pre "$bin" -s reload -c "$BH_HOME/nginx/nginx.conf" -p "$BH_HOME/nginx" 2>/dev/null \ + && ok "nginx reloaded" || warn "reload failed — run: bhserve restart nginx" + } } # `bhserve dns` — default: (re)sync /etc/hosts for all sites. `bhserve dns wildcard` — diff --git a/linux/app/bhserve/pages.py b/linux/app/bhserve/pages.py index 1bc9efd..b4996de 100644 --- a/linux/app/bhserve/pages.py +++ b/linux/app/bhserve/pages.py @@ -120,6 +120,23 @@ def site_change_php(win, s: dict) -> None: ("ols", "Switch to OpenLiteSpeed")] +def site_change_root(win, s: dict) -> None: + def on_pick(dialog, result): + try: + f = dialog.select_folder_finish(result) + if f: + path = f.get_path() + win.run_verb(["site", "root", s["name"], path], f"Changing root for {s['name']} → {path}…") + except Exception: + pass + + dlg = Gtk.FileDialog() + dlg.set_title(f"Select new root directory for {s['name']}") + if s.get("root") and os.path.isdir(s["root"]): + dlg.set_initial_folder(Gio.File.new_for_path(s["root"])) + dlg.select_folder(win, None, on_pick) + + def _site_menu(win, s: dict) -> Gtk.Popover: pop = Gtk.Popover() v = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2, margin_top=6, margin_bottom=6, @@ -144,6 +161,7 @@ def item(label, icon, cb, destructive=False): continue item(_label, "network-server-symbolic", lambda v=_val: win.run_verb(["site", "server", name, v], f"Switching {name} → {v}…")) + item("Change root directory…", "folder-symbolic", lambda: site_change_root(win, s)) dom = s["domain"] if not s.get("secure"): item("Install SSL (HTTPS)", "security-high-symbolic", diff --git a/windows/src/BHServe.Core/Nginx.cs b/windows/src/BHServe.Core/Nginx.cs index 908b16a..715ddbf 100644 --- a/windows/src/BHServe.Core/Nginx.cs +++ b/windows/src/BHServe.Core/Nginx.cs @@ -121,7 +121,11 @@ public static void Reload(Config cfg) { NginxConfig.RenderMain(cfg); var exe = Tools.NginxExe(); - if (exe is null || !Running()) return; - Run(exe, $"-s reload -p \"{NginxConfig.Fwd(NginxDir)}\" -c \"{NginxConfig.Fwd(ConfPath)}\""); + if (exe is null) return; + if (Running()) + { + Stop(); + Start(cfg); + } } } From 7ff67028816d985b572ec877b7b36d305fbf2970 Mon Sep 17 00:00:00 2001 From: plusemon Date: Thu, 23 Jul 2026 10:32:48 +0600 Subject: [PATCH 4/4] fix(linux): resolve MySQL root password removal and prompt for current password when set --- engine/bhserve | 68 ++++++++++++++++++++++++++++++++----- engine/platform-linux.sh | 14 ++++++-- linux/app/bhserve/window.py | 63 +++++++++++++++++++++++++++++----- 3 files changed, 124 insertions(+), 21 deletions(-) diff --git a/engine/bhserve b/engine/bhserve index 024c651..3360aab 100755 --- a/engine/bhserve +++ b/engine/bhserve @@ -1809,7 +1809,7 @@ cmd_restart() { } # ── databases (mysql/mariadb + postgresql) ────────────────────────────────── -MYSQL_CLI(){ echo "$BREW_PREFIX/bin/mysql"; } +MYSQL_CLI(){ [ -x "$BREW_PREFIX/bin/mysql" ] && echo "$BREW_PREFIX/bin/mysql" || command -v mysql 2>/dev/null || command -v mariadb 2>/dev/null || echo /usr/bin/mysql; } PSQL_CLI(){ echo "$BREW_PREFIX/opt/postgresql@17/bin/psql"; } PG_BIN(){ echo "$BREW_PREFIX/opt/postgresql@17/bin/$1"; } @@ -1821,8 +1821,14 @@ valid_site_name(){ [[ "$1" =~ ^[a-z0-9][a-z0-9.-]*$ ]] || die "invalid site name # unix_socket account), then passwordless -u root (Oracle MySQL / older setups). mysql_run(){ local c; c="$(MYSQL_CLI)"; [ -x "$c" ] || return 127 + local saved_pw; saved_pw="$(jget root_password "")" if "$c" -N -e "SELECT 1;" >/dev/null 2>&1; then "$c" "$@" elif "$c" -u root -N -e "SELECT 1;" >/dev/null 2>&1; then "$c" -u root "$@" + elif [ -n "${BHSERVE_OLD_DB_PASSWORD:-}" ] && MYSQL_PWD="$BHSERVE_OLD_DB_PASSWORD" "$c" -u root -N -e "SELECT 1;" >/dev/null 2>&1; then MYSQL_PWD="$BHSERVE_OLD_DB_PASSWORD" "$c" -u root "$@" + elif [ -n "${DB_OLD_PASSWORD:-}" ] && MYSQL_PWD="$DB_OLD_PASSWORD" "$c" -u root -N -e "SELECT 1;" >/dev/null 2>&1; then MYSQL_PWD="$DB_OLD_PASSWORD" "$c" -u root "$@" + elif [ -n "${DB_PASSWORD:-}" ] && MYSQL_PWD="$DB_PASSWORD" "$c" -u root -N -e "SELECT 1;" >/dev/null 2>&1; then MYSQL_PWD="$DB_PASSWORD" "$c" -u root "$@" + elif [ -n "${BHSERVE_DB_PASSWORD:-}" ] && MYSQL_PWD="$BHSERVE_DB_PASSWORD" "$c" -u root -N -e "SELECT 1;" >/dev/null 2>&1; then MYSQL_PWD="$BHSERVE_DB_PASSWORD" "$c" -u root "$@" + elif [ -n "$saved_pw" ] && MYSQL_PWD="$saved_pw" "$c" -u root -N -e "SELECT 1;" >/dev/null 2>&1; then MYSQL_PWD="$saved_pw" "$c" -u root "$@" else return 1; fi } @@ -1871,18 +1877,59 @@ cmd_db(){ # Whether mysql/mariadb root@localhost has a password set. Prints set|blank|unavailable. db_root_status(){ - local r + local r out cli r="$(mysql_run -N -e "SELECT IF(LENGTH(authentication_string)>0,'set','blank') FROM mysql.user WHERE user='root' AND host='localhost' LIMIT 1;" 2>/dev/null)" || true - [ -n "$r" ] && printf '%s\n' "$r" || echo unavailable + if [ -n "$r" ]; then + printf '%s\n' "$r" + return 0 + fi + cli="$(MYSQL_CLI 2>/dev/null || echo mysql)" + out="$("$cli" -u root -N -e "SELECT 1;" 2>&1 || true)" + if grep -qi "Access denied" <<<"$out"; then + echo set + else + echo unavailable + fi } # Set (or, with empty password, clear) the mysql/mariadb root@localhost password. # Only touches root — the OS-user socket account we operate through is untouched. db_root_passwd(){ local epw; epw="$(mysql_esc "$DB_PASSWORD")" - mysql_run -e "ALTER USER 'root'@'localhost' IDENTIFIED BY '$epw'; FLUSH PRIVILEGES;" \ - && ok "root password $([ -n "$DB_PASSWORD" ] && echo set || echo "cleared (blank)")" \ - || die "failed (server running? do you have privilege?)" + local sql="ALTER USER 'root'@'localhost' IDENTIFIED BY '$epw'; FLUSH PRIVILEGES;" + if mysql_run -e "$sql" 2>/dev/null; then + json_set "root_password" "$DB_PASSWORD" 2>/dev/null || true + ok "root password $([ -n "$DB_PASSWORD" ] && echo set || echo "cleared (blank)")" + return 0 + fi + # Fallback 1: Privileged execution via pkexec/sudo if unprivileged connect failed + local cli; cli="$(MYSQL_CLI 2>/dev/null || echo mysql)" + if [ -x "$cli" ]; then + if [ "$(id -u)" != 0 ] && command -v pkexec >/dev/null 2>&1 && [ -n "${DISPLAY:-}${WAYLAND_DISPLAY:-}" ]; then + if pkexec "$cli" -e "$sql" 2>/dev/null; then + json_set "root_password" "$DB_PASSWORD" 2>/dev/null || true + ok "root password $([ -n "$DB_PASSWORD" ] && echo set || echo "cleared (blank)")" + return 0 + fi + elif [ "$(id -u)" = 0 ] || command -v sudo >/dev/null 2>&1; then + if $SUDO "$cli" -e "$sql" 2>/dev/null; then + json_set "root_password" "$DB_PASSWORD" 2>/dev/null || true + ok "root password $([ -n "$DB_PASSWORD" ] && echo set || echo "cleared (blank)")" + return 0 + fi + fi + fi + # Fallback 2: Legacy MariaDB/MySQL SQL syntax + for alt_sql in \ + "ALTER USER 'root'@'localhost' IDENTIFIED VIA mysql_native_password USING '$epw'; FLUSH PRIVILEGES;" \ + "SET PASSWORD FOR 'root'@'localhost' = PASSWORD('$epw'); FLUSH PRIVILEGES;"; do + if mysql_run -e "$alt_sql" 2>/dev/null; then + json_set "root_password" "$DB_PASSWORD" 2>/dev/null || true + ok "root password $([ -n "$DB_PASSWORD" ] && echo set || echo "cleared (blank)")" + return 0 + fi + done + die "failed (server running? do you have privilege?)" } db_list(){ @@ -2019,14 +2066,15 @@ cmd_config(){ # Rewrite bhserve.json from current values, overriding one key. json_set(){ local k="$1" v="$2" - local tld httpp httpsp dphp dweb bp sroot autostart + local tld httpp httpsp dphp dweb bp sroot autostart rootpw tld="$(jget tld test)"; httpp="$(jget http_port 80)"; httpsp="$(jget https_port 443)" dphp="$(jget default_php 8.4)"; dweb="$(jget default_web nginx)" bp="$(jget brew_prefix "$BREW_PREFIX")"; sroot="$(jget sites_root "$HOME/BHServe/www")" - autostart="$(jget autostart false)" + autostart="$(jget autostart false)"; rootpw="$(jget root_password "")" case "$k" in tld) tld="$v";; http_port) httpp="$v";; https_port) httpsp="$v";; default_php) dphp="$v";; default_web) dweb="$v";; sites_root) sroot="$v";; autostart) autostart="$v";; + root_password) rootpw="$v";; esac cat > "$BH_HOME/config/bhserve.json" </dev/null 2>&1; then "$c" "$@" - elif $SUDO "$c" -N -e "SELECT 1;" >/dev/null 2>&1; then $SUDO "$c" "$@" elif "$c" -u root -N -e "SELECT 1;" >/dev/null 2>&1; then "$c" -u root "$@" + elif [ -n "${BHSERVE_OLD_DB_PASSWORD:-}" ] && MYSQL_PWD="$BHSERVE_OLD_DB_PASSWORD" "$c" -u root -N -e "SELECT 1;" >/dev/null 2>&1; then MYSQL_PWD="$BHSERVE_OLD_DB_PASSWORD" "$c" -u root "$@" + elif [ -n "${DB_OLD_PASSWORD:-}" ] && MYSQL_PWD="$DB_OLD_PASSWORD" "$c" -u root -N -e "SELECT 1;" >/dev/null 2>&1; then MYSQL_PWD="$DB_OLD_PASSWORD" "$c" -u root "$@" + elif [ -n "${DB_PASSWORD:-}" ] && MYSQL_PWD="$DB_PASSWORD" "$c" -u root -N -e "SELECT 1;" >/dev/null 2>&1; then MYSQL_PWD="$DB_PASSWORD" "$c" -u root "$@" + elif [ -n "${BHSERVE_DB_PASSWORD:-}" ] && MYSQL_PWD="$BHSERVE_DB_PASSWORD" "$c" -u root -N -e "SELECT 1;" >/dev/null 2>&1; then MYSQL_PWD="$BHSERVE_DB_PASSWORD" "$c" -u root "$@" + elif [ -n "$saved_pw" ] && MYSQL_PWD="$saved_pw" "$c" -u root -N -e "SELECT 1;" >/dev/null 2>&1; then MYSQL_PWD="$saved_pw" "$c" -u root "$@" + elif command -v sudo >/dev/null 2>&1 && $SUDO "$c" -N -e "SELECT 1;" >/dev/null 2>&1; then $SUDO "$c" "$@" + elif command -v sudo >/dev/null 2>&1 && $SUDO "$c" -u root -N -e "SELECT 1;" >/dev/null 2>&1; then $SUDO "$c" -u root "$@" else return 1; fi } # Make root@localhost a BLANK-password native account — same posture as the Mac (root has # no password; the server is bound to loopback only) so the engine + WordPress connect over -# TCP as root. Tries MariaDB then MySQL syntax. +# TCP as root. Tries MySQL 8.x, MariaDB, then legacy syntax. _db_open_root(){ local cli i; cli="$(MYSQL_CLI)" for i in 1 2 3 4 5 6; do $SUDO "$cli" -e "SELECT 1" >/dev/null 2>&1 && break; sleep 1; done - $SUDO "$cli" -e "ALTER USER 'root'@'localhost' IDENTIFIED VIA mysql_native_password USING ''; FLUSH PRIVILEGES;" >/dev/null 2>&1 \ + $SUDO "$cli" -e "ALTER USER 'root'@'localhost' IDENTIFIED BY ''; FLUSH PRIVILEGES;" >/dev/null 2>&1 \ + || $SUDO "$cli" -e "ALTER USER 'root'@'localhost' IDENTIFIED VIA mysql_native_password USING ''; FLUSH PRIVILEGES;" >/dev/null 2>&1 \ || $SUDO "$cli" -e "ALTER USER 'root'@'localhost' IDENTIFIED WITH mysql_native_password BY ''; FLUSH PRIVILEGES;" >/dev/null 2>&1 \ || $SUDO "$cli" -e "SET PASSWORD FOR 'root'@'localhost' = ''; FLUSH PRIVILEGES;" >/dev/null 2>&1 || true db_secure_bind diff --git a/linux/app/bhserve/window.py b/linux/app/bhserve/window.py index 25ddb93..542cc6d 100644 --- a/linux/app/bhserve/window.py +++ b/linux/app/bhserve/window.py @@ -586,15 +586,60 @@ def _pw_dialog(self, heading, body, hint, on_apply, apply_label="Apply", initial def db_root_dialog(self) -> None: # Pass the password via BHSERVE_DB_PASSWORD env, not argv — keeps it out of `ps`. - self._pw_dialog( - "Root password", - "Sets the MySQL/MariaDB root password BHServe uses everywhere (new WordPress sites + " - "phpMyAdmin). Leave blank to remove it. Local-dev only.", - "new root password (blank = remove)", - lambda pw: self.run_verb(["db", "root-passwd"], - "Setting root password…" if pw else "Removing root password…", - env={"BHSERVE_DB_PASSWORD": pw}), - apply_label="Apply") + _rc, out = self.engine.run("db", "root-status") + root_status = out.strip().splitlines()[-1].strip() if out.strip() else "" + is_set = (root_status == "set") + + dlg = Adw.MessageDialog( + transient_for=self, + heading="Root password", + body="Sets or removes the MySQL/MariaDB root password. Leave blank to remove it. Local-dev only.") + box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10) + box.set_margin_top(12) + box.set_margin_bottom(12) + box.set_margin_start(12) + box.set_margin_end(12) + + old_entry = Gtk.Entry(placeholder_text="Current root password", hexpand=True) + if is_set: + old_row = Gtk.Box(spacing=10) + old_row.append(Gtk.Label(label="Current", width_chars=10, xalign=0)) + old_row.append(old_entry) + box.append(old_row) + + new_entry = Gtk.Entry( + placeholder_text="New root password (blank = remove)" if is_set else "New root password", + hexpand=True) + new_row_box = Gtk.Box(spacing=8) + new_row_box.append(new_entry) + gen = Gtk.Button(label="Generate", valign=Gtk.Align.CENTER) + gen.connect("clicked", lambda *_: new_entry.set_text(self._gen_password())) + new_row_box.append(gen) + + new_row = Gtk.Box(spacing=10) + new_row.append(Gtk.Label(label="New", width_chars=10, xalign=0)) + new_row.append(new_row_box) + box.append(new_row) + + dlg.set_extra_child(box) + dlg.add_response("cancel", "Cancel") + dlg.add_response("ok", "Apply") + dlg.set_response_appearance("ok", Adw.ResponseAppearance.SUGGESTED) + + def resp(d, r): + if r != "ok": + return + old_pw = old_entry.get_text() if is_set else "" + new_pw = new_entry.get_text() + msg = ("Removing root password…" if not new_pw else + ("Changing root password…" if is_set else "Setting root password…")) + env = {"BHSERVE_DB_PASSWORD": new_pw} + if old_pw: + env["BHSERVE_OLD_DB_PASSWORD"] = old_pw + self.run_verb(["db", "root-passwd"], msg, env=env) + + dlg.connect("response", resp) + dlg.present() def db_password_dialog(self, name) -> None: self._pw_dialog(