// cronr schedules recurring or one-shot jobs as systemd user timers, // so an agent can set up work that survives session exit and reboot. // No daemon of its own — systemd does the running. See // doc/tool-parity.md §3.1. package main import ( "flag" "fmt" "os" "os/exec" "path/filepath" "sort" "strings" "gitea.brasse-pc.eu/brasse/agent-tools/cronr/unit" ) var version = "dev" const usage = `cronr - agent-friendly scheduling via systemd user timers Usage: cronr add --schedule "OnCalendar spec" --cmd "shell command" cronr add --at "YYYY-MM-DD HH:MM" --cmd "shell command" one-shot cronr list all cronr jobs: schedule, next run, last result cronr run run the job now (does not touch the timer) cronr logs [--lines N] journal for the job cronr rm disable and delete the job cronr version Schedule examples (systemd OnCalendar; validated with systemd-analyze): "*-*-* 07:00" every morning at 07:00 "Mon *-*-* 09:00" mondays 09:00 "*:0/15" every 15 minutes The job's command is stored as a script in ~/.local/share/cronr/ and runs with ~/.local/bin on PATH, so agent tools (notifyr, svgc, agy) work as in a login shell. Everything cronr creates is namespaced cronr- — it never touches other units. Example: cronr add nightly-ci --schedule "*-*-* 07:00" --cmd 'agy -p "check CI and notify"' ` func main() { if len(os.Args) < 2 { fmt.Print(usage) os.Exit(2) } switch os.Args[1] { case "add": cmdAdd(os.Args[2:]) case "list": cmdList() case "run": requireName(os.Args[2:], "run") sh("systemctl", "--user", "start", unit.Prefix+os.Args[2]+".service") fmt.Printf("started %s — see: cronr logs %s\n", os.Args[2], os.Args[2]) case "logs": cmdLogs(os.Args[2:]) case "rm": cmdRm(os.Args[2:]) case "version", "--version", "-v": fmt.Println("cronr", version) case "help", "--help", "-h": fmt.Print(usage) default: die("unknown command %q — run 'cronr help'", os.Args[1]) } } func unitDir() string { home, _ := os.UserHomeDir() return filepath.Join(home, ".config", "systemd", "user") } func scriptDir() string { home, _ := os.UserHomeDir() return filepath.Join(home, ".local", "share", "cronr") } func cmdAdd(args []string) { fs := flag.NewFlagSet("add", flag.ExitOnError) schedule := fs.String("schedule", "", "OnCalendar spec for recurring jobs") at := fs.String("at", "", `one-shot time "YYYY-MM-DD HH:MM"`) cmd := fs.String("cmd", "", "shell command the job runs (required)") pos := parseInterspersed(fs, args) if len(pos) != 1 { die("add needs exactly one job name") } name := pos[0] if !unit.ValidName(name) { die("invalid job name %q (letters, digits, - and _)", name) } if *cmd == "" { die("--cmd is required") } if (*schedule == "") == (*at == "") { die("give exactly one of --schedule (recurring) or --at (one-shot)") } spec := *schedule oneshot := false if *at != "" { var err error spec, err = unit.AtToCalendar(*at) if err != nil { die("%v", err) } oneshot = true } // systemd itself is the authority on calendar specs if out, err := exec.Command("systemd-analyze", "calendar", spec).CombinedOutput(); err != nil { die("systemd rejects the schedule %q:\n%s", spec, strings.TrimSpace(string(out))) } svcName, tmrName := unit.UnitNames(name) if _, err := os.Stat(filepath.Join(unitDir(), tmrName)); err == nil { die("job %s already exists (cronr rm %s first)", name, name) } scriptPath := filepath.Join(scriptDir(), name+".sh") if err := os.MkdirAll(scriptDir(), 0o755); err != nil { die("%v", err) } if err := os.MkdirAll(unitDir(), 0o755); err != nil { die("%v", err) } writes := []struct{ path, content string }{ {scriptPath, unit.Script(*cmd)}, {filepath.Join(unitDir(), svcName), unit.Service(name, scriptPath)}, {filepath.Join(unitDir(), tmrName), unit.Timer(name, spec, oneshot)}, } for _, w := range writes { mode := os.FileMode(0o644) if strings.HasSuffix(w.path, ".sh") { mode = 0o755 } if err := os.WriteFile(w.path, []byte(w.content), mode); err != nil { die("%v", err) } } sh("systemctl", "--user", "daemon-reload") sh("systemctl", "--user", "enable", "--now", tmrName) fmt.Printf("job %s created and enabled\n\n", name) for _, w := range writes { fmt.Printf("--- %s ---\n%s\n", w.path, w.content) } fmt.Print(nextRun(spec)) } func cmdList() { matches, _ := filepath.Glob(filepath.Join(unitDir(), unit.Prefix+"*.timer")) if len(matches) == 0 { fmt.Println("no cronr jobs") return } sort.Strings(matches) fmt.Printf("%-24s %-22s %-26s %s\n", "NAME", "SCHEDULE", "NEXT", "LAST RESULT") for _, m := range matches { name := unit.JobName(m) if name == "" { continue } spec := "" if data, err := os.ReadFile(m); err == nil { for _, line := range strings.Split(string(data), "\n") { if strings.HasPrefix(line, "OnCalendar=") { spec = strings.TrimPrefix(line, "OnCalendar=") } } } next := strings.TrimPrefix(nextRun(spec), "next run: ") svcName, _ := unit.UnitNames(name) last := lastResult(svcName) fmt.Printf("%-24s %-22s %-26s %s\n", name, spec, strings.TrimSpace(next), last) } } // nextRun asks systemd-analyze when the spec fires next. func nextRun(spec string) string { out, err := exec.Command("systemd-analyze", "calendar", spec).Output() if err != nil { return "next run: ?\n" } for _, line := range strings.Split(string(out), "\n") { line = strings.TrimSpace(line) if strings.HasPrefix(line, "Next elapse:") { return "next run: " + strings.TrimSpace(strings.TrimPrefix(line, "Next elapse:")) + "\n" } } return "next run: never (already elapsed?)\n" } func lastResult(svcName string) string { out, err := exec.Command("systemctl", "--user", "show", svcName, "-p", "ExecMainStatus", "-p", "ExecMainExitTimestamp").Output() if err != nil { return "?" } status, when := "?", "" for _, line := range strings.Split(string(out), "\n") { if v, ok := strings.CutPrefix(line, "ExecMainStatus="); ok { status = v } if v, ok := strings.CutPrefix(line, "ExecMainExitTimestamp="); ok { when = v } } if when == "" { return "never ran" } if status == "0" { return "ok (" + when + ")" } return "exit " + status + " (" + when + ")" } func cmdLogs(args []string) { fs := flag.NewFlagSet("logs", flag.ExitOnError) lines := fs.Int("lines", 50, "number of journal lines") pos := parseInterspersed(fs, args) if len(pos) != 1 { die("logs needs exactly one job name") } svcName, _ := unit.UnitNames(pos[0]) c := exec.Command("journalctl", "--user", "-u", svcName, "-n", fmt.Sprint(*lines), "--no-pager") c.Stdout, c.Stderr = os.Stdout, os.Stderr c.Run() } func cmdRm(args []string) { requireName(args, "rm") name := args[0] if !unit.ValidName(name) { die("invalid job name %q", name) } svcName, tmrName := unit.UnitNames(name) if _, err := os.Stat(filepath.Join(unitDir(), tmrName)); err != nil { die("no such job %s", name) } sh("systemctl", "--user", "disable", "--now", tmrName) for _, p := range []string{ filepath.Join(unitDir(), tmrName), filepath.Join(unitDir(), svcName), filepath.Join(scriptDir(), name+".sh"), } { os.Remove(p) } sh("systemctl", "--user", "daemon-reload") fmt.Printf("job %s removed\n", name) } func requireName(args []string, cmd string) { if len(args) < 1 || strings.HasPrefix(args[0], "-") { die("%s needs a job name", cmd) } } // sh runs a command, dying with its output on failure — every call // here is a systemctl whose failure should stop the operation. func sh(name string, args ...string) { out, err := exec.Command(name, args...).CombinedOutput() if err != nil { die("%s %s: %v\n%s", name, strings.Join(args, " "), err, strings.TrimSpace(string(out))) } } // parseInterspersed lets flags appear before or after positional args. func parseInterspersed(fs *flag.FlagSet, args []string) []string { var flags, pos []string for i := 0; i < len(args); i++ { a := args[i] if len(a) > 1 && a[0] == '-' { flags = append(flags, a) name := strings.TrimLeft(a, "-") if eq := strings.Index(name, "="); eq >= 0 { continue } if f := fs.Lookup(name); f != nil { if bf, ok := f.Value.(interface{ IsBoolFlag() bool }); ok && bf.IsBoolFlag() { continue } } if i+1 < len(args) { i++ flags = append(flags, args[i]) } } else { pos = append(pos, a) } } fs.Parse(flags) return pos } func die(format string, args ...interface{}) { fmt.Fprintf(os.Stderr, "cronr: "+format+"\n", args...) os.Exit(1) }