package main import ( "crypto/sha256" "encoding/hex" "encoding/json" "errors" "fmt" "io" "os" "os/exec" "path/filepath" "strings" "syscall" "time" ) const ( appVersion = "0.6.0" pluginID = "local.omarchy-citizen" ) type Config struct { AutoApply bool `json:"auto_apply"` TrustedRemote string `json:"trusted_remote,omitempty"` LastCheck string `json:"last_check,omitempty"` LastUpdate string `json:"last_update,omitempty"` LastResult string `json:"last_result,omitempty"` } type Status struct { BackendVersion string `json:"backend_version"` PluginID string `json:"plugin_id"` PluginDir string `json:"plugin_dir"` Managed string `json:"managed"` AutoApply bool `json:"auto_apply"` Remote string `json:"remote,omitempty"` TrustedRemote string `json:"trusted_remote,omitempty"` Branch string `json:"branch,omitempty"` LocalCommit string `json:"local_commit,omitempty"` RemoteCommit string `json:"remote_commit,omitempty"` Dirty bool `json:"dirty"` Update string `json:"update"` LastCheck string `json:"last_check,omitempty"` LastUpdate string `json:"last_update,omitempty"` LastResult string `json:"last_result,omitempty"` } type App struct { home string pluginDir string configDir string stateDir string configPath string logPath string libDir string selfPath string } func main() { app, err := newApp() if err != nil { fatal(err) } app.rotateLog() args := os.Args[1:] if len(args) == 0 { args = []string{"status"} } switch args[0] { case "version", "--version", "-v": fmt.Println(appVersion) case "status": st, _ := app.status(false) if hasArg(args[1:], "--json") { printJSON(st) } else { printStatusKV(st) } case "check": st, err := app.status(true) app.recordCheck(st, err) if hasArg(args[1:], "--json") { printJSON(st) } else { printStatusKV(st) } if err != nil { os.Exit(2) } case "update": if err := app.update(false); err != nil { app.logf("manual update failed: %v", err) fatal(err) } case "tick": if err := app.tick(); err != nil { app.logf("tick failed: %v", err) os.Exit(2) } case "auto": if len(args) < 2 { fatal(errors.New("usage: auto enable|disable|status")) } switch args[1] { case "enable": if err := app.enableAuto(); err != nil { fatal(err) } case "disable": if err := app.disableAuto(); err != nil { fatal(err) } case "status": cfg := app.loadConfig() if cfg.AutoApply { fmt.Println("enabled") } else { fmt.Println("disabled") } default: fatal(errors.New("usage: auto enable|disable|status")) } case "install-service": if err := app.installService(); err != nil { fatal(err) } case "uninstall-service": if err := app.uninstallService(); err != nil { fatal(err) } case "self-sync": if err := app.selfSync(); err != nil { fatal(err) } default: fatal(fmt.Errorf("unknown command: %s", args[0])) } } func newApp() (*App, error) { home, err := os.UserHomeDir() if err != nil { return nil, err } self, _ := os.Executable() self, _ = filepath.EvalSymlinks(self) configHome := envOr("XDG_CONFIG_HOME", filepath.Join(home, ".config")) stateHome := envOr("XDG_STATE_HOME", filepath.Join(home, ".local", "state")) return &App{ home: home, pluginDir: filepath.Join(configHome, "omarchy", "plugins", pluginID), configDir: filepath.Join(configHome, "omarchy-citizen"), stateDir: filepath.Join(stateHome, "omarchy-citizen"), configPath: filepath.Join(configHome, "omarchy-citizen", "updater.json"), logPath: filepath.Join(stateHome, "omarchy-citizen", "updater.log"), libDir: filepath.Join(home, ".local", "lib", "omarchy-citizen"), selfPath: self, }, nil } func (a *App) status(fetch bool) (Status, error) { cfg := a.loadConfig() st := Status{ BackendVersion: appVersion, PluginID: pluginID, PluginDir: a.pluginDir, Managed: "manual", AutoApply: cfg.AutoApply, TrustedRemote: cfg.TrustedRemote, Update: "unavailable", LastCheck: cfg.LastCheck, LastUpdate: cfg.LastUpdate, LastResult: cfg.LastResult, } if !isGitRepo(a.pluginDir) { return st, nil } st.Managed = "git" remote, _ := git(a.pluginDir, "remote", "get-url", "origin") st.Remote = strings.TrimSpace(remote) branch, _ := git(a.pluginDir, "branch", "--show-current") st.Branch = strings.TrimSpace(branch) local, _ := git(a.pluginDir, "rev-parse", "HEAD") st.LocalCommit = short(strings.TrimSpace(local)) dirty, _ := git(a.pluginDir, "status", "--porcelain") st.Dirty = strings.TrimSpace(dirty) != "" if fetch { if st.Dirty { st.Update = "blocked-dirty" return st, errors.New("plugin checkout has local changes") } if cfg.TrustedRemote != "" && st.Remote != cfg.TrustedRemote { st.Update = "blocked-remote-changed" return st, errors.New("git origin differs from trusted remote") } if _, err := git(a.pluginDir, "fetch", "--quiet", "origin"); err != nil { st.Update = "check-failed" return st, err } } upstream, err := git(a.pluginDir, "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}") if err != nil { st.Update = "no-upstream" return st, nil } upstream = strings.TrimSpace(upstream) remoteCommit, err := git(a.pluginDir, "rev-parse", upstream) if err != nil { st.Update = "unknown" return st, err } remoteCommit = strings.TrimSpace(remoteCommit) st.RemoteCommit = short(remoteCommit) localFull, _ := git(a.pluginDir, "rev-parse", "HEAD") localFull = strings.TrimSpace(localFull) switch { case st.Dirty: st.Update = "blocked-dirty" case localFull == remoteCommit: st.Update = "current" default: ancestor, err := runExit(a.pluginDir, "git", "merge-base", "--is-ancestor", localFull, remoteCommit) if err == nil && ancestor == 0 { st.Update = "available" } else { st.Update = "diverged" } } return st, nil } func (a *App) tick() error { cfg := a.loadConfig() if !cfg.AutoApply { a.logf("tick: auto updates disabled") return nil } st, err := a.status(true) a.recordCheck(st, err) if err != nil { return err } if st.Update != "available" { a.logf("tick: no update applied; state=%s", st.Update) return nil } return a.update(true) } func (a *App) update(automatic bool) error { unlock, err := a.acquireUpdateLock() if err != nil { return err } defer unlock() st, err := a.status(true) a.recordCheck(st, err) if err != nil { return err } if st.Managed != "git" { return errors.New("automatic updates require a git-managed Omarchy plugin installation") } cfg := a.loadConfig() if cfg.TrustedRemote != "" && st.Remote != cfg.TrustedRemote { return errors.New("refusing update: git origin changed after auto-update trust was granted") } if st.Dirty { return errors.New("refusing update: plugin contains local changes") } if st.Update == "current" { a.logf("update: already current at %s", st.LocalCommit) return nil } if st.Update != "available" { return fmt.Errorf("update cannot be applied in state %q", st.Update) } before := st.LocalCommit a.logf("update begin automatic=%v remote=%q from=%s to=%s", automatic, st.Remote, st.LocalCommit, st.RemoteCommit) cmd := exec.Command("omarchy", "plugin", "update", pluginID, "--yes") cmd.Stdout = io.MultiWriter(os.Stdout, a.logWriter()) cmd.Stderr = io.MultiWriter(os.Stderr, a.logWriter()) if err := cmd.Run(); err != nil { a.updateConfig(func(c *Config) { c.LastResult = "update-failed" }) return fmt.Errorf("omarchy plugin update failed: %w", err) } if err := a.validatePlugin(); err != nil { a.updateConfig(func(c *Config) { c.LastResult = "post-validation-failed" }) return fmt.Errorf("post-update validation failed: %w", err) } after, _ := git(a.pluginDir, "rev-parse", "HEAD") after = short(strings.TrimSpace(after)) a.updateConfig(func(c *Config) { c.LastUpdate = time.Now().Format(time.RFC3339) c.LastResult = "updated:" + before + "->" + after }) a.logf("update success from=%s to=%s", before, after) a.notify("Omarchy Citizen", "Plugin-Update installiert: "+before+" → "+after) if err := a.selfSync(); err != nil { a.logf("backend self-sync warning: %v", err) } _ = run("", "omarchy-shell", "shell", "rescanPlugins") return nil } func (a *App) enableAuto() error { st, err := a.status(true) if err != nil { return err } if st.Managed != "git" || st.Remote == "" { return errors.New("auto updates can only be enabled after installing Omarchy Citizen as a git-managed Omarchy plugin") } if st.Dirty { return errors.New("cannot enable auto updates while the plugin checkout has local changes") } if err := a.validatePlugin(); err != nil { return err } if err := a.installService(); err != nil { return err } a.updateConfig(func(c *Config) { c.AutoApply = true c.TrustedRemote = st.Remote c.LastResult = "auto-enabled" }) if err := run("", "systemctl", "--user", "enable", "--now", "omarchy-citizen-update.timer"); err != nil { return err } a.logf("auto updates enabled trusted_remote=%q", st.Remote) fmt.Println("enabled") return nil } func (a *App) disableAuto() error { a.updateConfig(func(c *Config) { c.AutoApply = false c.LastResult = "auto-disabled" }) _ = run("", "systemctl", "--user", "disable", "--now", "omarchy-citizen-update.timer") a.logf("auto updates disabled") fmt.Println("disabled") return nil } func (a *App) installService() error { if err := os.MkdirAll(filepath.Join(a.home, ".config", "systemd", "user"), 0o755); err != nil { return err } if err := os.MkdirAll(a.libDir, 0o755); err != nil { return err } if err := a.selfSync(); err != nil { return err } servicePath := filepath.Join(a.home, ".config", "systemd", "user", "omarchy-citizen-update.service") timerPath := filepath.Join(a.home, ".config", "systemd", "user", "omarchy-citizen-update.timer") backendPath := filepath.Join(a.libDir, "omarchy-citizen-backend") service := `[Unit]\nDescription=Omarchy Citizen automatic plugin updater\nAfter=network-online.target\nWants=network-online.target\n\n[Service]\nType=oneshot\nExecStart=` + backendPath + ` tick\n` timer := `[Unit]\nDescription=Check Omarchy Citizen updates periodically\n\n[Timer]\nOnBootSec=10min\nOnUnitActiveSec=6h\nRandomizedDelaySec=15min\nPersistent=true\nUnit=omarchy-citizen-update.service\n\n[Install]\nWantedBy=timers.target\n` if err := os.WriteFile(servicePath, []byte(strings.ReplaceAll(service, `\n`, "\n")), 0o644); err != nil { return err } if err := os.WriteFile(timerPath, []byte(strings.ReplaceAll(timer, `\n`, "\n")), 0o644); err != nil { return err } return run("", "systemctl", "--user", "daemon-reload") } func (a *App) uninstallService() error { _ = run("", "systemctl", "--user", "disable", "--now", "omarchy-citizen-update.timer") _ = os.Remove(filepath.Join(a.home, ".config", "systemd", "user", "omarchy-citizen-update.service")) _ = os.Remove(filepath.Join(a.home, ".config", "systemd", "user", "omarchy-citizen-update.timer")) _ = run("", "systemctl", "--user", "daemon-reload") return nil } func (a *App) selfSync() error { bundled := filepath.Join(a.pluginDir, "backend", "bin", "omarchy-citizen-backend") if _, err := os.Stat(bundled); err != nil { // During development or before first git install, copy current executable instead. if a.selfPath == "" { return fmt.Errorf("bundled backend not found: %s", bundled) } bundled = a.selfPath } if err := os.MkdirAll(a.libDir, 0o755); err != nil { return err } target := filepath.Join(a.libDir, "omarchy-citizen-backend") same, _ := sameFileHash(bundled, target) if same { return nil } tmp := target + ".new" if err := copyFile(bundled, tmp, 0o755); err != nil { return err } if err := os.Rename(tmp, target); err != nil { return err } a.logf("backend self-sync installed %s", target) return nil } func (a *App) acquireUpdateLock() (func(), error) { if err := os.MkdirAll(a.stateDir, 0o755); err != nil { return nil, err } path := filepath.Join(a.stateDir, "update.lock") f, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o600) if err != nil { return nil, err } if err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil { f.Close() return nil, errors.New("another Omarchy Citizen update is already running") } return func() { _ = syscall.Flock(int(f.Fd()), syscall.LOCK_UN) _ = f.Close() }, nil } func (a *App) notify(title, body string) { if _, err := exec.LookPath("omarchy-notification-send"); err == nil { _ = exec.Command("omarchy-notification-send", title, body).Run() return } if _, err := exec.LookPath("notify-send"); err == nil { _ = exec.Command("notify-send", title, body).Run() } } func (a *App) validatePlugin() error { cmd := exec.Command("omarchy", "plugin", "validate", a.pluginDir) out, err := cmd.CombinedOutput() if err != nil { return fmt.Errorf("plugin validation failed: %s", strings.TrimSpace(string(out))) } return nil } func (a *App) recordCheck(st Status, err error) { a.updateConfig(func(c *Config) { c.LastCheck = time.Now().Format(time.RFC3339) if err != nil { c.LastResult = "check-failed:" + err.Error() } else { c.LastResult = "check:" + st.Update } }) a.logf("check managed=%s update=%s local=%s remote=%s dirty=%v err=%v", st.Managed, st.Update, st.LocalCommit, st.RemoteCommit, st.Dirty, err) } func (a *App) loadConfig() Config { var cfg Config data, err := os.ReadFile(a.configPath) if err == nil { _ = json.Unmarshal(data, &cfg) } return cfg } func (a *App) updateConfig(fn func(*Config)) { cfg := a.loadConfig() fn(&cfg) _ = os.MkdirAll(a.configDir, 0o755) data, _ := json.MarshalIndent(cfg, "", " ") data = append(data, '\n') tmp := a.configPath + ".tmp" if os.WriteFile(tmp, data, 0o600) == nil { _ = os.Rename(tmp, a.configPath) } } func (a *App) rotateLog() { _ = os.MkdirAll(a.stateDir, 0o755) info, err := os.Stat(a.logPath) if err != nil || info.Size() <= 4*1024*1024 { return } f, err := os.Open(a.logPath) if err != nil { return } defer f.Close() keep := int64(2 * 1024 * 1024) if _, err := f.Seek(-keep, io.SeekEnd); err != nil { return } data, _ := io.ReadAll(f) _ = os.WriteFile(a.logPath, data, 0o600) } func (a *App) logWriter() io.Writer { _ = os.MkdirAll(a.stateDir, 0o755) f, err := os.OpenFile(a.logPath, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600) if err != nil { return io.Discard } return &autoCloseWriter{f: f} } func (a *App) logf(format string, args ...any) { _ = os.MkdirAll(a.stateDir, 0o755) f, err := os.OpenFile(a.logPath, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600) if err != nil { return } defer f.Close() fmt.Fprintf(f, "%s version=%s %s\n", time.Now().Format(time.RFC3339), appVersion, fmt.Sprintf(format, args...)) } type autoCloseWriter struct{ f *os.File } func (w *autoCloseWriter) Write(p []byte) (int, error) { return w.f.Write(p) } func git(dir string, args ...string) (string, error) { cmd := exec.Command("git", append([]string{"-C", dir}, args...)...) out, err := cmd.CombinedOutput() if err != nil { return string(out), fmt.Errorf("git %s: %s", strings.Join(args, " "), strings.TrimSpace(string(out))) } return string(out), nil } func run(dir, name string, args ...string) error { cmd := exec.Command(name, args...) if dir != "" { cmd.Dir = dir } cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr return cmd.Run() } func runExit(dir, name string, args ...string) (int, error) { cmd := exec.Command(name, args...) if dir != "" { cmd.Dir = dir } err := cmd.Run() if err == nil { return 0, nil } var exitErr *exec.ExitError if errors.As(err, &exitErr) { return exitErr.ExitCode(), nil } return -1, err } func isGitRepo(dir string) bool { out, err := git(dir, "rev-parse", "--is-inside-work-tree") return err == nil && strings.TrimSpace(out) == "true" } func copyFile(src, dst string, mode os.FileMode) error { in, err := os.Open(src) if err != nil { return err } defer in.Close() out, err := os.OpenFile(dst, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, mode) if err != nil { return err } if _, err := io.Copy(out, in); err != nil { out.Close() return err } if err := out.Sync(); err != nil { out.Close() return err } return out.Close() } func sameFileHash(a, b string) (bool, error) { ha, err := fileHash(a) if err != nil { return false, err } hb, err := fileHash(b) if err != nil { return false, err } return ha == hb, nil } func fileHash(path string) (string, error) { f, err := os.Open(path) if err != nil { return "", err } defer f.Close() h := sha256.New() if _, err := io.Copy(h, f); err != nil { return "", err } return hex.EncodeToString(h.Sum(nil)), nil } func envOr(key, fallback string) string { if v := os.Getenv(key); v != "" { return v } return fallback } func short(s string) string { if len(s) > 12 { return s[:12] } return s } func hasArg(args []string, needle string) bool { for _, a := range args { if a == needle { return true } } return false } func printJSON(v any) { enc := json.NewEncoder(os.Stdout); enc.SetIndent("", " "); _ = enc.Encode(v) } func printStatusKV(s Status) { fmt.Printf("backend_version=%s\n", s.BackendVersion) fmt.Printf("managed=%s\n", s.Managed) fmt.Printf("auto_apply=%t\n", s.AutoApply) fmt.Printf("update=%s\n", s.Update) fmt.Printf("remote=%s\n", s.Remote) fmt.Printf("trusted_remote=%s\n", s.TrustedRemote) fmt.Printf("branch=%s\n", s.Branch) fmt.Printf("local_commit=%s\n", s.LocalCommit) fmt.Printf("remote_commit=%s\n", s.RemoteCommit) fmt.Printf("dirty=%t\n", s.Dirty) fmt.Printf("last_check=%s\n", s.LastCheck) fmt.Printf("last_update=%s\n", s.LastUpdate) fmt.Printf("last_result=%s\n", s.LastResult) } func fatal(err error) { fmt.Fprintln(os.Stderr, "omarchy-citizen-backend:", err); os.Exit(1) }