204 lines
5.5 KiB
Go
204 lines
5.5 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 (cfg *Config) templateDir() string {
|
|
return filepath.Join(cfg.DataDir, "git-template")
|
|
}
|
|
|
|
func (cfg *Config) Install() error {
|
|
binary, err := os.Executable()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
home, err := os.UserHomeDir()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := cfg.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
|
|
}
|
|
}
|
|
if err := startService(home, binary, filepath.Join(cfg.DataDir, "daemon.log")); err != nil {
|
|
fmt.Fprintf(os.Stderr, "lodge: the daemon could not be started as a service: %v\nrun `lodge daemon &` to keep this machine in sync\n", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (cfg *Config) installHook(binary string) error {
|
|
template, _ := exec.Command("git", "config", "--global", "--get", "init.templateDir").Output()
|
|
dir := strings.TrimSpace(string(template))
|
|
if dir == "" {
|
|
dir = cfg.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 (cfg *Config) 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)) == cfg.templateDir() {
|
|
_ = command("git", "config", "--global", "--unset", "init.templateDir")
|
|
}
|
|
for _, dir := range skillDirs {
|
|
_ = os.RemoveAll(filepath.Join(home, dir))
|
|
}
|
|
return os.RemoveAll(cfg.templateDir())
|
|
}
|
|
|
|
func (cfg *Config) Update(ctx context.Context) (bool, error) {
|
|
c, err := cfg.client(cfg.URL)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
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()
|
|
}
|