package client import ( "context" _ "embed" "fmt" "io" "net/http" "os" "os/exec" "path/filepath" "runtime" "strconv" "strings" ) const ( serviceLabel = "com.kotikot.lodge" systemdUnit = "lodge.service" hookName = "post-checkout" ) //go:embed skill.md var skill []byte var skillDirs = []string{".claude/skills/lodge", ".agents/skills/lodge"} func launchdPlist(binary, logFile string) string { return fmt.Sprintf(` Label%s ProgramArguments%sdaemon RunAtLoad KeepAlive ProcessTypeBackground StandardErrorPath%s `, serviceLabel, binary, logFile) } func systemdService(binary string) string { return fmt.Sprintf(`[Unit] Description=lodge sync daemon [Service] ExecStart=%s daemon Restart=always RestartSec=5 [Install] WantedBy=default.target `, binary) } func command(name string, args ...string) error { out, err := exec.Command(name, args...).CombinedOutput() if err != nil { return fmt.Errorf("%s %s: %w: %s", name, strings.Join(args, " "), err, strings.TrimSpace(string(out))) } return nil } func launchdTarget() string { return "gui/" + strconv.Itoa(os.Getuid()) } func plistPath(home string) string { return filepath.Join(home, "Library", "LaunchAgents", serviceLabel+".plist") } func unitPath(home string) string { return filepath.Join(home, ".config", "systemd", "user", systemdUnit) } func (c *Client) templateDir() string { return filepath.Join(c.DataDir, "git-template") } func (c *Client) Install() error { binary, err := os.Executable() if err != nil { return err } home, err := os.UserHomeDir() if err != nil { return err } if err := c.installHook(binary); err != nil { return err } for _, dir := range skillDirs { if err := writeFile(filepath.Join(home, dir, "SKILL.md"), skill, 0o644); err != nil { return err } } return startService(home, binary, filepath.Join(c.DataDir, "daemon.log")) } func (c *Client) installHook(binary string) error { template, _ := exec.Command("git", "config", "--global", "--get", "init.templateDir").Output() dir := strings.TrimSpace(string(template)) if dir == "" { dir = c.templateDir() if err := command("git", "config", "--global", "init.templateDir", dir); err != nil { return err } } if home, err := os.UserHomeDir(); err == nil && strings.HasPrefix(dir, "~/") { dir = filepath.Join(home, dir[2:]) } hook := filepath.Join(dir, "hooks", hookName) if existing, err := os.ReadFile(hook); err == nil && !strings.Contains(string(existing), "lodge hook") { fmt.Fprintf(os.Stderr, "lodge: %s already exists, add this line to it yourself: %q hook\n", hook, binary) return nil } script := fmt.Sprintf("#!/bin/sh\n[ -x %q ] && %q hook\nexit 0\n", binary, binary) return writeFile(hook, []byte(script), 0o755) } func startService(home, binary, logFile string) error { if runtime.GOOS == "darwin" { plist := plistPath(home) if err := writeFile(plist, []byte(launchdPlist(binary, logFile)), 0o644); err != nil { return err } _ = command("launchctl", "bootout", launchdTarget()+"/"+serviceLabel) return command("launchctl", "bootstrap", launchdTarget(), plist) } if err := writeFile(unitPath(home), []byte(systemdService(binary)), 0o644); err != nil { return err } _ = command("loginctl", "enable-linger") if err := command("systemctl", "--user", "daemon-reload"); err != nil { return err } return command("systemctl", "--user", "enable", "--now", systemdUnit) } func restartService() error { if runtime.GOOS == "darwin" { return command("launchctl", "kickstart", "-k", launchdTarget()+"/"+serviceLabel) } return command("systemctl", "--user", "restart", systemdUnit) } func (c *Client) Uninstall() error { home, err := os.UserHomeDir() if err != nil { return err } if runtime.GOOS == "darwin" { _ = command("launchctl", "bootout", launchdTarget()+"/"+serviceLabel) _ = os.Remove(plistPath(home)) } else { _ = command("systemctl", "--user", "disable", "--now", systemdUnit) _ = os.Remove(unitPath(home)) } template, _ := exec.Command("git", "config", "--global", "--get", "init.templateDir").Output() if strings.TrimSpace(string(template)) == c.templateDir() { _ = command("git", "config", "--global", "--unset", "init.templateDir") } for _, dir := range skillDirs { _ = os.RemoveAll(filepath.Join(home, dir)) } return os.RemoveAll(c.templateDir()) } func (c *Client) Update(ctx context.Context) (bool, error) { binary, err := os.Executable() if err != nil { return false, err } current, err := os.ReadFile(binary) if err != nil { return false, err } res, err := c.request(ctx, http.MethodGet, fmt.Sprintf("/dl/lodge-%s-%s", runtime.GOOS, runtime.GOARCH), nil) if err != nil { return false, err } defer res.Body.Close() latest, err := io.ReadAll(res.Body) if err != nil { return false, err } if hashOf(latest) == hashOf(current) { return false, nil } if err := writeFile(binary, latest, 0o755); err != nil { return false, err } return true, restartService() }