macOS: zsh and launchd

As of version 10.15 (Catalina) macOS changed the default shell to zsh (Z Shell), prior using bash (Bourne again Shell). While bash is still available in macOS as of now, it is recommended to zsh, including for shell scripts.

A basic shell script

Shell scripts use the *.sh suffix. In order to execute shell scripts directly, like

$ my_first_script.sh

instead of requiring to pass it as an argument to a shell call like

$ zsh ./my_first_script.sh

a so called shebang line is required in your script as the first line. This declares which type of shell is used when the script is called directly. It is recommended to use this shebang line on macOS:

1#!/usr/bin/env zsh -l

Hint

The env tool acts as a pointer towards the shell binary file. Using it makes the script less system dependent. Passing in the zsh argument defines, that we want the path to the zsh shell (here /bin/zsh).

The -l option defines, that we want a shell with the current user being logged into it. This executes the user specific profile files like /etc/zprofile or ~/.zshrc (more info), which feed to the PATH variable, which otherwise contains no paths to user-specific executable directories, such as /usr/local/bin. In the shell script this enables us to use commands directly, without having to use their absolute paths:

# use binary commands directly
match=$(pcregrep -o1 $update_version_regex $tmp_file)
say "This worked just fine"

# instead of requiring its absolute path
match=$(/usr/local/bin/pcregrep -o1 $update_version_regex $tmp_file)
/usr/bin/say "This worked as well, but is longer and system dependent"

macOS uses a tool called path_helper to feed some entries to the PATH variable. It is called from /etc/zprofile by default.

Next up you may define your shell script. Here are some useful resources:

Make sure to make your script executable (for security reasons this is not done by default):

$ chmod 755 my_first_script.sh

launchd: Script management

If you created a script and wish to execute regularly or on specific occasion (e.g. after each log-in), macOS provides the launchd tool, which manages services and daemons, similar to Linux’ systemd. Similar to cron on Linux (or the Task Scheduler on Windows) it offers the possibility to schedule executions of shell scripts.

Hint

There are tools which help generating *.plist files (although you might still need to do some manual tweaking afterwards), such as:

Launchd tasks (agents and daemons) are defined in *.plist config files, which follows the XML syntax. For our script we create a new file in ~/Library/LaunchAgents (the user agent directory):

$ touch ~/Library/LaunchAgents/my_first_script.plist

Add in the following content:

 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>MY_SCRIPT_NAME</string>
 7        <key>ProgramArguments</key>
 8        <array>
 9            <string>/bin/zsh</string>
10            <string>-c</string>
11            <string>/PATH/TO/MY/SCRIPT.sh</string>
12        </array>
13        <key>StartInterval</key>
14        <integer>3600</integer>
15        <key>StandardOutPath</key>
16        <string>~/tmp/MY_SCRIPT_NAME.stdout.log</string>
17        <key>StandardErrorPath</key>
18        <string>~/tmp/MY_SCRIPT_NAME.stderr.log</string>
19        <key>AbandonProcessGroup</key>
20        <true/>
21    </dict>
22</plist>

Now go ahead and replace MY_SCRIPT_NAME and PATH/TO/MY/SCRIPT.sh to match your script. Also edit the StartInterval time if needed (above set to 3600 seconds, which is one execution per hour). To run the script only once on login replace

13<key>StartInterval</key>
14<integer>3600</integer>

with

13<key>RunAtLoad</key>
14<true/>

For more information on the plist options, check https://www.launchd.info/.

After you finished configuration file, add it to the launchd controller:

$ launchctl load ~/Library/LaunchAgents/my_first_script.plist

To check the status of the last execution run (replace MY_SCRIPT_NAME with the label you defined in your *.plist file)

$ launchctl list | grep MY_SCRIPT_NAME

which lists the return code of your script from the last execution, which should be 0, if the script ran successfully. Check the stderr.log and stdout.log files which you defined in the *.plist config file for clues.

Hint

The return code is always 0 before the script was executed once after loading it. For debugging, change to a low StartInterval value to trigger an execution.

To remove the script from the launcher run

$ launchctl unload ~/Library/LaunchAgents/my_first_script.plist

Hint

Changes to the shell script are adapted and used at the next execution, but changes to the *.plist require a reload (unload then again load) to the launch controller.