From 9a77fda9cb27ebe23071a5f0d61dc7221288e379 Mon Sep 17 00:00:00 2001 From: Akif Gumussu Date: Tue, 23 Jun 2026 16:24:35 +0200 Subject: [PATCH] Self-heal web-UI service commands when their container is stopped MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit phpmyadmin, elasticvue and mailpit dead-ended when their global container was stopped (e.g. after a Docker or machine restart): open errored out and enable printed "already enabled" while doing nothing. Now open/enable start the container on demand when the service is enabled (mailpit is always on), and enable is idempotent. Only the requested UI container is started — no full global start, no databases pulled in — and a clear message is shown when Docker is not running. status now points to the relevant open command. Shared helpers (isContainerRunning, openInBrowser, ensureGlobalServiceRunning, decideServiceUI) de-duplicate the logic. --- CHANGELOG.md | 6 ++ cmd/magebox/elasticvue.go | 134 ++++++++++++++++++-------------- cmd/magebox/mailpit.go | 49 ++++++------ cmd/magebox/phpmyadmin.go | 134 ++++++++++++++++++-------------- cmd/magebox/service_ui.go | 85 ++++++++++++++++++++ cmd/magebox/service_ui_test.go | 25 ++++++ vitepress/reference/commands.md | 24 +++++- 7 files changed, 307 insertions(+), 150 deletions(-) create mode 100644 cmd/magebox/service_ui.go create mode 100644 cmd/magebox/service_ui_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 6db5382..95b9075 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to MageBox will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Fixed + +- **Self-Healing Web-UI Commands** - `phpmyadmin`, `elasticvue`, and `mailpit` now start their container on demand instead of dead-ending when it is stopped (which happens routinely after a Docker Desktop or machine restart). `phpmyadmin open` and `elasticvue open` start the container when the service is enabled but stopped, then open the browser; `mailpit open` always starts it (Mailpit is always on). `phpmyadmin enable` and `elasticvue enable` are now idempotent — when already enabled but stopped, they restart the container instead of printing "already enabled" and doing nothing. Only the requested UI container is started (no full `global start`, no databases dragged in), and a clear message is shown when Docker itself is not running. `status` now points to the relevant `open` command. Shared helpers (`isContainerRunning`, `openInBrowser`, `ensureGlobalServiceRunning`, `decideServiceUI`) de-duplicate the logic across the three commands. + ## [1.18.2] - 2026-06-23 ### Fixed diff --git a/cmd/magebox/elasticvue.go b/cmd/magebox/elasticvue.go index 4af7785..e42f406 100644 --- a/cmd/magebox/elasticvue.go +++ b/cmd/magebox/elasticvue.go @@ -3,7 +3,6 @@ package main import ( "fmt" "os/exec" - "runtime" "strings" "github.com/spf13/cobra" @@ -15,12 +14,16 @@ import ( "qoliber/magebox/internal/project" ) -const elasticvueDefaultPort = "8090" +const ( + elasticvueDefaultPort = "8090" + elasticvueContainer = "magebox-elasticvue" + elasticvueService = "elasticvue" +) // getElasticvueURL returns the Elasticvue URL, reading the actual port from the running container. // Falls back to the default port if the container is not running. func getElasticvueURL() string { - portCmd := exec.Command("docker", "port", "magebox-elasticvue", "8080") + portCmd := exec.Command("docker", "port", elasticvueContainer, "8080") output, err := portCmd.Output() if err == nil { for _, line := range strings.Split(strings.TrimSpace(string(output)), "\n") { @@ -64,7 +67,7 @@ var elasticvueStatusCmd = &cobra.Command{ var elasticvueOpenCmd = &cobra.Command{ Use: "open", Short: "Open Elasticvue in browser", - Long: "Opens the Elasticvue web UI in the default browser", + Long: "Opens the Elasticvue web UI in the default browser, starting it if needed", RunE: runElasticvueOpen, } @@ -87,48 +90,55 @@ func runElasticvueEnable(cmd *cobra.Command, args []string) error { return fmt.Errorf("failed to load global config: %w", err) } - if globalCfg.Elasticvue { - cli.PrintInfo("Elasticvue is already enabled") + switch decideServiceUI(globalCfg.Elasticvue, isContainerRunning(elasticvueContainer)) { + case decisionProceed: + cli.PrintInfo("Elasticvue is already enabled and running") fmt.Println() fmt.Printf(" Web UI: %s\n", cli.Highlight(getElasticvueURL())) return nil - } - cli.PrintTitle("Enabling Elasticvue") - fmt.Println() + case decisionStart: + cli.PrintTitle("Starting Elasticvue") + fmt.Println() + fmt.Print("Elasticvue is enabled but stopped, starting... ") + if err := ensureGlobalServiceRunning(p, elasticvueService); err != nil { + fmt.Println(cli.Error("failed")) + cli.PrintError("%v", err) + return nil + } + fmt.Println(cli.Success("done")) + fmt.Println() + cli.PrintSuccess("Elasticvue started!") + fmt.Println() + fmt.Printf(" Web UI: %s\n", cli.Highlight(getElasticvueURL())) + return nil - globalCfg.Elasticvue = true - if err := config.SaveGlobalConfig(p.HomeDir, globalCfg); err != nil { - return fmt.Errorf("failed to save config: %w", err) - } + default: // decisionNotEnabled + cli.PrintTitle("Enabling Elasticvue") + fmt.Println() - // Regenerate docker-compose and start - fmt.Print("Starting Elasticvue container... ") - composeGen := docker.NewComposeGenerator(p) + globalCfg.Elasticvue = true + if err := config.SaveGlobalConfig(p.HomeDir, globalCfg); err != nil { + return fmt.Errorf("failed to save config: %w", err) + } - // Discover all project configs to regenerate docker-compose - configs := discoverAllConfigs(p) - if err := composeGen.GenerateGlobalServices(configs); err != nil { - fmt.Println(cli.Error("failed")) - return fmt.Errorf("failed to generate docker-compose: %w", err) - } + fmt.Print("Starting Elasticvue container... ") + if err := ensureGlobalServiceRunning(p, elasticvueService); err != nil { + fmt.Println(cli.Error("failed")) + cli.PrintError("%v", err) + return nil + } + fmt.Println(cli.Success("done")) - dockerCtrl := docker.NewDockerController(composeGen.ComposeFilePath()) - if err := dockerCtrl.StartService("elasticvue"); err != nil { - fmt.Println(cli.Error("failed")) - return fmt.Errorf("failed to start Elasticvue: %w", err) + fmt.Println() + cli.PrintSuccess("Elasticvue enabled!") + fmt.Println() + fmt.Printf(" Web UI: %s\n", cli.Highlight(getElasticvueURL())) + fmt.Println() + cli.PrintInfo("Add your cluster in the Elasticvue UI using http://localhost:") + cli.PrintInfo("Check 'magebox status' or the ports reference for your search engine's port") + return nil } - fmt.Println(cli.Success("done")) - - fmt.Println() - cli.PrintSuccess("Elasticvue enabled!") - fmt.Println() - fmt.Printf(" Web UI: %s\n", cli.Highlight(getElasticvueURL())) - fmt.Println() - cli.PrintInfo("Add your cluster in the Elasticvue UI using http://localhost:") - cli.PrintInfo("Check 'magebox status' or the ports reference for your search engine's port") - - return nil } func runElasticvueDisable(cmd *cobra.Command, args []string) error { @@ -154,7 +164,7 @@ func runElasticvueDisable(cmd *cobra.Command, args []string) error { fmt.Print("Stopping Elasticvue container... ") composeGen := docker.NewComposeGenerator(p) dockerCtrl := docker.NewDockerController(composeGen.ComposeFilePath()) - if err := dockerCtrl.StopService("elasticvue"); err != nil { + if err := dockerCtrl.StopService(elasticvueService); err != nil { fmt.Println(cli.Warning("not running")) } else { fmt.Println(cli.Success("done")) @@ -166,8 +176,7 @@ func runElasticvueDisable(cmd *cobra.Command, args []string) error { } // Regenerate docker-compose without Elasticvue - configs := discoverAllConfigs(p) - if err := composeGen.GenerateGlobalServices(configs); err != nil { + if err := composeGen.GenerateGlobalServices(discoverAllConfigs(p)); err != nil { return fmt.Errorf("failed to update docker-compose: %w", err) } @@ -200,44 +209,49 @@ func runElasticvueStatus(cmd *cobra.Command, args []string) error { fmt.Println("Enabled: " + cli.Success("yes")) - // Check if container is running - checkCmd := exec.Command("docker", "ps", "--filter", "name=magebox-elasticvue", "--filter", "status=running", "-q") - output, err := checkCmd.Output() - if err == nil && len(strings.TrimSpace(string(output))) > 0 { + if isContainerRunning(elasticvueContainer) { fmt.Println("Status: " + cli.Success("running")) fmt.Printf("Web UI: %s\n", cli.Highlight(getElasticvueURL())) } else { fmt.Println("Status: " + cli.Warning("stopped")) fmt.Println() - cli.PrintInfo("Start global services with: magebox global start") + cli.PrintInfo("Start with: magebox elasticvue open") } return nil } func runElasticvueOpen(cmd *cobra.Command, args []string) error { - // Check if container is running - checkCmd := exec.Command("docker", "ps", "--filter", "name=magebox-elasticvue", "--filter", "status=running", "-q") - output, err := checkCmd.Output() - if err != nil || len(strings.TrimSpace(string(output))) == 0 { - cli.PrintError("Elasticvue is not running") + p, err := getPlatform() + if err != nil { + return err + } + + globalCfg, err := config.LoadGlobalConfig(p.HomeDir) + if err != nil { + return fmt.Errorf("failed to load global config: %w", err) + } + + switch decideServiceUI(globalCfg.Elasticvue, isContainerRunning(elasticvueContainer)) { + case decisionNotEnabled: + cli.PrintError("Elasticvue is not enabled") fmt.Println() cli.PrintInfo("Enable with: magebox elasticvue enable") return nil + + case decisionStart: + fmt.Print("Elasticvue is not running, starting... ") + if err := ensureGlobalServiceRunning(p, elasticvueService); err != nil { + fmt.Println(cli.Error("failed")) + cli.PrintError("%v", err) + return nil + } + fmt.Println(cli.Success("done")) } url := getElasticvueURL() cli.PrintInfo("Opening %s", cli.URL(url)) - - var openCmd *exec.Cmd - switch runtime.GOOS { - case "darwin": - openCmd = exec.Command("open", url) - default: - openCmd = exec.Command("xdg-open", url) - } - - return openCmd.Start() + return openInBrowser(url) } // discoverAllConfigs loads configs from all registered MageBox projects diff --git a/cmd/magebox/mailpit.go b/cmd/magebox/mailpit.go index 0af57d8..48e6a29 100644 --- a/cmd/magebox/mailpit.go +++ b/cmd/magebox/mailpit.go @@ -3,7 +3,6 @@ package main import ( "fmt" "os/exec" - "runtime" "strings" "github.com/spf13/cobra" @@ -11,12 +10,16 @@ import ( "qoliber/magebox/internal/cli" ) -const mailpitDefaultPort = "8025" +const ( + mailpitDefaultPort = "8025" + mailpitContainer = "magebox-mailpit" + mailpitService = "mailpit" +) // getMailpitURL returns the Mailpit URL, reading the actual port from the running container. // Falls back to the default port if the container is not running. func getMailpitURL() string { - portCmd := exec.Command("docker", "port", "magebox-mailpit", "8025") + portCmd := exec.Command("docker", "port", mailpitContainer, "8025") output, err := portCmd.Output() if err == nil { for _, line := range strings.Split(strings.TrimSpace(string(output)), "\n") { @@ -39,7 +42,7 @@ var mailpitCmd = &cobra.Command{ var mailpitOpenCmd = &cobra.Command{ Use: "open", Short: "Open Mailpit in browser", - Long: "Opens the Mailpit web UI in the default browser", + Long: "Opens the Mailpit web UI in the default browser, starting it if needed", RunE: runMailpitOpen, } @@ -57,28 +60,23 @@ func init() { } func runMailpitOpen(cmd *cobra.Command, args []string) error { - // Check if container is running - checkCmd := exec.Command("docker", "ps", "--filter", "name=magebox-mailpit", "--filter", "status=running", "-q") - output, err := checkCmd.Output() - if err != nil || len(strings.TrimSpace(string(output))) == 0 { - cli.PrintError("Mailpit is not running") - fmt.Println() - cli.PrintInfo("Start global services with: magebox global start") - return nil + if !isContainerRunning(mailpitContainer) { + p, err := getPlatform() + if err != nil { + return err + } + fmt.Print("Mailpit is not running, starting... ") + if err := ensureGlobalServiceRunning(p, mailpitService); err != nil { + fmt.Println(cli.Error("failed")) + cli.PrintError("%v", err) + return nil + } + fmt.Println(cli.Success("done")) } url := getMailpitURL() cli.PrintInfo("Opening %s", cli.URL(url)) - - var openCmd *exec.Cmd - switch runtime.GOOS { - case "darwin": - openCmd = exec.Command("open", url) - default: - openCmd = exec.Command("xdg-open", url) - } - - return openCmd.Start() + return openInBrowser(url) } func runMailpitStatus(cmd *cobra.Command, args []string) error { @@ -88,17 +86,14 @@ func runMailpitStatus(cmd *cobra.Command, args []string) error { // Mailpit is always enabled fmt.Println("Enabled: " + cli.Success("yes (always on)")) - // Check if container is running - checkCmd := exec.Command("docker", "ps", "--filter", "name=magebox-mailpit", "--filter", "status=running", "-q") - output, err := checkCmd.Output() - if err == nil && len(strings.TrimSpace(string(output))) > 0 { + if isContainerRunning(mailpitContainer) { fmt.Println("Status: " + cli.Success("running")) fmt.Printf("Web UI: %s\n", cli.Highlight(getMailpitURL())) fmt.Printf("SMTP: %s\n", cli.Highlight("localhost:1025")) } else { fmt.Println("Status: " + cli.Warning("stopped")) fmt.Println() - cli.PrintInfo("Start global services with: magebox global start") + cli.PrintInfo("Start with: magebox mailpit open") } return nil diff --git a/cmd/magebox/phpmyadmin.go b/cmd/magebox/phpmyadmin.go index cd99d09..83755d4 100644 --- a/cmd/magebox/phpmyadmin.go +++ b/cmd/magebox/phpmyadmin.go @@ -3,7 +3,6 @@ package main import ( "fmt" "os/exec" - "runtime" "strings" "github.com/spf13/cobra" @@ -13,12 +12,16 @@ import ( "qoliber/magebox/internal/docker" ) -const phpmyadminDefaultPort = "8036" +const ( + phpmyadminDefaultPort = "8036" + phpmyadminContainer = "magebox-phpmyadmin" + phpmyadminService = "phpmyadmin" +) // getPhpMyAdminURL returns the phpMyAdmin URL, reading the actual port from the running container. // Falls back to the default port if the container is not running. func getPhpMyAdminURL() string { - portCmd := exec.Command("docker", "port", "magebox-phpmyadmin", "80") + portCmd := exec.Command("docker", "port", phpmyadminContainer, "80") output, err := portCmd.Output() if err == nil { // Output format: "0.0.0.0:8036" or "[::]:8036" @@ -65,7 +68,7 @@ var phpmyadminStatusCmd = &cobra.Command{ var phpmyadminOpenCmd = &cobra.Command{ Use: "open", Short: "Open phpMyAdmin in browser", - Long: "Opens the phpMyAdmin web UI in the default browser", + Long: "Opens the phpMyAdmin web UI in the default browser, starting it if needed", RunE: runPhpMyAdminOpen, } @@ -88,48 +91,55 @@ func runPhpMyAdminEnable(cmd *cobra.Command, args []string) error { return fmt.Errorf("failed to load global config: %w", err) } - if globalCfg.PhpMyAdmin { - cli.PrintInfo("phpMyAdmin is already enabled") + switch decideServiceUI(globalCfg.PhpMyAdmin, isContainerRunning(phpmyadminContainer)) { + case decisionProceed: + cli.PrintInfo("phpMyAdmin is already enabled and running") fmt.Println() fmt.Printf(" Web UI: %s\n", cli.Highlight(getPhpMyAdminURL())) return nil - } - cli.PrintTitle("Enabling phpMyAdmin") - fmt.Println() + case decisionStart: + cli.PrintTitle("Starting phpMyAdmin") + fmt.Println() + fmt.Print("phpMyAdmin is enabled but stopped, starting... ") + if err := ensureGlobalServiceRunning(p, phpmyadminService); err != nil { + fmt.Println(cli.Error("failed")) + cli.PrintError("%v", err) + return nil + } + fmt.Println(cli.Success("done")) + fmt.Println() + cli.PrintSuccess("phpMyAdmin started!") + fmt.Println() + fmt.Printf(" Web UI: %s\n", cli.Highlight(getPhpMyAdminURL())) + return nil - globalCfg.PhpMyAdmin = true - if err := config.SaveGlobalConfig(p.HomeDir, globalCfg); err != nil { - return fmt.Errorf("failed to save config: %w", err) - } + default: // decisionNotEnabled + cli.PrintTitle("Enabling phpMyAdmin") + fmt.Println() - // Regenerate docker-compose and start - fmt.Print("Starting phpMyAdmin container... ") - composeGen := docker.NewComposeGenerator(p) + globalCfg.PhpMyAdmin = true + if err := config.SaveGlobalConfig(p.HomeDir, globalCfg); err != nil { + return fmt.Errorf("failed to save config: %w", err) + } - // Discover all project configs to regenerate docker-compose - configs := discoverAllConfigs(p) - if err := composeGen.GenerateGlobalServices(configs); err != nil { - fmt.Println(cli.Error("failed")) - return fmt.Errorf("failed to generate docker-compose: %w", err) - } + fmt.Print("Starting phpMyAdmin container... ") + if err := ensureGlobalServiceRunning(p, phpmyadminService); err != nil { + fmt.Println(cli.Error("failed")) + cli.PrintError("%v", err) + return nil + } + fmt.Println(cli.Success("done")) - dockerCtrl := docker.NewDockerController(composeGen.ComposeFilePath()) - if err := dockerCtrl.StartService("phpmyadmin"); err != nil { - fmt.Println(cli.Error("failed")) - return fmt.Errorf("failed to start phpMyAdmin: %w", err) + fmt.Println() + cli.PrintSuccess("phpMyAdmin enabled!") + fmt.Println() + fmt.Printf(" Web UI: %s\n", cli.Highlight(getPhpMyAdminURL())) + fmt.Println() + cli.PrintInfo("Use arbitrary server mode to connect to any MySQL/MariaDB instance") + cli.PrintInfo("Database containers are accessible by their container name (e.g. magebox-mysql-8.0)") + return nil } - fmt.Println(cli.Success("done")) - - fmt.Println() - cli.PrintSuccess("phpMyAdmin enabled!") - fmt.Println() - fmt.Printf(" Web UI: %s\n", cli.Highlight(getPhpMyAdminURL())) - fmt.Println() - cli.PrintInfo("Use arbitrary server mode to connect to any MySQL/MariaDB instance") - cli.PrintInfo("Database containers are accessible by their container name (e.g. magebox-mysql-8.0)") - - return nil } func runPhpMyAdminDisable(cmd *cobra.Command, args []string) error { @@ -155,7 +165,7 @@ func runPhpMyAdminDisable(cmd *cobra.Command, args []string) error { fmt.Print("Stopping phpMyAdmin container... ") composeGen := docker.NewComposeGenerator(p) dockerCtrl := docker.NewDockerController(composeGen.ComposeFilePath()) - if err := dockerCtrl.StopService("phpmyadmin"); err != nil { + if err := dockerCtrl.StopService(phpmyadminService); err != nil { fmt.Println(cli.Warning("not running")) } else { fmt.Println(cli.Success("done")) @@ -167,8 +177,7 @@ func runPhpMyAdminDisable(cmd *cobra.Command, args []string) error { } // Regenerate docker-compose without phpMyAdmin - configs := discoverAllConfigs(p) - if err := composeGen.GenerateGlobalServices(configs); err != nil { + if err := composeGen.GenerateGlobalServices(discoverAllConfigs(p)); err != nil { return fmt.Errorf("failed to update docker-compose: %w", err) } @@ -201,42 +210,47 @@ func runPhpMyAdminStatus(cmd *cobra.Command, args []string) error { fmt.Println("Enabled: " + cli.Success("yes")) - // Check if container is running - checkCmd := exec.Command("docker", "ps", "--filter", "name=magebox-phpmyadmin", "--filter", "status=running", "-q") - output, err := checkCmd.Output() - if err == nil && len(strings.TrimSpace(string(output))) > 0 { + if isContainerRunning(phpmyadminContainer) { fmt.Println("Status: " + cli.Success("running")) fmt.Printf("Web UI: %s\n", cli.Highlight(getPhpMyAdminURL())) } else { fmt.Println("Status: " + cli.Warning("stopped")) fmt.Println() - cli.PrintInfo("Start global services with: magebox global start") + cli.PrintInfo("Start with: magebox phpmyadmin open") } return nil } func runPhpMyAdminOpen(cmd *cobra.Command, args []string) error { - // Check if container is running - checkCmd := exec.Command("docker", "ps", "--filter", "name=magebox-phpmyadmin", "--filter", "status=running", "-q") - output, err := checkCmd.Output() - if err != nil || len(strings.TrimSpace(string(output))) == 0 { - cli.PrintError("phpMyAdmin is not running") + p, err := getPlatform() + if err != nil { + return err + } + + globalCfg, err := config.LoadGlobalConfig(p.HomeDir) + if err != nil { + return fmt.Errorf("failed to load global config: %w", err) + } + + switch decideServiceUI(globalCfg.PhpMyAdmin, isContainerRunning(phpmyadminContainer)) { + case decisionNotEnabled: + cli.PrintError("phpMyAdmin is not enabled") fmt.Println() cli.PrintInfo("Enable with: magebox phpmyadmin enable") return nil + + case decisionStart: + fmt.Print("phpMyAdmin is not running, starting... ") + if err := ensureGlobalServiceRunning(p, phpmyadminService); err != nil { + fmt.Println(cli.Error("failed")) + cli.PrintError("%v", err) + return nil + } + fmt.Println(cli.Success("done")) } url := getPhpMyAdminURL() cli.PrintInfo("Opening %s", cli.URL(url)) - - var openCmd *exec.Cmd - switch runtime.GOOS { - case "darwin": - openCmd = exec.Command("open", url) - default: - openCmd = exec.Command("xdg-open", url) - } - - return openCmd.Start() + return openInBrowser(url) } diff --git a/cmd/magebox/service_ui.go b/cmd/magebox/service_ui.go new file mode 100644 index 0000000..0dfefe6 --- /dev/null +++ b/cmd/magebox/service_ui.go @@ -0,0 +1,85 @@ +package main + +import ( + "fmt" + "os/exec" + "runtime" + "strings" + + "qoliber/magebox/internal/docker" + "qoliber/magebox/internal/platform" +) + +// serviceUIDecision describes the action an enable/open command should take, +// based on whether the web-UI service is enabled and whether its container runs. +type serviceUIDecision int + +const ( + // decisionNotEnabled: the service is not enabled in the global config. + decisionNotEnabled serviceUIDecision = iota + // decisionStart: the service is enabled but its container is not running. + decisionStart + // decisionProceed: the service is enabled and already running. + decisionProceed +) + +// decideServiceUI maps (enabled, running) to the action a web-UI command takes. +// Always-on services (e.g. Mailpit) pass enabled=true. +func decideServiceUI(enabled, running bool) serviceUIDecision { + switch { + case !enabled: + return decisionNotEnabled + case running: + return decisionProceed + default: + return decisionStart + } +} + +// isContainerRunning reports whether a Docker container with the given name runs. +func isContainerRunning(name string) bool { + cmd := exec.Command("docker", "ps", "--filter", "name="+name, "--filter", "status=running", "-q") + output, err := cmd.Output() + return err == nil && len(strings.TrimSpace(string(output))) > 0 +} + +// dockerRunning reports whether the Docker daemon is reachable. +func dockerRunning() bool { + return exec.Command("docker", "info").Run() == nil +} + +// openInBrowser opens the given URL in the default browser. +func openInBrowser(url string) error { + var openCmd *exec.Cmd + switch runtime.GOOS { + case "darwin": + openCmd = exec.Command("open", url) + default: + openCmd = exec.Command("xdg-open", url) + } + return openCmd.Start() +} + +// ensureGlobalServiceRunning starts a single global web-UI service container. +// +// It regenerates the global compose file so the service is defined with the +// current configuration (also covering a missing compose file), then starts only +// that service. The web-UI services have no depends_on, so no databases are +// dragged in and no full stack is brought up. The Docker daemon is never started +// automatically: if it is not running, a clear, actionable error is returned. +func ensureGlobalServiceRunning(p *platform.Platform, service string) error { + if !dockerRunning() { + return fmt.Errorf("Docker is not running. Please start Docker first") + } + + composeGen := docker.NewComposeGenerator(p) + if err := composeGen.GenerateGlobalServices(discoverAllConfigs(p)); err != nil { + return fmt.Errorf("failed to generate docker-compose: %w", err) + } + + dockerCtrl := docker.NewDockerController(composeGen.ComposeFilePath()) + if err := dockerCtrl.StartService(service); err != nil { + return fmt.Errorf("failed to start %s: %w", service, err) + } + return nil +} diff --git a/cmd/magebox/service_ui_test.go b/cmd/magebox/service_ui_test.go new file mode 100644 index 0000000..ffe4025 --- /dev/null +++ b/cmd/magebox/service_ui_test.go @@ -0,0 +1,25 @@ +package main + +import "testing" + +func TestDecideServiceUI(t *testing.T) { + tests := []struct { + name string + enabled bool + running bool + want serviceUIDecision + }{ + {"disabled and stopped", false, false, decisionNotEnabled}, + {"disabled but somehow running", false, true, decisionNotEnabled}, + {"enabled but stopped", true, false, decisionStart}, + {"enabled and running", true, true, decisionProceed}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := decideServiceUI(tt.enabled, tt.running); got != tt.want { + t.Errorf("decideServiceUI(%v, %v) = %v, want %v", tt.enabled, tt.running, got, tt.want) + } + }) + } +} diff --git a/vitepress/reference/commands.md b/vitepress/reference/commands.md index e2396e4..7c8b8f7 100644 --- a/vitepress/reference/commands.md +++ b/vitepress/reference/commands.md @@ -1043,6 +1043,8 @@ magebox elasticvue enable Starts the Elasticvue container on port 8090. Access at http://localhost:8090. +This command is idempotent and self-healing: if Elasticvue is already enabled but its container is stopped (for example after a Docker or machine restart), it starts the container again instead of doing nothing. + --- ### `magebox elasticvue disable` @@ -1065,7 +1067,19 @@ Show Elasticvue status. magebox elasticvue status ``` -Shows whether Elasticvue is enabled, running, and the web UI URL. +Shows whether Elasticvue is enabled, running, and the web UI URL. When enabled but stopped, it points you to `magebox elasticvue open`, which starts it for you. + +--- + +### `magebox elasticvue open` + +Open Elasticvue in the default browser. + +```bash +magebox elasticvue open +``` + +Requires Elasticvue to be enabled (otherwise it tells you to run `magebox elasticvue enable`). If it is enabled but the container is stopped, it starts the container first and then opens the browser. Returns a clear message if Docker itself is not running. ## Mailpit Commands @@ -1077,7 +1091,7 @@ Open the Mailpit email testing UI in the default browser. magebox mailpit open ``` -Reads the actual port from the running container via `docker port`. Falls back to port 8025 if the container is not running. +Reads the actual port from the running container via `docker port`. Falls back to port 8025 if the container is not running. If the Mailpit container is stopped, it is started automatically before the browser opens (Mailpit is always enabled). Returns a clear message if Docker itself is not running. --- @@ -1107,6 +1121,8 @@ magebox phpmyadmin enable Sets `phpmyadmin: true` in the global config, starts the container, and prints the URL (default port 8036). Uses arbitrary server mode — connect to any database container by name (e.g. `magebox-mysql-8.0`). +This command is idempotent and self-healing: if phpMyAdmin is already enabled but its container is stopped (for example after a Docker or machine restart), it starts the container again instead of doing nothing. + --- ### `magebox phpmyadmin disable` @@ -1129,6 +1145,8 @@ Show phpMyAdmin status. magebox phpmyadmin status ``` +Shows whether phpMyAdmin is enabled, running, and the web UI URL. When enabled but stopped, it points you to `magebox phpmyadmin open`, which starts it for you. + --- ### `magebox phpmyadmin open` @@ -1139,7 +1157,7 @@ Open phpMyAdmin in the default browser. magebox phpmyadmin open ``` -Reads the actual port from the running container. Errors if phpMyAdmin is not running. +Requires phpMyAdmin to be enabled (otherwise it tells you to run `magebox phpmyadmin enable`). If it is enabled but the container is stopped, it starts the container first and then opens the browser. Returns a clear message if Docker itself is not running. ## Expose Commands