@albeorla/launchd-kit 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,42 @@
1
+ # launchd-kit
2
+
3
+ Shared tooling for managing local LaunchAgents.
4
+
5
+ ## What Lives Here
6
+ - `bin/`: install/uninstall/reload/status/log helpers
7
+ - `plist-monitor/`: Discord webhook monitoring
8
+ - `plist-status.py`: terminal status dashboard
9
+ - `templates/`: plist templates
10
+
11
+ ## Environment Variables
12
+ - `LAUNCHD_LABEL_PREFIX` (default: `com.user.`)
13
+ - `LAUNCHD_PLIST_GLOB` (default: `*/<prefix>*.plist`)
14
+ - `LAUNCHD_INSTALL_DIR` (default: `~/Library/LaunchAgents`)
15
+ - `PLIST_MONITOR_ENV` (optional `.env` path override)
16
+
17
+ ## Typical Usage (local wrapper justfile)
18
+ In each repo, add a small `justfile` that calls `launchd-kit/bin/*`.
19
+
20
+ Example:
21
+ - `just install` → `~/dev/launchd-kit/bin/launchd-install '*/com.user.admin.*.plist'`
22
+ - `just status` → `~/dev/launchd-kit/bin/launchd-status`
23
+
24
+ ## Creating Jobs
25
+ See `docs/new-job.md` and the generator:
26
+ - `bin/launchd-new-job`
27
+
28
+ ## Monitoring
29
+ `plist-monitor` scans all loaded agents by prefix (defaults to `com.user.`).
30
+ You can override the filter via `LAUNCHD_PREFIX_FILTER` in the monitor's environment.
31
+
32
+ Suggested model:
33
+ - One global monitor job (e.g., in `admin`) with `LAUNCHD_PREFIX_FILTER=com.user.` to watch all agents.
34
+ - Domain repos only define their own jobs; they don't need their own monitor unless you want per-domain alerts.
35
+
36
+ ## Recommended Job Layout
37
+ In each repo, keep jobs self-contained:
38
+ - `jobs/<job>/job.sh`
39
+ - `jobs/<job>/com.user.<domain_id>.<job>.plist`
40
+ - `jobs/<job>/README.md`
41
+
42
+ If you prefer existing layouts (e.g., `scripts/` + `*.plist`), keep them; just point the glob in your wrapper justfile.
@@ -0,0 +1,27 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ prefix="${LAUNCHD_LABEL_PREFIX:-com.user.}"
5
+ plist_glob="${1:-${LAUNCHD_PLIST_GLOB:-*/${prefix}*.plist}}"
6
+ install_dir="${LAUNCHD_INSTALL_DIR:-$HOME/Library/LaunchAgents}"
7
+ uid=$(id -u)
8
+
9
+ shopt -s nullglob
10
+ plists=( $plist_glob )
11
+
12
+ if [[ ${#plists[@]} -eq 0 ]]; then
13
+ echo "No plists matched pattern: $plist_glob" >&2
14
+ exit 1
15
+ fi
16
+
17
+ mkdir -p "$install_dir"
18
+
19
+ count=0
20
+ for plist in "${plists[@]}"; do
21
+ name=$(basename "$plist")
22
+ ln -sf "$(pwd)/$plist" "$install_dir/$name"
23
+ launchctl bootstrap gui/"$uid" "$install_dir/$name" 2>/dev/null || true
24
+ count=$((count + 1))
25
+ done
26
+
27
+ echo "Installed $count agent(s)"
@@ -0,0 +1,9 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ prefix="${LAUNCHD_LABEL_PREFIX:-com.user.}"
5
+ plutil -p "$HOME/Library/LaunchAgents/${prefix}"*.plist \
6
+ | grep -o '"/tmp/[^"]*"' \
7
+ | tr -d '"' \
8
+ | xargs -n1 dirname \
9
+ | sort -u
@@ -0,0 +1,21 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ _self="$0"
5
+ [ -L "$_self" ] && { _t="$(readlink "$_self")"; [[ "$_t" != /* ]] && _t="$(dirname "$_self")/$_t"; _self="$_t"; }
6
+ SCRIPT_DIR="$(cd "$(dirname "$_self")" && pwd)"
7
+
8
+ "$SCRIPT_DIR/launchd-log-dirs" > /tmp/launchd-kit-log-dirs.txt
9
+ mapfile -t dirs < /tmp/launchd-kit-log-dirs.txt
10
+
11
+ if [[ ${#dirs[@]} -eq 0 ]]; then
12
+ echo "No log directories found" >&2
13
+ exit 1
14
+ fi
15
+
16
+ globs=()
17
+ for d in "${dirs[@]}"; do
18
+ globs+=("$d"/*.log)
19
+ done
20
+
21
+ exec tail -F "${globs[@]}"
@@ -0,0 +1,21 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ _self="$0"
5
+ [ -L "$_self" ] && { _t="$(readlink "$_self")"; [[ "$_t" != /* ]] && _t="$(dirname "$_self")/$_t"; _self="$_t"; }
6
+ SCRIPT_DIR="$(cd "$(dirname "$_self")" && pwd)"
7
+
8
+ "$SCRIPT_DIR/launchd-log-dirs" > /tmp/launchd-kit-log-dirs.txt
9
+ mapfile -t dirs < /tmp/launchd-kit-log-dirs.txt
10
+
11
+ if [[ ${#dirs[@]} -eq 0 ]]; then
12
+ echo "No log directories found" >&2
13
+ exit 1
14
+ fi
15
+
16
+ globs=()
17
+ for d in "${dirs[@]}"; do
18
+ globs+=("$d"/*)
19
+ done
20
+
21
+ exec tail -F "${globs[@]}"
@@ -0,0 +1,21 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ _self="$0"
5
+ [ -L "$_self" ] && { _t="$(readlink "$_self")"; [[ "$_t" != /* ]] && _t="$(dirname "$_self")/$_t"; _self="$_t"; }
6
+ SCRIPT_DIR="$(cd "$(dirname "$_self")" && pwd)"
7
+
8
+ "$SCRIPT_DIR/launchd-log-dirs" > /tmp/launchd-kit-log-dirs.txt
9
+ mapfile -t dirs < /tmp/launchd-kit-log-dirs.txt
10
+
11
+ if [[ ${#dirs[@]} -eq 0 ]]; then
12
+ echo "No log directories found" >&2
13
+ exit 1
14
+ fi
15
+
16
+ globs=()
17
+ for d in "${dirs[@]}"; do
18
+ globs+=("$d"/*.err)
19
+ done
20
+
21
+ exec tail -F "${globs[@]}"
@@ -0,0 +1,85 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ _self="$0"
5
+ [ -L "$_self" ] && { _t="$(readlink "$_self")"; [[ "$_t" != /* ]] && _t="$(dirname "$_self")/$_t"; _self="$_t"; }
6
+ SCRIPT_DIR="$(cd "$(dirname "$_self")" && pwd)"
7
+
8
+ usage() {
9
+ cat <<'USAGE'
10
+ Usage:
11
+ launchd-new-job \
12
+ --label com.user.<domain>.<job> \
13
+ --program /abs/path/to/job.sh \
14
+ --working-dir /abs/path/to/repo \
15
+ [--interval 3600] \
16
+ [--log-dir /tmp/<domain>] \
17
+ --out path/to/com.user.<domain>.<job>.plist
18
+
19
+ Notes:
20
+ - Generates a StartInterval plist using templates/job.plist.tmpl
21
+ - For calendar schedules, edit the plist manually
22
+ USAGE
23
+ }
24
+
25
+ label=""
26
+ program=""
27
+ working_dir=""
28
+ interval="3600"
29
+ log_dir=""
30
+ out_path=""
31
+
32
+ while [[ $# -gt 0 ]]; do
33
+ case "$1" in
34
+ --label) label="$2"; shift 2;;
35
+ --program) program="$2"; shift 2;;
36
+ --working-dir) working_dir="$2"; shift 2;;
37
+ --interval) interval="$2"; shift 2;;
38
+ --log-dir) log_dir="$2"; shift 2;;
39
+ --out) out_path="$2"; shift 2;;
40
+ -h|--help) usage; exit 0;;
41
+ *) echo "Unknown arg: $1" >&2; usage; exit 1;;
42
+ esac
43
+
44
+ done
45
+
46
+ if [[ -z "$label" || -z "$program" || -z "$working_dir" || -z "$out_path" ]]; then
47
+ echo "Missing required arguments" >&2
48
+ usage
49
+ exit 1
50
+ fi
51
+
52
+ job_name="${label##*.}"
53
+
54
+ domain="launchd"
55
+ if [[ "$label" =~ ^com\.user\.([^\.]+)\..+ ]]; then
56
+ domain="${BASH_REMATCH[1]}"
57
+ fi
58
+
59
+ if [[ -z "$log_dir" ]]; then
60
+ log_dir="/tmp/${domain}"
61
+ fi
62
+
63
+ stdout_path="${log_dir}/${job_name}.log"
64
+ stderr_path="${log_dir}/${job_name}.err"
65
+
66
+ mkdir -p "$log_dir"
67
+ mkdir -p "$(dirname "$out_path")"
68
+
69
+ template_path="$SCRIPT_DIR/../templates/job.plist.tmpl"
70
+
71
+ if [[ ! -f "$template_path" ]]; then
72
+ echo "Template not found: $template_path" >&2
73
+ exit 1
74
+ fi
75
+
76
+ sed \
77
+ -e "s#{{LABEL}}#${label}#g" \
78
+ -e "s#{{PROGRAM}}#${program}#g" \
79
+ -e "s#{{START_INTERVAL}}#${interval}#g" \
80
+ -e "s#{{WORKING_DIRECTORY}}#${working_dir}#g" \
81
+ -e "s#{{STDOUT_PATH}}#${stdout_path}#g" \
82
+ -e "s#{{STDERR_PATH}}#${stderr_path}#g" \
83
+ "$template_path" > "$out_path"
84
+
85
+ echo "Wrote plist: $out_path"
@@ -0,0 +1,25 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ prefix="${LAUNCHD_LABEL_PREFIX:-com.user.}"
5
+ plist_glob="${1:-${LAUNCHD_PLIST_GLOB:-*/${prefix}*.plist}}"
6
+ install_dir="${LAUNCHD_INSTALL_DIR:-$HOME/Library/LaunchAgents}"
7
+ uid=$(id -u)
8
+
9
+ shopt -s nullglob
10
+ plists=( $plist_glob )
11
+
12
+ if [[ ${#plists[@]} -eq 0 ]]; then
13
+ echo "No plists matched pattern: $plist_glob" >&2
14
+ exit 1
15
+ fi
16
+
17
+ for plist in "${plists[@]}"; do
18
+ name=$(basename "$plist")
19
+ label="${name%.plist}"
20
+ launchctl bootout gui/"$uid"/"$label" 2>/dev/null || true
21
+ ln -sf "$(pwd)/$plist" "$install_dir/$name"
22
+ launchctl bootstrap gui/"$uid" "$install_dir/$name" 2>/dev/null || true
23
+ done
24
+
25
+ echo "Reloaded ${#plists[@]} agent(s)"
@@ -0,0 +1,9 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ _self="$0"
5
+ [ -L "$_self" ] && { _t="$(readlink "$_self")"; [[ "$_t" != /* ]] && _t="$(dirname "$_self")/$_t"; _self="$_t"; }
6
+ SCRIPT_DIR="$(cd "$(dirname "$_self")" && pwd)"
7
+
8
+ prefix="${LAUNCHD_LABEL_PREFIX:-com.user.}"
9
+ python3 "$SCRIPT_DIR/../plist-status.py" --prefix "$prefix"
@@ -0,0 +1,24 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ prefix="${LAUNCHD_LABEL_PREFIX:-com.user.}"
5
+ plist_glob="${1:-${LAUNCHD_PLIST_GLOB:-*/${prefix}*.plist}}"
6
+ install_dir="${LAUNCHD_INSTALL_DIR:-$HOME/Library/LaunchAgents}"
7
+ uid=$(id -u)
8
+
9
+ shopt -s nullglob
10
+ plists=( $plist_glob )
11
+
12
+ if [[ ${#plists[@]} -eq 0 ]]; then
13
+ echo "No plists matched pattern: $plist_glob" >&2
14
+ exit 1
15
+ fi
16
+
17
+ for plist in "${plists[@]}"; do
18
+ name=$(basename "$plist")
19
+ label="${name%.plist}"
20
+ launchctl bootout gui/"$uid"/"$label" 2>/dev/null || true
21
+ rm -f "$install_dir/$name"
22
+ done
23
+
24
+ echo "Uninstalled ${#plists[@]} agent(s)"
package/package.json ADDED
@@ -0,0 +1,19 @@
1
+ {
2
+ "name": "@albeorla/launchd-kit",
3
+ "version": "1.0.0",
4
+ "description": "Shared tooling for managing macOS LaunchAgents",
5
+ "bin": {
6
+ "launchd-install": "bin/launchd-install",
7
+ "launchd-log-dirs": "bin/launchd-log-dirs",
8
+ "launchd-logs": "bin/launchd-logs",
9
+ "launchd-logs-all": "bin/launchd-logs-all",
10
+ "launchd-logs-err": "bin/launchd-logs-err",
11
+ "launchd-new-job": "bin/launchd-new-job",
12
+ "launchd-reload": "bin/launchd-reload",
13
+ "launchd-status": "bin/launchd-status",
14
+ "launchd-uninstall": "bin/launchd-uninstall"
15
+ },
16
+ "files": ["bin", "templates", "plist-monitor", "plist-status.py"],
17
+ "publishConfig": { "access": "public" },
18
+ "license": "ISC"
19
+ }
@@ -0,0 +1,3 @@
1
+ DISCORD_WEBHOOK_PLIST_MONITOR=https://discord.com/api/webhooks/YOUR_ID/YOUR_TOKEN
2
+ SEND_HEALTHY_NOTIFICATION=true
3
+ LAUNCHD_PREFIX_FILTER=com.user.
@@ -0,0 +1,11 @@
1
+ # plist-monitor
2
+
3
+ Discord webhook notifier for LaunchAgent health monitoring. Sends daily embeds showing failed jobs, today's runs, and upcoming schedule.
4
+
5
+ **Schedule:** Daily at 8:00 AM
6
+ **Logs:** `/tmp/com.user.admin.plist-monitor.{log,err}`
7
+ **Env:** `.env` (see `.env.example` for template). You can override the env path with `PLIST_MONITOR_ENV`.
8
+
9
+ Template plist: `../templates/plist-monitor.plist.tmpl`
10
+
11
+ Note: `com.user.admin.plist-monitor.plist` is an admin-specific example. Copy and edit the label, paths, and log locations for each repo that uses it.
@@ -0,0 +1,514 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Plist Monitor - Discord webhook for LaunchAgent monitoring.
4
+
5
+ Usage:
6
+ ./plist-monitor.py # Send if healthy notification enabled
7
+ ./plist-monitor.py --force # Always send notification
8
+
9
+ Reads configuration from .env file in the same directory.
10
+ """
11
+
12
+ import json
13
+ import os
14
+ import subprocess
15
+ import sys
16
+ from dataclasses import dataclass
17
+ from datetime import datetime, timedelta
18
+ from pathlib import Path
19
+
20
+ # pip install discord-webhook python-dotenv
21
+ from discord_webhook import DiscordWebhook, DiscordEmbed
22
+ from dotenv import load_dotenv
23
+
24
+ # Load .env from a specific path if provided, otherwise from the script directory
25
+ _script_dir = Path(__file__).parent
26
+ _env_path = Path(os.environ["PLIST_MONITOR_ENV"]) if os.environ.get("PLIST_MONITOR_ENV") else (_script_dir / ".env")
27
+ load_dotenv(_env_path)
28
+
29
+ # Colors (decimal format for Discord)
30
+ COLOR_SUCCESS = 0x2ECC71 # Green
31
+ COLOR_ERROR = 0xE74C3C # Red
32
+ COLOR_INFO = 0x3498DB # Blue
33
+
34
+ # Default prefix filter (can be overridden by LAUNCHD_PREFIX_FILTER env var)
35
+ DEFAULT_PREFIXES = "com.user."
36
+
37
+ # Exit code explanations
38
+ EXIT_CODE_HINTS = {
39
+ 0: "Success",
40
+ 1: "General error",
41
+ 2: "Misuse of shell command",
42
+ 126: "Permission denied (chmod +x?)",
43
+ 127: "Command not found",
44
+ 128: "Invalid exit argument",
45
+ 130: "Terminated (SIGINT)",
46
+ 137: "Killed (OOM or SIGKILL)",
47
+ 139: "Segfault (SIGSEGV)",
48
+ 143: "Terminated (SIGTERM)",
49
+ }
50
+
51
+
52
+ def get_exit_hint(code: str) -> str:
53
+ """Return human-readable hint for exit code."""
54
+ try:
55
+ c = int(code)
56
+ if c in EXIT_CODE_HINTS:
57
+ return EXIT_CODE_HINTS[c]
58
+ if c > 128:
59
+ return f"Signal {c - 128}"
60
+ if c < 0:
61
+ return f"Signal {abs(c)}"
62
+ return "Unknown"
63
+ except ValueError:
64
+ return ""
65
+
66
+
67
+ @dataclass
68
+ class Job:
69
+ """Represents a LaunchAgent job."""
70
+ label: str
71
+ domain: str
72
+ name: str
73
+ exit_code: str
74
+ last_run_epoch: int
75
+ next_run_epoch: int
76
+ schedule: str
77
+ interval: int = 0 # StartInterval seconds, 0 if calendar-based
78
+
79
+ @property
80
+ def is_healthy(self) -> bool:
81
+ return self.exit_code in ("0", "-")
82
+
83
+ @property
84
+ def display_name(self) -> str:
85
+ return f"{self.domain}.{self.name}"
86
+
87
+ @property
88
+ def cadence(self) -> str:
89
+ """Schedule cadence without time prefix (e.g., 'daily' from '6am daily')."""
90
+ for c in ("daily", "weekly", "monthly"):
91
+ if c in self.schedule:
92
+ return c
93
+ return self.schedule
94
+
95
+
96
+ def get_jobs(prefixes: list[str] | None = None) -> list[Job]:
97
+ """Get LaunchAgent jobs matching any of the prefixes."""
98
+ if prefixes is None:
99
+ prefix_str = os.environ.get("LAUNCHD_PREFIX_FILTER", DEFAULT_PREFIXES)
100
+ prefixes = [p.strip() for p in prefix_str.split(",") if p.strip()]
101
+
102
+ result = subprocess.run(["launchctl", "list"], capture_output=True, text=True)
103
+ home = Path.home()
104
+ now = datetime.now()
105
+ jobs = []
106
+
107
+ for line in result.stdout.strip().split("\n")[1:]:
108
+ parts = line.split("\t")
109
+ if len(parts) < 3:
110
+ continue
111
+
112
+ _, exit_code, label = parts
113
+ # Check if label matches any prefix, skip plist-monitor itself
114
+ if not any(label.startswith(p) for p in prefixes) or "plist-monitor" in label:
115
+ continue
116
+
117
+ # Parse domain.name from label
118
+ short = label.removeprefix("com.user.")
119
+ domain, name = short.split(".", 1) if "." in short else ("misc", short)
120
+
121
+ # Get schedule and timing info
122
+ plist = home / f"Library/LaunchAgents/{label}.plist"
123
+ schedule, next_epoch, interval = parse_schedule(plist, now)
124
+ last_run = get_last_run(plist, label, home)
125
+
126
+ jobs.append(Job(
127
+ label=label,
128
+ domain=domain,
129
+ name=name,
130
+ exit_code=exit_code,
131
+ last_run_epoch=last_run,
132
+ next_run_epoch=next_epoch,
133
+ schedule=schedule,
134
+ interval=interval,
135
+ ))
136
+
137
+ return jobs
138
+
139
+
140
+ def parse_schedule(plist: Path, now: datetime) -> tuple[str, int, int]:
141
+ """Parse schedule from plist, return (schedule_str, next_run_epoch, interval_secs)."""
142
+ if not plist.exists():
143
+ return "unknown", 0, 0
144
+
145
+ try:
146
+ result = subprocess.run(
147
+ ["plutil", "-convert", "json", "-o", "-", str(plist)],
148
+ capture_output=True, text=True
149
+ )
150
+ data = json.loads(result.stdout)
151
+ except (json.JSONDecodeError, subprocess.SubprocessError):
152
+ return "unknown", 0, 0
153
+
154
+ start_interval = data.get("StartInterval")
155
+ if start_interval:
156
+ if start_interval <= 60:
157
+ schedule = f"every {start_interval}s"
158
+ elif start_interval < 3600:
159
+ schedule = f"every {start_interval // 60}m"
160
+ elif start_interval == 3600:
161
+ schedule = "hourly"
162
+ else:
163
+ schedule = f"every {start_interval // 3600}h"
164
+ next_epoch = int(now.timestamp()) + start_interval
165
+ return schedule, next_epoch, start_interval
166
+
167
+ interval = data.get("StartCalendarInterval", {})
168
+ if isinstance(interval, list):
169
+ interval = interval[0] if interval else {}
170
+
171
+ hour = interval.get("Hour", 0)
172
+ minute = interval.get("Minute", 0)
173
+ day = interval.get("Day")
174
+ weekday = interval.get("Weekday")
175
+
176
+ # Format schedule string
177
+ if hour is not None:
178
+ h = hour if hour <= 12 else hour - 12
179
+ if hour == 0:
180
+ h = 12
181
+ ampm = "am" if hour < 12 else "pm"
182
+ time_str = f"{h}{ampm}"
183
+ else:
184
+ time_str = ""
185
+
186
+ if day is not None:
187
+ suffix = {1: "st", 2: "nd", 3: "rd", 21: "st", 22: "nd", 23: "rd", 31: "st"}.get(day, "th")
188
+ schedule = f"{time_str} {day}{suffix} monthly"
189
+ elif weekday is not None:
190
+ days = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]
191
+ schedule = f"{time_str} {days[weekday]}"
192
+ else:
193
+ schedule = f"{time_str} daily"
194
+
195
+ # Calculate next run as an absolute target time
196
+ target = now.replace(hour=hour or 0, minute=minute or 0, second=0, microsecond=0)
197
+
198
+ if weekday is not None:
199
+ days_ahead = (weekday - now.weekday()) % 7
200
+ if days_ahead == 0 and target <= now:
201
+ days_ahead = 7
202
+ target += timedelta(days=days_ahead)
203
+ elif day is not None:
204
+ if now.day > day or (now.day == day and target <= now):
205
+ month = now.month + 1 if now.month < 12 else 1
206
+ year = now.year if now.month < 12 else now.year + 1
207
+ target = target.replace(year=year, month=month, day=day)
208
+ else:
209
+ target = target.replace(day=day)
210
+ else:
211
+ if target <= now:
212
+ target += timedelta(days=1)
213
+
214
+ return schedule.strip(), int(target.timestamp()), 0
215
+
216
+
217
+ def get_last_run(plist: Path, label: str, home: Path) -> int:
218
+ """Get last run timestamp from log file mtime (uses most recent of stdout/stderr)."""
219
+ candidates = []
220
+
221
+ # Add paths from plist (check both stdout and stderr)
222
+ if plist.exists():
223
+ try:
224
+ result = subprocess.run(
225
+ ["plutil", "-convert", "json", "-o", "-", str(plist)],
226
+ capture_output=True, text=True
227
+ )
228
+ data = json.loads(result.stdout)
229
+ for key in ("StandardOutPath", "StandardErrorPath"):
230
+ if path := data.get(key):
231
+ candidates.append(Path(path))
232
+ except (json.JSONDecodeError, subprocess.SubprocessError):
233
+ pass
234
+
235
+ # Fallback: check /tmp/ with label-based naming
236
+ candidates.append(Path(f"/tmp/{label}.log"))
237
+ candidates.append(Path(f"/tmp/{label}.err"))
238
+
239
+ # Find the most recent mtime across all existing candidates
240
+ latest_mtime = 0
241
+ for path in candidates:
242
+ if path.exists():
243
+ mtime = int(path.stat().st_mtime)
244
+ if mtime > latest_mtime:
245
+ latest_mtime = mtime
246
+ return latest_mtime
247
+
248
+
249
+ def format_time(epoch: int) -> str:
250
+ """Format epoch as local time string like '6:15 AM'."""
251
+ if epoch <= 0:
252
+ return "?"
253
+ return datetime.fromtimestamp(epoch).strftime("%-I:%M %p")
254
+
255
+
256
+ def estimate_runs_today(interval: int) -> int:
257
+ """Estimate how many times an interval-based job ran since midnight."""
258
+ if interval <= 0:
259
+ return 0
260
+ now = datetime.now()
261
+ midnight = now.replace(hour=0, minute=0, second=0, microsecond=0)
262
+ secs = (now - midnight).total_seconds()
263
+ return max(0, int(secs / interval))
264
+
265
+
266
+ def static_relative(secs: int) -> str:
267
+ """Format seconds-from-now as static human-readable relative text."""
268
+ if secs < 0:
269
+ return "now"
270
+ if secs < 3600:
271
+ return f"{secs // 60}m"
272
+ if secs < 86400:
273
+ h = secs // 3600
274
+ return f"{h}h"
275
+ days = secs // 86400
276
+ if days == 1:
277
+ return "1d"
278
+ if days < 14:
279
+ return f"{days}d"
280
+ if days < 60:
281
+ weeks = days // 7
282
+ return f"{weeks}w"
283
+ months = days // 30
284
+ return f"~{months}mo"
285
+
286
+
287
+ def get_midnight_epoch() -> int:
288
+ """Get epoch timestamp for local midnight today."""
289
+ now = datetime.now()
290
+ midnight = now.replace(hour=0, minute=0, second=0, microsecond=0)
291
+ return int(midnight.timestamp())
292
+
293
+
294
+ def build_attention_embed(failed_jobs: list[Job], total_jobs: int) -> DiscordEmbed | None:
295
+ """Build embed for jobs needing attention. Returns None if all healthy."""
296
+ if not failed_jobs:
297
+ return None
298
+
299
+ embed = DiscordEmbed(
300
+ title=f"Needs Attention ({len(failed_jobs)}/{total_jobs})",
301
+ color=COLOR_ERROR
302
+ )
303
+ embed.set_footer(text=os.uname().nodename)
304
+ embed.set_timestamp()
305
+
306
+ sorted_jobs = sorted(failed_jobs, key=lambda j: -j.last_run_epoch)[:10]
307
+ w = max(len(j.display_name) for j in sorted_jobs)
308
+
309
+ lines = []
310
+ for job in sorted_jobs:
311
+ name = job.display_name.ljust(w)
312
+ hint = get_exit_hint(job.exit_code)
313
+ exit_info = f"exit {job.exit_code} {hint}" if hint else f"exit {job.exit_code}"
314
+ retry = format_time(job.next_run_epoch) if job.next_run_epoch else "?"
315
+ lines.append(f"✗ {name} {exit_info}")
316
+ lines.append(f" {''.ljust(w)} retry {retry} · {job.schedule}")
317
+
318
+ overflow = ""
319
+ if len(failed_jobs) > 10:
320
+ overflow = f"\n*+{len(failed_jobs) - 10} more*"
321
+
322
+ embed.description = f"```\n{chr(10).join(lines)}\n```{overflow}"
323
+ return embed
324
+
325
+
326
+ def build_ran_today_embed(jobs: list[Job]) -> DiscordEmbed | None:
327
+ """Build embed for jobs that ran today (since local midnight)."""
328
+ midnight = get_midnight_epoch()
329
+
330
+ # Include calendar jobs with recent logs AND interval jobs that are healthy (running)
331
+ today_jobs = [
332
+ j for j in jobs
333
+ if j.last_run_epoch >= midnight or (j.interval and j.is_healthy)
334
+ ]
335
+
336
+ if not today_jobs:
337
+ return None
338
+
339
+ # Sort: interval jobs (by estimated runs desc), then calendar jobs (by last run desc)
340
+ today_jobs.sort(key=lambda j: (0 if j.interval else 1, -j.last_run_epoch))
341
+
342
+ display = today_jobs[:15]
343
+ w = max(len(j.display_name) for j in display)
344
+
345
+ embed = DiscordEmbed(title=f"Ran Today ({len(today_jobs)})", color=COLOR_SUCCESS)
346
+ embed.set_footer(text=os.uname().nodename)
347
+ embed.set_timestamp()
348
+
349
+ lines = []
350
+ for job in display:
351
+ status = "✓" if job.is_healthy else "✗"
352
+ name = job.display_name.ljust(w)
353
+ if job.interval:
354
+ runs = estimate_runs_today(job.interval)
355
+ lines.append(f"{status} {name} ×{runs:<4} {job.cadence}")
356
+ else:
357
+ time_str = format_time(job.last_run_epoch)
358
+ lines.append(f"{status} {name} {time_str:>8} {job.cadence}")
359
+
360
+ overflow = ""
361
+ if len(today_jobs) > 15:
362
+ overflow = f"\n*+{len(today_jobs) - 15} more*"
363
+
364
+ embed.description = f"```\n{chr(10).join(lines)}\n```{overflow}"
365
+ return embed
366
+
367
+
368
+ def build_coming_up_embed(jobs: list[Job], now_epoch: int) -> DiscordEmbed | None:
369
+ """Build embed for jobs coming up within 24h."""
370
+ window_secs = 24 * 3600
371
+
372
+ upcoming = [
373
+ j for j in jobs
374
+ if j.next_run_epoch > now_epoch and (j.next_run_epoch - now_epoch) <= window_secs
375
+ ]
376
+
377
+ if not upcoming:
378
+ return None
379
+
380
+ upcoming.sort(key=lambda j: j.next_run_epoch)
381
+
382
+ display = upcoming[:10]
383
+ w = max(len(j.display_name) for j in display)
384
+
385
+ embed = DiscordEmbed(title=f"Up Next ({len(upcoming)})", color=COLOR_INFO)
386
+ embed.set_footer(text=os.uname().nodename)
387
+ embed.set_timestamp()
388
+
389
+ lines = []
390
+ for i, job in enumerate(display):
391
+ marker = "▸" if i == 0 else "·"
392
+ name = job.display_name.ljust(w)
393
+ time_str = format_time(job.next_run_epoch)
394
+ eta = static_relative(job.next_run_epoch - now_epoch)
395
+ lines.append(f"{marker} {name} {time_str:>8} {eta:>4}")
396
+
397
+ overflow = ""
398
+ if len(upcoming) > 10:
399
+ overflow = f"\n*+{len(upcoming) - 10} more*"
400
+
401
+ embed.description = f"```\n{chr(10).join(lines)}\n```{overflow}"
402
+ return embed
403
+
404
+
405
+ def build_later_embed(jobs: list[Job], now_epoch: int) -> DiscordEmbed | None:
406
+ """Build embed for jobs scheduled beyond 24h."""
407
+ soon_secs = 24 * 3600
408
+
409
+ later = [
410
+ j for j in jobs
411
+ if j.next_run_epoch > now_epoch and (j.next_run_epoch - now_epoch) > soon_secs
412
+ ]
413
+
414
+ if not later:
415
+ return None
416
+
417
+ later.sort(key=lambda j: j.next_run_epoch)
418
+
419
+ display = later[:10]
420
+ w = max(len(j.display_name) for j in display)
421
+
422
+ embed = DiscordEmbed(title=f"Later ({len(later)})", color=COLOR_INFO)
423
+ embed.set_footer(text=os.uname().nodename)
424
+ embed.set_timestamp()
425
+
426
+ lines = []
427
+ for job in display:
428
+ name = job.display_name.ljust(w)
429
+ eta = static_relative(job.next_run_epoch - now_epoch)
430
+ lines.append(f"· {name} in {eta:>4} {job.schedule}")
431
+
432
+ overflow = ""
433
+ if len(later) > 10:
434
+ overflow = f"\n*+{len(later) - 10} more*"
435
+
436
+ embed.description = f"```\n{chr(10).join(lines)}\n```{overflow}"
437
+ return embed
438
+
439
+
440
+ def build_embeds(jobs: list[Job]) -> list[DiscordEmbed]:
441
+ """Build Discord embeds - separate concerns for clarity."""
442
+ now_epoch = int(datetime.now().timestamp())
443
+ failed = [j for j in jobs if not j.is_healthy]
444
+
445
+ embeds = []
446
+
447
+ if attention := build_attention_embed(failed, len(jobs)):
448
+ embeds.append(attention)
449
+
450
+ if ran_today := build_ran_today_embed(jobs):
451
+ embeds.append(ran_today)
452
+
453
+ if coming_up := build_coming_up_embed(jobs, now_epoch):
454
+ embeds.append(coming_up)
455
+
456
+ if later := build_later_embed(jobs, now_epoch):
457
+ embeds.append(later)
458
+
459
+ if not embeds:
460
+ embed = DiscordEmbed(title="🟢 All Quiet", color=COLOR_SUCCESS)
461
+ embed.description = f"**{len(jobs)}** services healthy, no recent activity"
462
+ embed.set_footer(text=os.uname().nodename)
463
+ embed.set_timestamp()
464
+ embeds.append(embed)
465
+
466
+ return embeds
467
+
468
+
469
+ def send_notification(webhook_url: str, jobs: list[Job]) -> bool:
470
+ """Send Discord notification."""
471
+ webhook = DiscordWebhook(url=webhook_url)
472
+
473
+ for embed in build_embeds(jobs):
474
+ webhook.add_embed(embed)
475
+
476
+ response = webhook.execute()
477
+
478
+ if response.ok:
479
+ print(f"[INFO] Discord notification sent (HTTP {response.status_code})")
480
+ return True
481
+ else:
482
+ print(f"[ERROR] Discord failed: {response.status_code} {response.text}", file=sys.stderr)
483
+ return False
484
+
485
+
486
+ def main():
487
+ webhook_url = os.environ.get("DISCORD_WEBHOOK_PLIST_MONITOR")
488
+ if not webhook_url:
489
+ print("[ERROR] DISCORD_WEBHOOK_PLIST_MONITOR not set", file=sys.stderr)
490
+ print("[ERROR] Create .env file from .env.example in this directory", file=sys.stderr)
491
+ sys.exit(1)
492
+
493
+ send_healthy = os.environ.get("SEND_HEALTHY_NOTIFICATION", "").lower() == "true"
494
+ force = "--force" in sys.argv
495
+
496
+ print("[INFO] Starting job status check...")
497
+
498
+ jobs = get_jobs()
499
+ failed = [j for j in jobs if not j.is_healthy]
500
+
501
+ print(f"[INFO] Found {len(jobs)} jobs, {len(failed)} failed")
502
+
503
+ if failed or send_healthy or force:
504
+ if send_notification(webhook_url, jobs):
505
+ sys.exit(0)
506
+ else:
507
+ sys.exit(2)
508
+ else:
509
+ print("[INFO] All healthy, skipping notification (set SEND_HEALTHY_NOTIFICATION=true to enable)")
510
+ sys.exit(0)
511
+
512
+
513
+ if __name__ == "__main__":
514
+ main()
@@ -0,0 +1,186 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Plist Status Dashboard - Terminal display for all com.user.* LaunchAgents.
4
+
5
+ Usage:
6
+ python3 plist-status.py # Show all com.user.* agents
7
+ python3 plist-status.py --prefix X # Override prefix filter
8
+ """
9
+
10
+ import json
11
+ import os
12
+ import subprocess
13
+ import sys
14
+ from datetime import datetime, timedelta
15
+ from pathlib import Path
16
+
17
+
18
+ def parse_plist_json(plist_path: Path) -> dict:
19
+ """Convert plist to JSON dict."""
20
+ try:
21
+ result = subprocess.run(
22
+ ["plutil", "-convert", "json", "-o", "-", str(plist_path)],
23
+ capture_output=True, text=True
24
+ )
25
+ return json.loads(result.stdout)
26
+ except (json.JSONDecodeError, subprocess.SubprocessError):
27
+ return {}
28
+
29
+
30
+ def get_next_run(data: dict, now: datetime) -> str:
31
+ """Calculate next run time from plist schedule."""
32
+ interval = data.get("StartCalendarInterval", {})
33
+ start_interval = data.get("StartInterval")
34
+
35
+ if start_interval:
36
+ return f"every {start_interval}s"
37
+
38
+ if isinstance(interval, list):
39
+ interval = interval[0] if interval else {}
40
+
41
+ if not interval:
42
+ return "manual"
43
+
44
+ hour = interval.get("Hour", 0)
45
+ minute = interval.get("Minute", 0)
46
+ day = interval.get("Day")
47
+ weekday = interval.get("Weekday")
48
+
49
+ target = now.replace(hour=hour, minute=minute, second=0, microsecond=0)
50
+
51
+ if weekday is not None:
52
+ days_ahead = (weekday - now.weekday()) % 7
53
+ if days_ahead == 0 and target <= now:
54
+ days_ahead = 7
55
+ target += timedelta(days=days_ahead)
56
+ elif day is not None:
57
+ if now.day > day or (now.day == day and target <= now):
58
+ month = now.month + 1 if now.month < 12 else 1
59
+ year = now.year if now.month < 12 else now.year + 1
60
+ target = target.replace(year=year, month=month, day=day)
61
+ else:
62
+ target = target.replace(day=day)
63
+ else:
64
+ if target <= now:
65
+ target += timedelta(days=1)
66
+
67
+ delta = target - now
68
+ if delta.days > 0:
69
+ return f"{delta.days}d {delta.seconds // 3600}h"
70
+ elif delta.seconds > 3600:
71
+ return f"{delta.seconds // 3600}h {(delta.seconds % 3600) // 60}m"
72
+ else:
73
+ return f"{delta.seconds // 60}m"
74
+
75
+
76
+ def get_log_mtime(data: dict, label: str) -> str:
77
+ """Get last run time from log file mtime."""
78
+ candidates = []
79
+ for key in ("StandardOutPath", "StandardErrorPath"):
80
+ if path := data.get(key):
81
+ candidates.append(Path(path))
82
+ candidates.append(Path(f"/tmp/{label}.log"))
83
+
84
+ latest = 0
85
+ for p in candidates:
86
+ if p.exists():
87
+ mtime = int(p.stat().st_mtime)
88
+ if mtime > latest:
89
+ latest = mtime
90
+
91
+ if latest == 0:
92
+ return "never"
93
+
94
+ dt = datetime.fromtimestamp(latest)
95
+ now = datetime.now()
96
+ delta = now - dt
97
+
98
+ if delta.days == 0:
99
+ return dt.strftime("%H:%M today")
100
+ elif delta.days == 1:
101
+ return dt.strftime("%H:%M yesterday")
102
+ elif delta.days < 7:
103
+ return dt.strftime("%a %H:%M")
104
+ else:
105
+ return dt.strftime("%Y-%m-%d")
106
+
107
+
108
+ def get_log_path(data: dict) -> str:
109
+ """Get log path from plist."""
110
+ path = data.get("StandardOutPath", "")
111
+ if path:
112
+ return path
113
+ return "-"
114
+
115
+
116
+ def main():
117
+ prefix = "com.user."
118
+ if "--prefix" in sys.argv:
119
+ idx = sys.argv.index("--prefix")
120
+ if idx + 1 < len(sys.argv):
121
+ prefix = sys.argv[idx + 1]
122
+
123
+ # Get loaded agents
124
+ result = subprocess.run(["launchctl", "list"], capture_output=True, text=True)
125
+ loaded = {}
126
+ for line in result.stdout.strip().split("\n")[1:]:
127
+ parts = line.split("\t")
128
+ if len(parts) >= 3:
129
+ _, exit_code, label = parts
130
+ if label.startswith(prefix):
131
+ loaded[label] = exit_code
132
+
133
+ # Scan plists in ~/Library/LaunchAgents/
134
+ agents_dir = Path.home() / "Library/LaunchAgents"
135
+ plists = sorted(agents_dir.glob(f"{prefix}*.plist"))
136
+
137
+ now = datetime.now()
138
+ rows = []
139
+
140
+ for plist in plists:
141
+ label = plist.stem
142
+ data = parse_plist_json(plist)
143
+ exit_code = loaded.get(label, "unloaded")
144
+ is_loaded = label in loaded
145
+
146
+ status = "✓" if exit_code == "0" else ("─" if exit_code == "-" else f"✗ {exit_code}")
147
+ if not is_loaded:
148
+ status = "○ unloaded"
149
+
150
+ last_run = get_log_mtime(data, label)
151
+ next_run = get_next_run(data, now) if is_loaded else "-"
152
+ log_path = get_log_path(data)
153
+
154
+ # Short label: strip prefix
155
+ short = label.removeprefix("com.user.")
156
+
157
+ rows.append((short, status, last_run, next_run, log_path))
158
+
159
+ if not rows:
160
+ print(f"No agents found matching {prefix}*")
161
+ return
162
+
163
+ # Calculate column widths
164
+ headers = ("AGENT", "STATUS", "LAST RUN", "NEXT RUN", "LOG")
165
+ widths = [max(len(h), max(len(r[i]) for r in rows)) for i, h in enumerate(headers)]
166
+
167
+ # Print header
168
+ header_line = " ".join(h.ljust(w) for h, w in zip(headers, widths))
169
+ print(f"\n{header_line}")
170
+ print("─" * len(header_line))
171
+
172
+ # Print rows
173
+ for row in rows:
174
+ print(" ".join(val.ljust(w) for val, w in zip(row, widths)))
175
+
176
+ # Summary
177
+ total = len(rows)
178
+ healthy = sum(1 for r in rows if r[1].startswith("✓") or r[1].startswith("─"))
179
+ failed = sum(1 for r in rows if r[1].startswith("✗"))
180
+ unloaded = sum(1 for r in rows if "unloaded" in r[1])
181
+
182
+ print(f"\n{total} agents: {healthy} healthy, {failed} failed, {unloaded} unloaded\n")
183
+
184
+
185
+ if __name__ == "__main__":
186
+ main()
@@ -0,0 +1,25 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3
+ <plist version="1.0">
4
+ <dict>
5
+ <key>Label</key>
6
+ <string>{{LABEL}}</string>
7
+
8
+ <key>ProgramArguments</key>
9
+ <array>
10
+ <string>{{PROGRAM}}</string>
11
+ </array>
12
+
13
+ <key>StartInterval</key>
14
+ <integer>{{START_INTERVAL}}</integer>
15
+
16
+ <key>WorkingDirectory</key>
17
+ <string>{{WORKING_DIRECTORY}}</string>
18
+
19
+ <key>StandardOutPath</key>
20
+ <string>{{STDOUT_PATH}}</string>
21
+
22
+ <key>StandardErrorPath</key>
23
+ <string>{{STDERR_PATH}}</string>
24
+ </dict>
25
+ </plist>
@@ -0,0 +1,42 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3
+ <plist version="1.0">
4
+ <dict>
5
+ <key>Label</key>
6
+ <string>{{LABEL}}</string>
7
+
8
+ <key>ProgramArguments</key>
9
+ <array>
10
+ <string>{{PYTHON}}</string>
11
+ <string>{{SCRIPT}}</string>
12
+ </array>
13
+
14
+ <key>RunAtLoad</key>
15
+ <false/>
16
+
17
+ <key>StartCalendarInterval</key>
18
+ <dict>
19
+ <key>Hour</key>
20
+ <integer>{{HOUR}}</integer>
21
+ <key>Minute</key>
22
+ <integer>{{MINUTE}}</integer>
23
+ </dict>
24
+
25
+ <key>WorkingDirectory</key>
26
+ <string>{{WORKING_DIRECTORY}}</string>
27
+
28
+ <key>EnvironmentVariables</key>
29
+ <dict>
30
+ <key>PLIST_MONITOR_ENV</key>
31
+ <string>{{ENV_PATH}}</string>
32
+ <key>LAUNCHD_PREFIX_FILTER</key>
33
+ <string>{{PREFIX_FILTER}}</string>
34
+ </dict>
35
+
36
+ <key>StandardOutPath</key>
37
+ <string>{{STDOUT_PATH}}</string>
38
+
39
+ <key>StandardErrorPath</key>
40
+ <string>{{STDERR_PATH}}</string>
41
+ </dict>
42
+ </plist>