Files
agent-tools/cronr/unit/unit.go
claude 78c7b32eb2 cronr: recurring/one-shot jobs as systemd user timers
add (schedule validated by systemd-analyze, units + script printed),
list (next elapse + last result), run, logs, rm. Command stored as a
script so quoting never meets ExecStart; PATH includes ~/.local/bin
so agent tools resolve. cronr-* namespace guards foreign units.
Smoked live: add -> run -> journal -> rm. Spec: doc/tool-parity.md 3.1.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011KikHkfCiC3yELbsMN8fT9
2026-08-07 00:42:57 +02:00

97 lines
2.8 KiB
Go

// Package unit generates the systemd user units cronr manages. Pure
// text generation — systemd does the scheduling, cronr owns no daemon.
package unit
import (
"fmt"
"regexp"
"strings"
)
// Prefix namespaces everything cronr creates so list/rm can never
// touch units it does not own.
const Prefix = "cronr-"
var nameRe = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9_-]*$`)
// ValidName reports whether a job name is safe for unit/file names.
func ValidName(name string) bool {
return len(name) <= 64 && nameRe.MatchString(name)
}
// Script wraps the job command in an executable shell script — the
// unit ExecStart points here, so arbitrary quoting in the command
// never meets systemd's ExecStart parsing.
func Script(cmd string) string {
return "#!/bin/sh\n# generated by cronr - the job's command lives here so systemd\n# unit quoting never mangles it\n" + cmd + "\n"
}
// Service renders the .service unit. PATH gets ~/.local/bin first so
// jobs can call agent tools (notifyr, svgc, agy, ...) like a login
// shell would.
func Service(name, scriptPath string) string {
return fmt.Sprintf(`[Unit]
Description=cronr job %s
[Service]
Type=oneshot
Environment=PATH=%%h/.local/bin:/usr/local/bin:/usr/bin:/bin
ExecStart=/bin/sh %s
`, name, scriptPath)
}
// Timer renders the .timer unit. Recurring jobs get Persistent=true
// (a missed run fires at next boot/login); one-shots do not.
func Timer(name, calendarSpec string, oneshot bool) string {
persistent := "true"
if oneshot {
persistent = "false"
}
return fmt.Sprintf(`[Unit]
Description=cronr timer for %s
[Timer]
OnCalendar=%s
Persistent=%s
[Install]
WantedBy=timers.target
`, name, calendarSpec, persistent)
}
// UnitNames returns the service and timer unit names for a job.
func UnitNames(name string) (service, timer string) {
return Prefix + name + ".service", Prefix + name + ".timer"
}
// JobName extracts the job name from a cronr unit filename, or ""
// if the filename is not cronr's.
func JobName(unitFile string) string {
base := unitFile
if i := strings.LastIndex(base, "/"); i >= 0 {
base = base[i+1:]
}
if !strings.HasPrefix(base, Prefix) {
return ""
}
base = strings.TrimPrefix(base, Prefix)
for _, suffix := range []string{".timer", ".service"} {
if strings.HasSuffix(base, suffix) {
return strings.TrimSuffix(base, suffix)
}
}
return ""
}
// AtToCalendar converts "YYYY-MM-DD HH:MM" (or with seconds) to a
// systemd calendar spec for one-shot jobs. systemd accepts the format
// as-is; this just validates the shape early with a helpful error.
var atRe = regexp.MustCompile(`^\d{4}-\d{2}-\d{2} \d{2}:\d{2}(:\d{2})?$`)
func AtToCalendar(at string) (string, error) {
if !atRe.MatchString(at) {
return "", fmt.Errorf("--at must be \"YYYY-MM-DD HH:MM[:SS]\", got %q", at)
}
return at, nil
}