Files

197 lines
5.2 KiB
Go

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(`<?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>%s</string>
<key>ProgramArguments</key><array><string>%s</string><string>daemon</string></array>
<key>RunAtLoad</key><true/>
<key>KeepAlive</key><true/>
<key>ProcessType</key><string>Background</string>
<key>StandardErrorPath</key><string>%s</string>
</dict>
</plist>
`, 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()
}