Files
wehub-resource-sync a06f331eb8
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
install-script / powershell-syntax (push) Has been cancelled
install-script / install (macos-14) (push) Has been cancelled
install-script / install (ubuntu-latest) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:33:42 +08:00

470 lines
16 KiB
Go

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 = `<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>{{.Label}}</string>
<key>ProgramArguments</key>
<array>
<string>{{.Exe}}</string>
<string>daemon</string>
<string>start</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<dict>
<key>SuccessfulExit</key>
<false/>
</dict>
<key>StandardOutPath</key>
<string>{{.LogPath}}</string>
<key>StandardErrorPath</key>
<string>{{.LogPath}}</string>
<key>EnvironmentVariables</key>
<dict>
<key>PATH</key>
<string>/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin</string>
{{- range .EnvVars}}
<key>{{.Key}}</key>
<string>{{.Value}}</string>
{{- end}}
</dict>
</dict>
</plist>
`
// 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 <label>` returns 0 and a plist-ish blob when loaded,
// non-zero otherwise.
out, err := exec.Command("launchctl", "list", daemonServiceName).CombinedOutput()
if err != nil {
fmt.Fprintln(w, "status: not loaded — try `launchctl load ~/Library/LaunchAgents/"+daemonServiceName+".plist`")
return nil
}
fmt.Fprintln(w, "status: loaded")
// Extract PID and LastExitStatus lines for a concise summary.
for _, line := range strings.Split(string(out), "\n") {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "\"PID\"") || strings.HasPrefix(line, "\"LastExitStatus\"") {
fmt.Fprintln(w, " "+line)
}
}
return nil
}
// --- systemd --user (Linux) ----------------------------------------------
// systemdUnitTemplate renders a user-level systemd service. Type=simple
// because `gortex daemon start` (without --detach) runs in the
// foreground; Restart=on-failure covers the crash-restart case without
// pounding on successful exits. Environment= lines carry any XDG_*
// overrides that were in effect at install time so the supervised daemon
// resolves the same paths as the installing shell — see xdgServiceEnv.
const systemdUnitTemplate = `[Unit]
Description=Gortex code intelligence daemon
Documentation=https://github.com/zzet/gortex
After=network.target
[Service]
Type=simple
ExecStart={{.Exe}} daemon start
{{- range .EnvVars}}
Environment={{.Key}}={{.Value}}
{{- end}}
Restart=on-failure
RestartSec=2
StandardOutput=append:{{.LogPath}}
StandardError=append:{{.LogPath}}
[Install]
WantedBy=default.target
`
// renderSystemdUnit fills systemdUnitTemplate, quoting Environment=
// values that need it.
func renderSystemdUnit(exe, logPath string, env []serviceEnvVar) (string, error) {
data := struct {
Exe, LogPath string
EnvVars []serviceEnvVar
}{Exe: exe, LogPath: logPath, EnvVars: make([]serviceEnvVar, len(env))}
for i, e := range env {
data.EnvVars[i] = serviceEnvVar{Key: e.Key, Value: systemdEnvValue(e.Value)}
}
var buf bytes.Buffer
if err := template.Must(template.New("unit").Parse(systemdUnitTemplate)).Execute(&buf, data); err != nil {
return "", err
}
return buf.String(), nil
}
func systemdUnitPath() (string, error) {
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
return filepath.Join(home, ".config", "systemd", "user", daemonServiceName+".service"), nil
}
func installSystemd(w io.Writer, exe string) error {
path, err := systemdUnitPath()
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return fmt.Errorf("ensure systemd user dir: %w", err)
}
unit, err := renderSystemdUnit(exe, daemon.LogFilePath(), xdgServiceEnv())
if err != nil {
return fmt.Errorf("render unit: %w", err)
}
if err := os.WriteFile(path, []byte(unit), 0o644); err != nil {
return fmt.Errorf("write unit: %w", err)
}
if err := runCmd(w, "systemctl", "--user", "daemon-reload"); err != nil {
return fmt.Errorf("systemctl daemon-reload: %w", err)
}
// `enable --now` = enable for autostart + start immediately. Bundled
// so the user gets a running service in one command.
if err := runCmd(w, "systemctl", "--user", "enable", "--now", daemonServiceName); err != nil {
return fmt.Errorf("systemctl enable --now: %w", err)
}
fmt.Fprintf(w, "[gortex daemon] service installed at %s\n", path)
fmt.Fprintf(w, " logs: %s (or `journalctl --user -u %s -f`)\n", daemon.LogFilePath(), daemonServiceName)
fmt.Fprintf(w, " check: gortex daemon service-status\n")
return nil
}
func uninstallSystemd(w io.Writer) error {
// `disable --now` = stop + disable autostart. Swallowed so uninstall
// is safe to run on a never-installed system.
_ = runCmd(w, "systemctl", "--user", "disable", "--now", daemonServiceName)
path, err := systemdUnitPath()
if err != nil {
return err
}
if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("remove unit: %w", err)
}
_ = runCmd(w, "systemctl", "--user", "daemon-reload")
fmt.Fprintln(w, "[gortex daemon] service uninstalled")
return nil
}
func statusSystemd(w io.Writer) error {
path, err := systemdUnitPath()
if err != nil {
return err
}
if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) {
fmt.Fprintf(w, "systemd: not installed (expected %s)\n", path)
return nil
}
fmt.Fprintf(w, "systemd: installed at %s\n", path)
out, err := exec.Command("systemctl", "--user", "is-active", daemonServiceName).CombinedOutput()
status := strings.TrimSpace(string(out))
fmt.Fprintf(w, "status: %s", status)
if err != nil {
// is-active exits 3 for "inactive" — not really a CLI error.
fmt.Fprintln(w)
return nil
}
fmt.Fprintln(w)
return nil
}
// runCmd is a helper that runs an external command with its stdout /
// stderr piped through the caller's writer so users see launchctl /
// systemctl output in context with our own.
func runCmd(w io.Writer, name string, args ...string) error {
c := exec.Command(name, args...)
c.Stdout = w
c.Stderr = w
return c.Run()
}