feat(server): auto-start server if not running

Ayman Bagabas created

This commit adds functionality to the Crush CLI to automatically start
the Crush server as a detached process if it is not already running

TODO:
- [x] Implement server auto-start logic
- [ ] Ensure cross-platform compatibility (Windows, Linux, macOS)
  - [x] Test on macOS
  - [ ] Test on Linux
  - [ ] Test on Windows
- [ ] Implement server readiness checks
- [ ] Implement server version endpoint and compatibility checks
- [ ] Implement server control commands (stop, restart, status)

Change summary

internal/cmd/root.go         | 82 ++++++++++++++++++++++++++++++++++++++
internal/cmd/root_other.go   | 16 +++++++
internal/cmd/root_windows.go | 22 ++++++++++
internal/config/load.go      | 21 +++++++++
4 files changed, 141 insertions(+)

Detailed changes

internal/cmd/root.go 🔗

@@ -2,14 +2,20 @@ package cmd
 
 import (
 	"context"
+	"errors"
 	"fmt"
 	"io"
+	"io/fs"
 	"log/slog"
 	"os"
+	"os/exec"
 	"path/filepath"
+	"regexp"
+	"time"
 
 	tea "github.com/charmbracelet/bubbletea/v2"
 	"github.com/charmbracelet/crush/internal/client"
+	"github.com/charmbracelet/crush/internal/config"
 	"github.com/charmbracelet/crush/internal/log"
 	"github.com/charmbracelet/crush/internal/proto"
 	"github.com/charmbracelet/crush/internal/server"
@@ -65,6 +71,10 @@ crush run "Explain the use of context in Go"
 crush -y
   `,
 	RunE: func(cmd *cobra.Command, args []string) error {
+		if err := ensureServerRunning(cmd); err != nil {
+			return err
+		}
+
 		c, err := setupApp(cmd)
 		if err != nil {
 			return err
@@ -166,6 +176,78 @@ func setupApp(cmd *cobra.Command) (*client.Client, error) {
 	return c, nil
 }
 
+var safeNameRegexp = regexp.MustCompile(`[^a-zA-Z0-9._-]`)
+
+func ensureServerRunning(cmd *cobra.Command) error {
+	stat, err := os.Stat(clientHost)
+	if err == nil && stat.Mode()&os.ModeSocket == 0 {
+		return fmt.Errorf("crush server socket path exists but is not a socket: %s", clientHost)
+	} else if err == nil && stat.Mode()&os.ModeSocket != 0 {
+		// Socket exists, assume server is running.
+		return nil
+	} else if err != nil && !errors.Is(err, fs.ErrNotExist) {
+		return fmt.Errorf("failed to stat crush server socket: %v", err)
+	}
+
+	// Start the server as a detached process if the socket does not exist.
+	exe, err := os.Executable()
+	if err != nil {
+		return fmt.Errorf("failed to get executable path: %v", err)
+	}
+
+	safeClientHost := safeNameRegexp.ReplaceAllString(clientHost, "_")
+	chDir := filepath.Join(config.GlobalCacheDir(), "server-"+safeClientHost)
+	if err := os.MkdirAll(chDir, 0o700); err != nil {
+		return fmt.Errorf("failed to create server working directory: %v", err)
+	}
+
+	c := exec.CommandContext(cmd.Context(), exe, "server")
+	stdoutPath := filepath.Join(chDir, "stdout.log")
+	stderrPath := filepath.Join(chDir, "stderr.log")
+	detachProcess(c, stdoutPath, stderrPath)
+
+	stdout, err := os.Create(stdoutPath)
+	if err != nil {
+		return fmt.Errorf("failed to create stdout log file: %v", err)
+	}
+	defer stdout.Close()
+	c.Stdout = stdout
+
+	stderr, err := os.Create(stderrPath)
+	if err != nil {
+		return fmt.Errorf("failed to create stderr log file: %v", err)
+	}
+	defer stderr.Close()
+	c.Stderr = stderr
+
+	if err := c.Start(); err != nil {
+		return fmt.Errorf("failed to start crush server: %v", err)
+	}
+
+	if err := c.Process.Release(); err != nil {
+		return fmt.Errorf("failed to detach crush server process: %v", err)
+	}
+
+	// Wait for the server to start and create the socket.
+	for range 10 {
+		stat, err := os.Stat(clientHost)
+		if err == nil && stat.Mode()&os.ModeSocket != 0 {
+			// Socket exists, server is running.
+			return nil
+		} else if err != nil && !errors.Is(err, fs.ErrNotExist) {
+			return fmt.Errorf("failed to stat crush server socket: %v", err)
+		}
+		// Sleep for 100ms before checking again.
+		select {
+		case <-cmd.Context().Done():
+			return fmt.Errorf("context cancelled while waiting for crush server to start")
+		case <-time.After(100 * time.Millisecond):
+		}
+	}
+
+	return nil
+}
+
 func MaybePrependStdin(prompt string) (string, error) {
 	if term.IsTerminal(os.Stdin.Fd()) {
 		return prompt, nil

internal/cmd/root_other.go 🔗

@@ -0,0 +1,16 @@
+//go:build !windows
+// +build !windows
+
+package cmd
+
+import (
+	"os/exec"
+	"syscall"
+)
+
+func detachProcess(c *exec.Cmd, _, _ string) {
+	if c.SysProcAttr == nil {
+		c.SysProcAttr = &syscall.SysProcAttr{}
+	}
+	c.SysProcAttr.Setsid = true
+}

internal/cmd/root_windows.go 🔗

@@ -0,0 +1,22 @@
+//go:build windows
+// +build windows
+
+package cmd
+
+import (
+	"os/exec"
+)
+
+func detachProcess(c *exec.Cmd, stdoutPath, stderrPath string) {
+	argv1 := c.Args[0]
+	c.Path = "cmd"
+	c.Args = []string{
+		"cmd",
+		"/c",
+		argv1,
+		">",
+		stdoutPath,
+		"2>",
+		stderrPath,
+	}
+}

internal/config/load.go 🔗

@@ -639,3 +639,24 @@ func GlobalConfigData() string {
 
 	return filepath.Join(home.Dir(), ".local", "share", appName, fmt.Sprintf("%s.json", appName))
 }
+
+// GlobalCacheDir returns the path to the main cache directory for the application.
+func GlobalCacheDir() string {
+	xdgCacheHome := os.Getenv("XDG_CACHE_HOME")
+	if xdgCacheHome != "" {
+		return filepath.Join(xdgCacheHome, appName)
+	}
+
+	// return the path to the main cache directory
+	// for windows, it should be in `%LOCALAPPDATA%/crush/Cache`
+	// for linux and macOS, it should be in `$HOME/.cache/crush/`
+	if runtime.GOOS == "windows" {
+		localAppData := os.Getenv("LOCALAPPDATA")
+		if localAppData == "" {
+			localAppData = filepath.Join(os.Getenv("USERPROFILE"), "AppData", "Local")
+		}
+		return filepath.Join(localAppData, appName, "Cache")
+	}
+
+	return filepath.Join(home.Dir(), ".cache", appName)
+}