package main import ( "bytes" "encoding/xml" "errors" "fmt" "io" "os" "os/exec" "path/filepath" "runtime" "strings" "text/template" "github.com/spf13/cobra" "github.com/zzet/gortex/internal/daemon" ) // Service management — launchd on macOS, systemd --user on Linux. // // The service wraps `gortex daemon start` (foreground, without --detach) // so the OS supervisor owns lifecycle. Users who prefer to manage the // daemon manually keep using `gortex daemon start --detach`; the // service integration is strictly opt-in via `install-service`. // // No root privileges. On both platforms the unit lives under the // user's home so it starts with the user session and terminates on // logout. System-wide installation (requires sudo) is a follow-up for // shared-workstation deployments; not wired here. var ( daemonServiceName = "com.zzet.gortex" // launchd label + systemd unit base name ) var daemonInstallServiceCmd = &cobra.Command{ Use: "install-service", Short: "Install a user-level launchd/systemd unit that keeps the daemon running", Long: `Writes a user-level service unit for the host OS (launchd on macOS, systemd --user on Linux) that starts the daemon at login and restarts it on crash. The service wraps 'gortex daemon start' in foreground mode so the OS supervisor owns lifecycle; no --detach involved. No root/sudo required — unit lives under your home directory.`, RunE: runDaemonInstallService, } var daemonUninstallServiceCmd = &cobra.Command{ Use: "uninstall-service", Short: "Remove the launchd/systemd unit and stop the daemon", RunE: runDaemonUninstallService, } var daemonServiceStatusCmd = &cobra.Command{ Use: "service-status", Short: "Show whether the launchd/systemd unit is installed and active", RunE: runDaemonServiceStatus, } func init() { daemonCmd.AddCommand(daemonInstallServiceCmd) daemonCmd.AddCommand(daemonUninstallServiceCmd) daemonCmd.AddCommand(daemonServiceStatusCmd) } // serviceEnvVar is a single environment entry rendered into a service // unit. Render helpers escape Value for the target format. type serviceEnvVar struct{ Key, Value string } // xdgServiceEnv captures the XDG base-directory overrides in effect when // the service unit is written, so the supervised daemon resolves the // same config / data / cache locations as the shell that installed it. // // launchd and systemd --user start the daemon with a near-empty // environment — they do NOT inherit the XDG_* variables a user exports // from their shell or session manager. Without this capture the daemon // falls back to ~/.gortex even though the user opted into an XDG layout // (see internal/platform/xdg.go), silently splitting their state across // two trees. Re-run install-service to re-capture changed values. // // Only absolute values are propagated — the rule platform.unifiedDir // itself applies when honouring an override (a relative XDG path is // ignored per the XDG Base Directory spec). XDG_RUNTIME_DIR is // deliberately excluded: the init system sets it per session, so pinning // an install-time value would point the daemon socket at a stale dir. func xdgServiceEnv() []serviceEnvVar { var out []serviceEnvVar for _, name := range []string{"XDG_CONFIG_HOME", "XDG_DATA_HOME", "XDG_CACHE_HOME"} { if v := os.Getenv(name); v != "" && filepath.IsAbs(v) { out = append(out, serviceEnvVar{Key: name, Value: v}) } } return out } // xmlEscape renders s safe for an XML text node (the launchd plist) so a // home path containing an XML metacharacter can't produce a malformed, // unloadable plist. func xmlEscape(s string) string { var b strings.Builder if err := xml.EscapeText(&b, []byte(s)); err != nil { return s } return b.String() } // systemdEnvValue renders a value safe for a systemd Environment= line. // `%` is escaped to `%%` because systemd treats it as a specifier // introducer across the whole unit file (systemd.unit(5)) — an // unescaped `%d` in a path would expand to a directory specifier and // silently change the value the daemon sees. Values containing // whitespace are additionally double-quoted (with embedded quotes / // backslashes escaped) per systemd's quoting rules. Plain paths (the // common case) pass through unchanged. func systemdEnvValue(v string) string { v = strings.ReplaceAll(v, "%", "%%") if !strings.ContainsAny(v, " \t") { return v } r := strings.NewReplacer(`\`, `\\`, `"`, `\"`) return `"` + r.Replace(v) + `"` } // runDaemonInstallService writes the unit file and enables + starts it. // Existing daemon processes are stopped first so the service owns the // only running instance after this returns. func runDaemonInstallService(cmd *cobra.Command, _ []string) error { w := cmd.ErrOrStderr() // Stop any manual daemon that's currently running so the service // doesn't fight with it over the socket. if daemon.IsRunning() { fmt.Fprintln(w, "[gortex daemon] stopping existing daemon before install") // This is an internal lifecycle bounce — the supervisor starts the // daemon right after — not a user "stay down". Suppress the stop-intent // marker (as `daemon restart` does) so a failed install can't leave // autostart permanently disabled with no daemon and no service. daemonRestartActive = true err := runDaemonStop(cmd, nil) daemonRestartActive = false if err != nil { return fmt.Errorf("stop existing daemon: %w", err) } } exe, err := os.Executable() if err != nil { return fmt.Errorf("resolve executable: %w", err) } switch runtime.GOOS { case "darwin": return installLaunchd(w, exe) case "linux": return installSystemd(w, exe) default: return fmt.Errorf("service install is not supported on %s — run 'gortex daemon start --detach' to keep the daemon running in the background", runtime.GOOS) } } func runDaemonUninstallService(cmd *cobra.Command, _ []string) error { w := cmd.ErrOrStderr() switch runtime.GOOS { case "darwin": return uninstallLaunchd(w) case "linux": return uninstallSystemd(w) default: return fmt.Errorf("service uninstall not supported on %s", runtime.GOOS) } } func runDaemonServiceStatus(cmd *cobra.Command, _ []string) error { w := cmd.OutOrStdout() switch runtime.GOOS { case "darwin": return statusLaunchd(w) case "linux": return statusSystemd(w) default: return fmt.Errorf("service status not supported on %s", runtime.GOOS) } } // --- launchd (macOS) ------------------------------------------------------ // launchdPlistTemplate renders the LaunchAgent plist. KeepAlive uses the // SuccessfulExit=false policy so the agent is restarted on a crash but NOT on a // clean exit — the launchd analogue of systemd's Restart=on-failure, so an // explicit `gortex daemon stop` (which exits 0) stays down instead of being // resurrected by KeepAlive. RunAtLoad starts on login. // // StandardOutPath / StandardErrorPath redirect logs into the same file // `gortex daemon logs` tails, so users don't need to remember two paths. // // EnvironmentVariables carries PATH (so a Homebrew-installed binary is // found in launchd's minimal environment) plus any XDG_* overrides that // were in effect at install time — see xdgServiceEnv for why that // capture is necessary. const launchdPlistTemplate = ` Label {{.Label}} ProgramArguments {{.Exe}} daemon start RunAtLoad KeepAlive SuccessfulExit StandardOutPath {{.LogPath}} StandardErrorPath {{.LogPath}} EnvironmentVariables PATH /usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin {{- range .EnvVars}} {{.Key}} {{.Value}} {{- end}} ` // renderLaunchdPlist fills launchdPlistTemplate. String values are // XML-escaped so a path containing an XML metacharacter can't produce a // malformed, unloadable plist. func renderLaunchdPlist(label, exe, logPath string, env []serviceEnvVar) (string, error) { data := struct { Label, Exe, LogPath string EnvVars []serviceEnvVar }{ Label: xmlEscape(label), Exe: xmlEscape(exe), LogPath: xmlEscape(logPath), EnvVars: make([]serviceEnvVar, len(env)), } for i, e := range env { data.EnvVars[i] = serviceEnvVar{Key: e.Key, Value: xmlEscape(e.Value)} } var buf bytes.Buffer if err := template.Must(template.New("plist").Parse(launchdPlistTemplate)).Execute(&buf, data); err != nil { return "", err } return buf.String(), nil } func launchdPlistPath() (string, error) { home, err := os.UserHomeDir() if err != nil { return "", err } return filepath.Join(home, "Library", "LaunchAgents", daemonServiceName+".plist"), nil } func installLaunchd(w io.Writer, exe string) error { path, err := launchdPlistPath() if err != nil { return err } if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { return fmt.Errorf("ensure LaunchAgents dir: %w", err) } plist, err := renderLaunchdPlist(daemonServiceName, exe, daemon.LogFilePath(), xdgServiceEnv()) if err != nil { return fmt.Errorf("render plist: %w", err) } if err := os.WriteFile(path, []byte(plist), 0o644); err != nil { return fmt.Errorf("write plist: %w", err) } // -w persists the load across reboots; without it the service // starts only for the current login session. if err := runCmd(w, "launchctl", "load", "-w", path); err != nil { return fmt.Errorf("launchctl load: %w", err) } fmt.Fprintf(w, "[gortex daemon] service installed at %s\n", path) fmt.Fprintf(w, " logs: %s\n", daemon.LogFilePath()) fmt.Fprintf(w, " check: gortex daemon service-status\n") return nil } func uninstallLaunchd(w io.Writer) error { path, err := launchdPlistPath() if err != nil { return err } // unload is idempotent — emits a warning if the plist isn't loaded, // but exit 0. Swallow its error path so uninstall is safe to retry. _ = runCmd(w, "launchctl", "unload", "-w", path) if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) { return fmt.Errorf("remove plist: %w", err) } fmt.Fprintln(w, "[gortex daemon] service uninstalled") return nil } func statusLaunchd(w io.Writer) error { path, err := launchdPlistPath() if err != nil { return err } if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) { fmt.Fprintf(w, "launchd: not installed (expected %s)\n", path) return nil } fmt.Fprintf(w, "launchd: installed at %s\n", path) // `launchctl list