|
|
|
@@ -0,0 +1,663 @@
|
|
|
|
|
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) }
|