diff --git a/internal/cmd/root.go b/internal/cmd/root.go index 6ed33fa57e76d6610b5535cb3e962bf60003e2b0..af25869888ba51e9f7c02cd254ac849971671863 100644 --- a/internal/cmd/root.go +++ b/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 diff --git a/internal/cmd/root_other.go b/internal/cmd/root_other.go new file mode 100644 index 0000000000000000000000000000000000000000..8106c4bbcce3b8f1b3303768d8db57b930013fb0 --- /dev/null +++ b/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 +} diff --git a/internal/cmd/root_windows.go b/internal/cmd/root_windows.go new file mode 100644 index 0000000000000000000000000000000000000000..e87d9d5615060fc74b35428671f8fc851f91bde6 --- /dev/null +++ b/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, + } +} diff --git a/internal/config/load.go b/internal/config/load.go index f94ed06f862ee56d391869ce3bd47c8566dbecd2..dc806e5372bcd28437edd2b86d21390a4e67ff14 100644 --- a/internal/config/load.go +++ b/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) +}