Generate a Plist
Turn a JobDefinition into valid plist XML that launchd can consume.
launchd doesn't read your schedule file. It reads property list XML — .plist files in ~/Library/LaunchAgents/. The manager generates these from your job definitions.
What a Plist Looks Like
bun run src/cli.ts plist hello-worldThe result.xml field contains the generated plist. Every plist has:
The Generator
plistForJob takes a JobDefinition and returns a string of valid XML. The key pieces:
XML escaping — &, <, >, ", ' in paths must be escaped. Your username in a path is fine, but a & in a job name would break the XML.
Recursive value serialization — plist has its own type system: <string>, <integer>, <true/>, <false/>, <array>, <dict>. The plistValue function handles nested structures recursively.
Schedule section — switches on the schedule kind to emit the right plist keys:
case "periodic":
// <key>StartInterval</key><integer>60</integer>
case "scheduled":
// <key>StartCalendarInterval</key><dict>...</dict>
case "on-change":
// <key>WatchPaths</key><array>...</array>PATH injection — launchd runs jobs with a minimal PATH (/usr/bin:/bin). The manager captures your current process.env.PATH into the plist so shebangs like #!/usr/bin/env bun work. Without this, every bun/node job silently fails.
Validate
Every generated plist should pass plutil -lint:
bun run src/cli.ts plist hello-world | \
python3 -c "import json,sys; print(json.load(sys.stdin)['result']['valid'])"Should print true. If it doesn't, the XML is malformed — check escaping.
Verification
# Preview all plists and validate them
for job in hello-world morning-briefing inbox-watcher daily-digest; do
echo -n "$job: "
bun run src/cli.ts plist $job | python3 -c "import json,sys; d=json.load(sys.stdin); print('✓' if d['result']['valid'] else '✗')"
doneAll four should show ✓.