{"slug": "the-morning-my-cron-jobs-went-silent-a-97-line-script-that-migrated-everything", "title": "The Morning My cron Jobs Went Silent: A 97-Line Script That Migrated Everything to launchd", "summary": "A developer shared a 97-line shell script that migrates cron jobs to launchd on macOS after a system update silently disabled the cron daemon, causing automated tasks to fail without alerts. The script parses crontab entries and generates launchd plists, addressing the need for explicit environment variables and other launchd-specific configurations.", "body_md": "Six months after being laid off, I'd rebuilt my income from zero to ¥1.2M/month on an autonomous setup. Then one morning at 8:00, it just wasn't there — no error, no alert, nothing. The cause: a macOS update had quietly disabled the cron daemon. My fix was a 97-line shell script that parses `crontab`\n\nline by line and auto-generates launchd plists.\n\nBack when my side business was earning ¥600K/month, nearly every yen of that automation benefit rode on cron jobs. Timing note publications, scheduling social posts, daily data aggregation — all of it lined up in `crontab -l`\n\n. When I was laid off and dropped to zero, rebuilding the environment with Claude Code, I decided carrying the crontab over as-is was the fastest path.\n\nRight after upgrading to macOS Sequoia (15.x), nothing appeared to have changed. Run `crontab -l`\n\nand every entry is still there. But **the daemon isn't running**. Since macOS Ventura, Apple has been progressively decoupling the cron daemon from the user session, and on Sequoia/Tahoe it's perfectly normal to have `/usr/sbin/cron`\n\npresent while `launchctl list | grep cron`\n\nreturns nothing at all.\n\nThe reason I was slow to notice is that when automation stops, **no error appears**. My assumption was that if cron isn't running, an error mail lands in `/var/mail/<username>`\n\n— and that assumption had collapsed. On Sequoia it doesn't reach the post office by default. The 8:00 daily brief doesn't arrive, the 11:00 social post doesn't go out, and only then do you notice. That \"silent death\" is what scares me.\n\nOn macOS, process launching and management belongs to `launchd`\n\n(PID 1). cron survives only as historical compatibility; what Apple actually recommends is job management via launchd. launchd handles automatic restarts when a daemon crashes, automatic execution after wake for jobs scheduled while the machine was asleep, direct redirection of stdout/stderr to files, and explicit injection of environment variables — all declaratively, in a single plist file.\n\ncron lets you write `*/5 * * * * cmd`\n\non one line; a launchd plist becomes 20–30 lines of XML. That verbosity is the biggest psychological barrier to migrating to launchd. Rewriting ten of them by hand isn't realistic. So you generate them with a script.\n\nLooking at one plist that's actually in production makes the structure click. Here's how `~/Library/LaunchAgents/com.shun.daily-brief.plist`\n\nis composed (excerpted from the real file, paths converted to `~`\n\nnotation):\n\n```\n<key>Label</key>\n<string>com.shun.daily-brief</string>\n\n<key>EnvironmentVariables</key>\n<dict>\n  <key>PATH</key>\n  <string>~/.nvm/versions/node/v24.13.0/bin:/opt/homebrew/bin:/opt/homebrew/sbin:\n          /usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:~/.local/bin</string>\n</dict>\n\n<key>StartCalendarInterval</key>\n<array>\n  <dict>\n    <key>Hour</key><integer>8</integer>\n    <key>Minute</key><integer>0</integer>\n  </dict>\n  <dict>\n    <key>Hour</key><integer>10</integer>\n    <key>Minute</key><integer>30</integer>\n  </dict>\n</array>\n\n<key>ProgramArguments</key>\n<array>\n  <string>~/.claude/scripts/claude-quota-guard.py</string>\n  <string>--job</string>\n  <string>com.shun.daily-brief</string>\n  <string>--</string>\n  <string>/bin/bash</string>\n  <string>~/.claude/scripts/daily-brief.sh</string>\n</array>\n\n<key>LowPriorityIO</key><true/>\n<key>Nice</key><integer>10</integer>\n<key>RunAtLoad</key><true/>\n<key>StandardOutPath</key>\n<string>~/.claude/logs/com.shun.daily-brief.log</string>\n<key>StandardErrorPath</key>\n<string>~/.claude/logs/com.shun.daily-brief.log</string>\n```\n\nThree things stand out.\n\n**Explicit EnvironmentVariables.** launchd does not read your shell configuration (\n\n`.zshrc`\n\n, `.bashrc`\n\n). A script that uses node installed via nvm has no PATH to it under launchd management and dies with `node: command not found`\n\n. This accounts for 90% of the cases where a job migrated from cron suddenly stops working. Writing PATH explicitly into the plist guarantees the same binary gets called no matter what the shell is.**The array form of StartCalendarInterval.** When you want to run multiple times per day, you line up\n\n`<dict>`\n\nentries inside an `<array>`\n\n. daily-brief runs twice, at 8:00 and 10:30. In cron you'd write `0 8,10 * * *`\n\n, but launchd requires a dictionary per time. How far the auto-generation script covers this notational gap ties into the pitfalls described later.** LowPriorityIO and Nice.** Background jobs get lowered I/O priority and a CPU scheduler nice value of 10. It's a setting to minimize impact on foreground work (editor, browser), consistent with the \"erase your presence\" philosophy of an autonomous environment.\n\nOf that ¥1.2M/month breakdown, almost none of it is me moving my hands. Most of the note series, social updates, and data aggregation are automated. The maintenance cost of this environment comes down to moving cron onto a foundation that actually runs. The goal of being under launchd management is that a scheduled task you wrote once is still running three years later. Apple's launchd is a stable API unchanged since macOS 10.4 (2005), and it doesn't \"die unnoticed\" the way cron does. `launchctl list com.shun.daily-brief`\n\nshows you LastExitStatus and the next scheduled run instantly.\n\nThe 90 minutes spent setting up the environment is an investment that buys back 5 minutes × 365 days (= 30 hours) of \"let me check whether it's actually running\" every morning.\n\n```\ncrontab -l\n  │  grep -vE '^\\s*#' | grep -v '^$'  ← コメント行・空行を除外\n  ↓\n[1行ごとにループ]\n  │  awk '{print $1...$5}' で schedule フィールド抽出\n  │  cut -d' ' -f6-           で cmd 部分を切り出し\n  │  basename からラベル生成  → com.shun.<script-name>\n  ↓\nStartCalendarInterval XML 組み立て\n  │  ※ */N 形式は非対応（固定値のみ）← ここが落とし穴\n  ↓\nplist ファイル書き出し\n  → [dry]   ~/.claude/scripts/launchd-proposed/*.plist\n  → [apply] ~/Library/LaunchAgents/*.plist\n               + launchctl unload → launchctl load\n  ↓\n⚠️  警告: crontab から手動削除しないと二重起動\n```\n\nThe script lives at `~/.claude/scripts/cron-to-launchd.sh`\n\n, and there are two ways to use it.\n\n```\n# 差分確認（ファイルを書くだけ、loadしない）\n~/.claude/scripts/cron-to-launchd.sh dry\n\n# 本番反映（LaunchAgentsにコピーしてlaunchctl load）\n~/.claude/scripts/cron-to-launchd.sh apply\n```\n\nCall it with no arguments and `dry`\n\nis the default (`MODE=\"${1:-dry}\"`\n\n). The iron rule is to not jump straight to `apply`\n\n— run `dry`\n\nfirst and eyeball the generated output.\n\n**Phase 1: Reading and parsing the crontab (lines 20–28)**\n\n```\nCRON_LINES=()\nwhile IFS= read -r line; do\n  [ -n \"$line\" ] && CRON_LINES+=(\"$line\")\ndone < <(crontab -l 2>/dev/null | grep -vE '^\\s*#' | grep -v '^$')\n```\n\nAs the comment `bash 3.2 互換`\n\nindicates, the bash that ships with macOS is version 3.2 (Apple hasn't updated it for GPLv2 reasons). `mapfile`\n\nand `readarray`\n\naren't available in 3.2, so the array is built with a `while IFS= read -r`\n\nloop. `crontab -l 2>/dev/null`\n\nswallows the error when the crontab is empty, `grep -vE '^\\s*#'`\n\nstrips comment lines, and `grep -v '^$'`\n\nstrips blank lines.\n\n**Phase 2: Splitting each line into schedule and cmd (lines 28–38)**\n\n```\nminute=$(echo \"$line\" | awk '{print $1}')\nhour=$(echo \"$line\" | awk '{print $2}')\ndom=$(echo \"$line\"   | awk '{print $3}')\nmon=$(echo \"$line\"   | awk '{print $4}')\ndow=$(echo \"$line\"   | awk '{print $5}')\ncmd=$(echo \"$line\"   | cut -d' ' -f6-)\n```\n\nThe cron format `min hour dom mon dow cmd...`\n\nis pulled apart field by field with awk. Since `cmd`\n\ntakes everything from the sixth field onward via `cut -d' ' -f6-`\n\n, it picks up the command correctly no matter how many arguments it has.\n\nThe label generation logic (lines 38–40):\n\n```\nscript=$(echo \"$cmd\" | grep -oE '~/.claude/scripts/[^ ]+' | head -1 | xargs basename 2>/dev/null)\nif [ -z \"$script\" ]; then\n  script=\"$(echo \"$cmd\" | awk '{print $1}' | xargs basename 2>/dev/null)-${minute}${hour}\"\nfi\nlabel=\"com.shun.$(echo \"$script\" | sed -E 's/\\.[a-z]+$//' | tr '_' '-')\"\n```\n\nScripts under `~/.claude/scripts/`\n\nget labeled from the basename with the extension stripped. For example, `daily-brief.sh`\n\nbecomes `com.shun.daily-brief`\n\n. Other, general-purpose commands (`find`\n\n, `backup-rotate`\n\n, and so on) secure uniqueness with command name + minute + hour. Underscores are converted to hyphens (launchd Label convention).\n\n**Phase 3: Assembling the StartCalendarInterval XML (lines 44–52)**\n\n```\ncal_xml=\"  <key>StartCalendarInterval</key>\\n  <dict>\\n\"\n# */N 周期は launchd では複数エントリで再現する必要 — ここでは固定値だけ対応\nif [ \"$minute\" != \"*\" ]; then cal_xml+=\"    <key>Minute</key><integer>${minute}</integer>\\n\"; fi\nif [ \"$hour\"   != \"*\" ]; then cal_xml+=\"    <key>Hour</key><integer>${hour}</integer>\\n\";   fi\nif [ \"$dom\"    != \"*\" ]; then cal_xml+=\"    <key>Day</key><integer>${dom}</integer>\\n\";      fi\nif [ \"$mon\"    != \"*\" ]; then cal_xml+=\"    <key>Month</key><integer>${mon}</integer>\\n\";    fi\nif [ \"$dow\"    != \"*\" ]; then cal_xml+=\"    <key>Weekday</key><integer>${dow}</integer>\\n\";  fi\ncal_xml+=\"  </dict>\"\n```\n\nIf a field is `*`\n\n(wildcard), the corresponding key is omitted from the XML — that's the semantics of launchd's `StartCalendarInterval`\n\n. For instance, `0 8 * * *`\n\n(8:00 every day) only needs `Hour=8, Minute=0`\n\n; omitting `Day/Month/Weekday`\n\nis what makes it \"every day.\"\n\n**Phase 4: Writing out the plist body (lines 54–76)**\n\n```\ncat > \"$plist\" <<XMLEOF\n<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\"\n  \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n<plist version=\"1.0\">\n<dict>\n  <key>Label</key>\n  <string>${label}</string>\n  <key>ProgramArguments</key>\n  <array>\n    <string>/bin/zsh</string>\n    <string>-c</string>\n    <string>${cmd//&/&amp;}</string>\n  </array>\n$(echo -e \"${cal_xml}\")\n  <key>StandardOutPath</key>\n  <string>${log}</string>\n  <key>StandardErrorPath</key>\n  <string>${log}</string>\n  <key>ProcessType</key>\n  <string>Background</string>\n</dict>\n</plist>\nXMLEOF\n```\n\nThe command is wrapped as `/bin/zsh -c \"cmd\"`\n\n. Commands that were running under cron often depend on shell expansion (`~`\n\nexpansion, globbing), and there are cases where passing them directly to `ProgramArguments`\n\ndoesn't work. Going through zsh absorbs that difference. `${cmd//&/&}`\n\nis XML escaping — a command containing `&`\n\nwould produce invalid XML, so it's substituted here. Logs send both stdout and stderr together to `~/.claude/logs/${label}.log`\n\n.\n\n**Phase 5: Deployment in apply mode (lines 84–96)**\n\n```\nif [ \"$MODE\" = \"apply\" ]; then\n  for f in \"$PROPOSED\"/*.plist; do\n    cp \"$f\" \"$TARGET_DIR/\"\n    launchctl unload \"$TARGET_DIR/$(basename $f)\" 2>/dev/null\n    launchctl load   \"$TARGET_DIR/$(basename $f)\"\n    echo \"  loaded: $(basename $f)\"\n  done\n  echo \"\"\n  echo \"🚨 cron 行は **手動で削除してください**:  crontab -e\"\n  echo \"(誤って cron+launchd 両方走るのを避けるため)\"\nfi\n```\n\n`launchctl unload`\n\nis called first for idempotency. Trying to load a plist that's already loaded results in an error. Unloading beforehand means running `apply`\n\nany number of times produces the same result. But **there's one caveat** — the script only prints a warning after apply saying \"please delete from crontab manually\"; it doesn't automate the deletion. Leave the cron lines in place and, whenever macOS eventually revives the cron daemon, you get **double execution from cron + launchd**.\n\n`set -uo pipefail`\n\n— Why `-e`\n\nWas Left Out\nThe declaration at the top of the script is `set -uo pipefail`\n\n(line 9 of the real file). Some of you may have noticed `-e`\n\n(exit immediately on error) isn't there. That's an intentional design decision.\n\nLook at the loop in `apply`\n\nmode (lines 84–96).\n\n```\nlaunchctl unload \"$TARGET_DIR/$(basename $f)\" 2>/dev/null\nlaunchctl load   \"$TARGET_DIR/$(basename $f)\"\n```\n\n`launchctl unload`\n\nreturns a non-zero exit code if the target plist isn't loaded yet. With `-e`\n\nenabled, the script dies on the very first unload of the first plist. `2>/dev/null`\n\nsuppresses the error output, but the exit code remains. Omitting `-e`\n\nis what delivers the idempotent behavior of \"keep the loop going even if unload fails.\"\n\nFor the same reason, `crontab -l 2>/dev/null`\n\n(line 22) is safe. In a user environment with an empty crontab, `crontab -l`\n\nexits non-zero with `crontab: no crontab for <username>`\n\n, but `2>/dev/null`\n\nswallows it and the loop proceeds. With `-e`\n\n, it would have died right there.\n\n** -u (error on undefined variables) and -o pipefail (propagating pipe failures) stay.** Those are guards you need — for catching variable name typos and failures partway through a pipe. Only\n\n`-e`\n\ngets in the way — and that judgment call is a recurring pattern in shell script error handling.Read the label generation logic on line 35 precisely and one important specification becomes visible.\n\n```\nscript=$(echo \"$cmd\" | grep -oE '~/.claude/scripts/[^ ]+' | head -1 | xargs basename 2>/dev/null)\n```\n\nNote that the regex **matches on the absolute path**, not `~/.claude/scripts/`\n\n. If the crontab entry was written as `~/.claude/scripts/daily-brief.sh`\n\n, this regex won't match, because `~`\n\nis recorded as a literal string before the shell expands it. If it doesn't match, the `script`\n\nvariable ends up empty and falls through to the fallback.\n\n```\nif [ -z \"$script\" ]; then\n  script=\"$(echo \"$cmd\" | awk '{print $1}' | xargs basename 2>/dev/null)-${minute}${hour}\"\nfi\n```\n\nThe fallback is \"basename of the command + minute + hour.\" For example, if you registered `~/.claude/scripts/daily-brief.sh`\n\nwith `0 8 * * *`\n\n, the label becomes `com.shun.daily-brief-08`\n\n. Not `daily-brief`\n\nbut `daily-brief-08`\n\n. That discrepancy breeds confusion later when you're chasing logs.\n\nAlways write absolute paths when registering in the crontab — that's the only correct way to coexist with this script.\n\n`*/N`\n\nFormat Breaks — Traced Through the Code\nLet's confirm the \"`*/N`\n\nunsupported\" point raised earlier through the actual code flow. Say the crontab has the line `*/15 * * * * ~/.claude/scripts/health-check.sh`\n\n. What happens?\n\n```\nminute=$(echo \"*/15 * * * * ~/.claude/scripts/health-check.sh\" | awk '{print $1}')\n# → \"*/15\"\n```\n\nThen the conditional:\n\n```\nif [ \"$minute\" != \"*\" ]; then\n  cal_xml+=\"    <key>Minute</key><integer>${minute}</integer>\\n\"\nfi\n```\n\n`\"*/15\" != \"*\"`\n\nis true, so it passes the condition, and the generated XML is:\n\n```\n<key>Minute</key><integer>*/15</integer>\n```\n\nThe string `*/15`\n\nends up inside an `<integer>`\n\ntag. It parses as XML, more or less, but when launchd loads the plist it gets rejected by the validation that \"Minute must be an integer from 0 to 59.\" `launchctl load`\n\nreturns a non-zero exit code, `loaded:`\n\nstill gets printed, but scheduling was never actually enabled.\n\nThis \"looks like the load went through but it isn't actually running\" state is nasty, and it shows up again in the next section.\n\n`/bin/zsh -c`\n\nWrapper\nThe ProgramArguments in the generated plist (lines 54–66):\n\n```\n<key>ProgramArguments</key>\n<array>\n  <string>/bin/zsh</string>\n  <string>-c</string>\n  <string>${cmd}</string>\n</array>\n```\n\nWrapping the command in zsh is there to get the `~`\n\nexpansion, environment variable references, and glob patterns that tend to appear in cron entries interpreted. Pass a command directly to `ProgramArguments`\n\nand execvp is called without shell expansion, so `~`\n\ngets passed through as a literal string and you get a file-not-found error.\n\nThat said, even with `/bin/zsh -c`\n\n, launchd does not read your `.zshrc`\n\n. That's launchd's design. It starts zsh in non-login script mode rather than interactive mode, so even if you've written `source ~/.zshrc`\n\n, it isn't loaded. As a result, processes start in a state where **node managed by nvm, python from pyenv, and the various Homebrew commands have no PATH to them**.\n\nLook at the generated plist template and there's no `EnvironmentVariables`\n\nkey (not anywhere across lines 54–76). That's exactly why `daily-brief.plist`\n\nhas `EnvironmentVariables`\n\nappended by hand. The plists the script auto-generates do not include this PATH injection.\n\n`*/15 * * * *`\n\nFailed Silently\n**Symptom.** Running `apply`\n\nprinted `loaded: com.shun.health-check.plist`\n\n. But 15 minutes later, and 30 minutes later, nothing was written to `~/.claude/logs/com.shun.health-check.log`\n\n.\n\n```\nlaunchctl list com.shun.health-check\n# → Could not find service \"com.shun.health-check\" in domain for port\n```\n\nA service that should be loaded doesn't exist in launchctl's list.\n\n**Cause.** `*/15`\n\nwas written straight into `<integer>*/15</integer>`\n\n, and launchd internally rejected the plist during validation. Because the `launchctl load`\n\ncommand itself returned exit code 0 (behavior on macOS Sequoia), the script's `echo \"loaded:\"`\n\nran anyway. With no error shown, the service simply didn't exist.\n\n**Fix.** Validating the plist with `plutil -lint ~/.claude/scripts/launchd-proposed/com.shun.health-check.plist`\n\nrejects it immediately. Lines containing `*/15`\n\nneed to be manually rewritten in the crontab before migrating. For every 15 minutes, either switch to launchd's `StartInterval`\n\n(interval specified in seconds), or write out the fixed values `00,15,30,45`\n\nas an array of four entries.\n\n```\n<key>StartCalendarInterval</key>\n<array>\n  <dict><key>Minute</key><integer>0</integer></dict>\n  <dict><key>Minute</key><integer>15</integer></dict>\n  <dict><key>Minute</key><integer>30</integer></dict>\n  <dict><key>Minute</key><integer>45</integer></dict>\n</array>\n```\n\nOr specifying seconds with `StartInterval`\n\nis simpler:\n\n```\n<key>StartInterval</key>\n<integer>900</integer>\n```\n\n900 seconds = 15 minutes. This form is outside the script's auto-generation scope, but it's a single hand-written spot.\n\n`~`\n\nPaths in the crontab Caused Label Collisions and Overwrote Old plists\n**Symptom.** Inside `~/.claude/scripts/launchd-proposed/`\n\n, which I was checking in `dry`\n\nmode, plists with unfamiliar label names had appeared. Names like `com.shun.daily-brief-08.plist`\n\nand `com.shun.note-publish-308.plist`\n\n— with a time appended to the end.\n\n**Cause.** Because the crontab was written with `~`\n\nas `~/.claude/scripts/daily-brief.sh`\n\n, it didn't hit the absolute-path match `~/.claude/scripts/[^ ]+`\n\non line 35 and fell into the fallback `command-name-minutehour`\n\nform. On top of that, the `com.shun.daily-brief.plist`\n\ngenerated by a previous `apply`\n\nwas still sitting in `~/Library/LaunchAgents/`\n\n, so **the old plist and the new plist existed in duplicate under different labels**.\n\nRunning `launchctl list | grep com.shun`\n\nshowed two entries calling the same script.\n\n**Fix.** Open the crontab with `crontab -e`\n\nand rewrite `~`\n\nas an absolute path. Then manually unload and delete the old-label plist in `~/Library/LaunchAgents/`\n\n.\n\n```\nlaunchctl unload ~/Library/LaunchAgents/com.shun.daily-brief-08.plist\nrm ~/Library/LaunchAgents/com.shun.daily-brief-08.plist\n```\n\nYou need the habit of always running `dry`\n\nbefore `apply`\n\nto visually confirm the generated labels and check they're in the expected `com.shun.<script-name>`\n\nform. If fallback-form names (trailing digits) are mixed in, suspect how the crontab is written.\n\n`command not found`\n\n**Symptom.** After `apply`\n\n, the same error kept appearing every time in `~/.claude/logs/com.shun.note-autolike.log`\n\n.\n\n```\n/bin/zsh: node: command not found\n```\n\nRunning the same command manually from the terminal works fine.\n\n**Cause.** The generated plist doesn't include `EnvironmentVariables`\n\n. Even started via `/bin/zsh -c`\n\n, `.zshrc`\n\nisn't read, and the `~/.nvm/versions/node/v24.13.0/bin`\n\nthat nvm adds isn't in PATH. Your terminal's shell session and processes under launchd management run in completely different PATH environments.\n\n**Fix.** Manually edit the generated plist and add `EnvironmentVariables`\n\nbefore `<key>ProgramArguments</key>`\n\n. `daily-brief.plist`\n\n(quoted from the real file) is the correct model:\n\n```\n<key>EnvironmentVariables</key>\n<dict>\n  <key>PATH</key>\n  <string>~/.nvm/versions/node/v24.13.0/bin:/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:~/.local/bin</string>\n</dict>\n```\n\nProperly, this block should be built into the script's generation template. But \"which node version to use\" varies by environment, and hardcoding it into the template means rewriting every plist when the environment changes. Perhaps the current script omits it deliberately to avoid that \"danger of pinning a version\" — at least, that's how I've interpreted it to make peace with it.\n\nIn actual operation, I always hand-add EnvironmentVariables to the plists of jobs that use node. The division of labor is: script generation \"builds 90% of the skeleton,\" and the remaining 10% — PATH injection — is manual.\n\n**Symptom.** note auto-posting was supposed to run twice a day, but the logs showed the posting API being called four times a day. It came to light when I hit the rate limit and error responses started appearing.\n\n**Cause.** I'd forgotten to delete the cron lines with `crontab -e`\n\nafter `apply`\n\n. I'd overlooked the warning at the end of the script (lines 94–95).\n\n```\n🚨 cron 行は **手動で削除してください**:  crontab -e\n(誤って cron+launchd 両方走るのを避けるため)\n```\n\nI'd convinced myself that \"cron lines are safe to leave\" because the cron daemon doesn't start in a macOS Sequoia environment. In reality, even on Sequoia there are moments when the cron daemon restarts (mainly after OS updates), and at that point both start running. This time, a macOS minor update was that moment.\n\n**Fix.** Check which lines have been migrated to launchd with `crontab -l`\n\nand either delete them all or comment out the migrated ones. The safest is `crontab -r`\n\n(delete everything), but if anything hasn't been migrated there's no way back, so I handled it with `crontab -e`\n\n, checking line by line.\n\nSince that failure, I run these two commands as a set to confirm `apply`\n\nis complete.\n\n```\n# launchd側の稼働確認\nlaunchctl list | grep com.shun\n\n# cron側の残骸確認（0行ならOK）\ncrontab -l 2>/dev/null | grep -vE '^\\s*#' | grep -v '^$' | wc -l\n```\n\nIf the second command returns 0, no active cron lines exist. That's my criterion for judging the migration complete.\n\n`dry`\n\nWas a Different File From the One `apply`\n\nDeployed\n**Symptom.** Eyeball the output in `dry`\n\n→ no problems → run `apply`\n\n→ and somehow the schedule has changed.\n\n**Cause.** Old plists from a previous `dry`\n\nwere still sitting in the `PROPOSED`\n\ndirectory (`~/.claude/scripts/launchd-proposed/`\n\n). This time's `dry`\n\ngenerated from a different set of cron lines, so updated plists and old plists were mixed together. Since `apply`\n\ndeploys all of `PROPOSED/*.plist`\n\n, unintended older-generation plists also got copied over into `~/Library/LaunchAgents/`\n\n.\n\n```\nfor f in \"$PROPOSED\"/*.plist; do\n  cp \"$f\" \"$TARGET_DIR/\"\n```\n\nThis copy-everything is the origin of the problem.\n\n**Fix.** Make it a habit to clear the `PROPOSED`\n\ndirectory before `dry`\n\n.\n\n```\nrm -f ~/.claude/scripts/launchd-proposed/*.plist\n~/.claude/scripts/cron-to-launchd.sh dry\n```\n\nOr check the diff between `PROPOSED`\n\nand `LaunchAgents`\n\nwith `diff`\n\nright before `apply`\n\n. Both are chores, but since there's no cleanup handling on the script side, for now manual discipline is the only way to cover it.\n\nTo sum up the sticking points so far: the only lines the script automates are the ones that are \"fixed schedule, absolute path, no PATH needed.\" The rest — `*/N`\n\nformat, `~`\n\npaths, nvm/pyenv dependencies — need manual pre- or post-processing. Had I understood that boundary up front, I could have prevented three of the four failures. It's more accurate to read the 97-line script not as something that \"fully automates cron migration,\" but as a tool that \"skips 80% of the manual work and throws the remaining 20% into relief.\"\n\nThe \"where I got stuck\" section above covered five episodes. Here I organize the pitfalls systematically so the same failures don't repeat. First let's confirm \"the scope the script can automate,\" then line up the easily-missed traps all at once.\n\n`cron-to-launchd.sh`\n\n(97 lines) only works correctly for cron lines that satisfy all of the following conditions.\n\n`*/N`\n\nformat`~`\n\nexpansion`&`\n\n, `<`\n\n, or `>`\n\nLines that fall outside these four conditions either break auto-generation or require mandatory manual fixes after generation. It's accurate to use it not as something that \"fully automates migrating every crontab line,\" but as \"a tool that builds 80% of the skeleton for lines meeting the four conditions and throws the remaining 20% of manual work into relief.\"\n\n**XML escaping only covers & — plists break on lines containing < and >**\n\nLook at line 65 of the script.\n\n```\n<string>${cmd//&/&amp;}</string>\n```\n\nIt converts `&`\n\nto `&`\n\n, but there's no conversion for `<`\n\n→ `<`\n\nor `>`\n\n→ `>`\n\n. If your crontab has a line with a redirect like `cmd > /dev/null 2>&1`\n\n, a `>`\n\ngets mixed into the `<string>`\n\ntag of the generated plist and the XML parser can't read the plist. `launchctl load`\n\nreturns an error, but since the apply loop moves on to the next plist, it's a structure where a single broken file is easy to miss. For lines containing `>`\n\nor `<`\n\n, either move the redirect inside the script before migrating, or hand-write the plist.\n\n**Generated plists have no RunAtLoad — you can't verify behavior right after apply**\n\nThe auto-generation template (all of lines 54–76) has no `RunAtLoad`\n\nkey. Meanwhile, lines 28–29 of the hand-finished `com.shun.daily-brief.plist`\n\nreal file contain `<key>RunAtLoad</key><true/>`\n\n.\n\nA plist without `RunAtLoad`\n\ndoesn't execute until the next scheduled time. Checking the log right after `apply`\n\nand finding nothing written isn't a malfunction — it's by design. The problem, though, is that you can't test \"does this actually work\" on the spot. When you want to check, use `launchctl kickstart`\n\n:\n\n```\nlaunchctl kickstart -k gui/$(id -u)/com.shun.xxx\ntail -f ~/.claude/logs/com.shun.xxx.log\n```\n\n`StartCalendarInterval`\n\nis a bare `<dict>`\n\n— multiple times require manual conversion to `<array>`\n\nThe cal_xml on lines 46–52 of the generation script is complete with a single `<dict>`\n\n. Expressing \"twice, at 8:00 and 10:30\" like `com.shun.daily-brief.plist`\n\n(lines 33–47 of the real file) requires an array, but the script doesn't generate arrays.\n\n``` php\n<!-- 自動生成物（単一時刻しか表現できない） -->\n<key>StartCalendarInterval</key>\n<dict>\n  <key>Hour</key><integer>8</integer>\n  <key>Minute</key><integer>0</integer>\n</dict>\n```\n\nIf you want to assign multiple times to the same script, manually rewrite the plist into array form after generation.\n\n**Registering the same script at multiple times in the crontab makes the later plist overwrite the earlier one**\n\nSuppose you want `daily-brief.sh`\n\nto run at 8:00 and 10:30, so you write two lines in the crontab.\n\n```\n0  8  * * * /path/to/.claude/scripts/daily-brief.sh\n30 10 * * * /path/to/.claude/scripts/daily-brief.sh\n```\n\nBecause label generation (line 40) strips the extension from the script name, both lines become `com.shun.daily-brief`\n\n. The plist filename is identically `com.shun.daily-brief.plist`\n\n. The line processed later (the 10:30 one) overwrites the earlier one (8:00), and the 8:00 setting disappears. There's no collision detection on the script side. Eyeballing the generated output in `dry`\n\nis the only recourse.\n\n**The */N format fails silently — launchctl load looks successful**\n\nThe crux of the episode detailed on p2, in one line. `*/15`\n\ngets written out as `<integer>*/15</integer>`\n\nand launchd rejects the plist during internal validation. The `launchctl load`\n\ncommand returns exit code 0 so it looks successful, but if `launchctl list com.shun.xxx`\n\ncan't find the service, it was rejected. Manually converting lines containing `*/N`\n\nbefore migration is the only solution.\n\n`~`\n\npaths fall into the label-generation fallback\n\nThe regex on line 35 only matches absolute paths. If you've written `~/.claude/scripts/note-autolike.sh`\n\n, the fallback (lines 36–39) kicks in and the label gets trailing digits, like `com.shun.note-autolike-308`\n\n. If a `com.shun.note-autolike.plist`\n\ngenerated earlier from an absolute path is still in `~/Library/LaunchAgents/`\n\n, you've created a double-execution state where two different labels call the same script. Always write absolute paths in the crontab.\n\n**Running apply without clearing PROPOSED mixes in old plists**\n\n`for f in \"$PROPOSED\"/*.plist`\n\non line 87 copies every file in PROPOSED indiscriminately. If a plist generated by a previous `dry`\n\nfor a cron line you've since deleted is still there, the job you thought you deleted comes back to life on `apply`\n\n. Make clearing with `rm -f ~/.claude/scripts/launchd-proposed/*.plist`\n\nbefore running `dry`\n\na habit.\n\n**Generated plists have no EnvironmentVariables — nvm, pyenv, and Homebrew commands die**\n\nThe generation template (lines 54–76) doesn't include the `EnvironmentVariables`\n\nkey. Since launchd doesn't read your `.zshrc`\n\n, a script calling nvm-managed node falls over immediately at startup with `node: command not found`\n\n. It works fine when run manually from the terminal but dies via launchd — that asymmetry makes diagnosis hard. Using the PATH string on lines 6–9 of `com.shun.daily-brief.plist`\n\nas your model, add it to every plist that uses node or python.\n\n**Generated plists have no LowPriorityIO or Nice — automation interferes with the foreground**\n\nLines 12–15 of `com.shun.daily-brief.plist`\n\nhave `LowPriorityIO`\n\nand `Nice 10`\n\n, but the generation template doesn't. Without the setting, background jobs run at normal I/O priority. If you've ever had a job doing heavy file reads and writes slow down your editor or browser's responsiveness, check whether these keys are present.\n\n**Forgetting to delete cron lines is a time bomb — the next OS update double-runs everything**\n\nAfter the script's apply (lines 94–95) it only warns \"please delete the cron lines manually\"; the deletion isn't automated. Since the cron daemon doesn't start on Sequoia, it's easy to think \"leaving them is safe,\" but there are real cases where a macOS minor update revives the cron daemon. My note auto-posting running four times a day and hitting the API rate limit came out of this failure. I prevent recurrence by including \"zero cron leftovers\" in the criteria for migration completion.\n\nA rule set distilled from a 97-line script and six months of operation, usable for both migration work and day-to-day operation.\n\n**1. Write crontab entries with absolute paths**\n\nWrite `/home/.../.claude/scripts/xxx.sh`\n\ninstead of `~/.claude/scripts/xxx.sh`\n\n. It matches the regex on line 35 and the label becomes the intended `com.shun.xxx`\n\n. Rewriting past cron lines takes effort, but it prevents three things at once: label collisions, double execution, and confusion from fallback naming after migration.\n\n**2. Manually convert the */N format before migrating**\n\n`*/15 * * * *`\n\n(every 15 minutes) converts to one of two things. If the interval is fixed, `StartInterval`\n\n(in seconds) is simplest.\n\n``` php\n<key>StartInterval</key>\n<integer>900</integer>  <!-- 900秒 = 15分 -->\n```\n\nIf you need execution at specific minutes, enumerate fixed values in an array (minutes 0, 15, 30, 45). Missed conversions can be caught with `plutil -lint`\n\n.\n\n**3. Clear the PROPOSED directory before dry**\n\n```\nrm -f ~/.claude/scripts/launchd-proposed/*.plist\n~/.claude/scripts/cron-to-launchd.sh dry\n```\n\nRunning these two lines as a set prevents the problem of older-generation plists getting mixed into `apply`\n\n.\n\n**4. Validate every plist with plutil -lint after dry, before apply**\n\n```\nfor f in ~/.claude/scripts/launchd-proposed/*.plist; do\n  echo \"--- $(basename $f)\"\n  plutil -lint \"$f\"\ndone\n```\n\nCatch `*/N`\n\ncontamination, missed XML escaping, and syntax errors up front with Apple's official tool. Don't `apply`\n\nany plist for which `plutil -lint`\n\ndoesn't return `OK`\n\n.\n\n**5. Confirm completion with two commands right after apply**\n\n```\n# launchd側の稼働確認\nlaunchctl list | grep com.shun\n\n# cron残骸確認（0ならOK）\ncrontab -l 2>/dev/null | grep -vE '^\\s*#' | grep -v '^$' | wc -l\n```\n\nIf the second line returns `0`\n\nand the launchd entry count matches the number of lines targeted for migration, you can judge the migration complete.\n\n**6. Manually add EnvironmentVariables to the plists of jobs that use node**\n\nInsert it immediately before `<key>ProgramArguments</key>`\n\nright after generation:\n\n```\n<key>EnvironmentVariables</key>\n<dict>\n  <key>PATH</key>\n  <string>~/.nvm/versions/node/v24.13.0/bin:/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:~/.local/bin</string>\n</dict>\n```\n\nMatch the nvm version number to your actual environment. Lines 6–9 of `com.shun.daily-brief.plist`\n\nare the model.\n\n**7. Rewrite StartCalendarInterval as an array for multi-time plists**\n\nIf you want to run the same script at two times, use an array in a single plist (don't write two crontab lines and cause a label collision).\n\n```\n<key>StartCalendarInterval</key>\n<array>\n  <dict><key>Hour</key><integer>8</integer><key>Minute</key><integer>0</integer></dict>\n  <dict><key>Hour</key><integer>10</integer><key>Minute</key><integer>30</integer></dict>\n</array>\n```\n\nThe description on lines 33–47 of `com.shun.daily-brief.plist`\n\nis a live example.\n\n**8. Set LowPriorityIO and Nice 10 on background jobs generally**\n\nAdd it to every generated plist so it doesn't get in the way of your work:\n\n```\n<key>LowPriorityIO</key><true/>\n<key>Nice</key><integer>10</integer>\n```\n\nBy having the automation environment \"erase its presence,\" you can design it so it doesn't encroach on human working territory.\n\n**9. Hand-write plists for lines whose commands contain &, <, or >**\n\nDon't rely on generation; do the XML escaping accurately:\n\n`&`\n\n→ `&`\n\n`<`\n\n→ `<`\n\n`>`\n\n→ `>`\n\nMoving redirects inside the shell script being called is cleanest. Try to handle redirects within the plist's XML and you'll almost always hit this escaping problem.\n\n**10. Periodically check LastExitStatus with launchctl list com.shun.xxx**\n\n```\nlaunchctl list com.shun.daily-brief\n```\n\n`\"LastExitStatus\" = 0`\n\nis healthy. Anything other than 0, check the log. Weekly bulk check:\n\n```\nlaunchctl list | grep com.shun | awk '{print $3}' | \\\n  xargs -I{} sh -c 'launchctl list \"{}\" 2>/dev/null' | \\\n  grep -E '\"Label\"|\"LastExitStatus\"'\n```\n\n**11. Debug with on-demand execution via launchctl kickstart**\n\nWhen you want immediate execution without waiting for the scheduled time:\n\n```\nlaunchctl kickstart -k gui/$(id -u)/com.shun.xxx\n```\n\n`-k`\n\nis an idempotent option that kills the running instance and restarts it. If nothing appears in the log, it's a PATH problem or a script path problem.\n\n**12. Verify every service is alive after a macOS update**\n\nMinor updates can change launchd's behavior. If the daily brief doesn't arrive the morning after an update, hit `launchctl list | grep com.shun`\n\nfirst. If a service is gone, re-`apply`\n\nbrings it back.\n\n**13. Define three \"completion conditions\" for the migration**\n\nWhen the \"end\" of migration work is vague, you tend to skip verification. I set the following as completion conditions:\n\n`launchctl list | grep com.shun`\n\nmatches the number of cron lines targeted for migration`crontab -l 2>/dev/null | grep -vE '^\\s*#' | grep -v '^$' | wc -l`\n\nreturns `0`\n\n`LastExitStatus`\n\nis `0`\n\n, at least after its first runOnly when all three are satisfied can you say \"migration complete.\"\n\n**14. Estimate the total time for the migration work up front**\n\nCount the number of cron lines, how many contain the `*/N`\n\nformat, and how many jobs depend on nvm before you start. With ten or fewer, the whole sequence of `dry`\n\n→ `plutil`\n\nvalidation → manual fixes → `apply`\n\n→ completion check finishes within 90 minutes. With 30 or more, a split strategy is realistic: auto-migrate the lines meeting the four conditions first, then hand-migrate the rest on later days.\n\nThe problem of macOS's cron daemon quietly stopping is slow to discover precisely because no error appears. Entries are lined up in `crontab -l`\n\n, yet the 8:00 daily brief doesn't arrive and the 11:00 social post doesn't go out — and it takes hours before that odd feeling registers. It's more accurate to frame migrating to launchd not as \"dealing with it after cron breaks,\" but as \"an up-front investment in getting back onto macOS's native mechanism.\"\n\nWhat the 97-line `cron-to-launchd.sh`\n\ndoes is simple. Read the crontab line by line, convert five fields into XML, write it out as a plist. In three steps — dry → plutil validation → apply — you can mass-produce skeletons for lines that are fixed-schedule, absolute-path, and PATH-free. But it's not \"fully automatic magic.\" The `*/N`\n\nformat, `~`\n\npaths, nvm/pyenv dependencies, multiple times, characters requiring XML escaping — these need manual pre- or post-processing. By having the script \"build 90% of the skeleton,\" the target of the manual work becomes clear. Understanding that structure and using it accordingly is the shortest path to not getting stuck after migration.\n\nFor jobs you've finished moving to launchd, you can check state instantly with `launchctl list com.shun.xxx`\n\n. `LastExitStatus`\n\nbeing 0 proves \"it is running,\" not \"it should be running.\" The reliability of an autonomous environment accumulates by eliminating the discovery that \"I thought it was running, but it had stopped.\"\n\nI've written up the full picture of the setup, the ¥1.2M/month breakdown, and the 30-day procedure in a paid note.\n\n📕 [Claude Code自律環境で、実際どう稼ぐか ― 仕組み・実例・始め方・サポート](https://note.com/bokuwalily/n/n849b3a07784a)\n\n*Written by **Lily** — I ship iOS apps and automate my content stack with Claude Code.\n\nFollow along: [Portfolio](https://bokuwalily.com) · [X](https://x.com/bokuwalily) · [GitHub](https://github.com/bokuwalily)*", "url": "https://wpnews.pro/news/the-morning-my-cron-jobs-went-silent-a-97-line-script-that-migrated-everything", "canonical_source": "https://dev.to/bokuwalily/the-morning-my-cron-jobs-went-silent-a-97-line-script-that-migrated-everything-to-launchd-4aia", "published_at": "2026-09-01 00:00:04+00:00", "updated_at": "2026-09-01 00:22:40.944116+00:00", "lang": "en", "topics": ["developer-tools"], "entities": ["Apple", "macOS Sequoia", "launchd", "cron", "Claude Code"], "alternates": {"html": "https://wpnews.pro/news/the-morning-my-cron-jobs-went-silent-a-97-line-script-that-migrated-everything", "markdown": "https://wpnews.pro/news/the-morning-my-cron-jobs-went-silent-a-97-line-script-that-migrated-everything.md", "text": "https://wpnews.pro/news/the-morning-my-cron-jobs-went-silent-a-97-line-script-that-migrated-everything.txt", "jsonld": "https://wpnews.pro/news/the-morning-my-cron-jobs-went-silent-a-97-line-script-that-migrated-everything.jsonld"}}