@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 +42 -0
- package/bin/launchd-install +27 -0
- package/bin/launchd-log-dirs +9 -0
- package/bin/launchd-logs +21 -0
- package/bin/launchd-logs-all +21 -0
- package/bin/launchd-logs-err +21 -0
- package/bin/launchd-new-job +85 -0
- package/bin/launchd-reload +25 -0
- package/bin/launchd-status +9 -0
- package/bin/launchd-uninstall +24 -0
- package/package.json +19 -0
- package/plist-monitor/.env.example +3 -0
- package/plist-monitor/README.md +11 -0
- package/plist-monitor/__pycache__/plist-monitor.cpython-312.pyc +0 -0
- package/plist-monitor/plist-monitor.py +514 -0
- package/plist-status.py +186 -0
- package/templates/job.plist.tmpl +25 -0
- package/templates/plist-monitor.plist.tmpl +42 -0
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)"
|
package/bin/launchd-logs
ADDED
|
@@ -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,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.
|
|
Binary file
|
|
@@ -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()
|
package/plist-status.py
ADDED
|
@@ -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>
|