On env Shebangs

There was a comment to my previous post about using the /usr/bin/env shebang to gain system portability.

Regarding shebang, a more system independent way would be to use e.g. ‘#!/usr/bin/env bash’. With this the script would also be usable on FreeBSD, where bash would be installed from FreeBSD Ports and then be available as ‘/usr/local/bin/bash’ or some Linux systems (e.g. Debian) where it is ‘/usr/bin/bash’. Unfortunately there are some other unixode systems around, where ‘env’ is not in ‘/usr/bin/’ and so the shebang needs to be adjusted.

(I replied to the comment there, but then realized this deserves its own post.)

The /usr/bin/env shebang provides a means for system portability. It has many valid use cases. However, you don’t just magically gain portability by switching to an env shebang. There are many trade-offs to consider.

The note on how the env binary may not be in /usr/bin on all platforms, hints at some of these trade-offs, but there are more.

The trade-offs are a loss of predictability and reliability, or functionality, or increased maintenance and management.

Let me elaborate…

How the /usr/bin/env shebang works

When used in the shebang, the /usr/bin/env binary will use the current environment’s PATH to lookup the interpreter binary for the script, in the same way the shell looks up commands.

As an example, let us imagine you have installed bash v5 on your Mac. Either manually or using brew or some other package management system.

This will (usually) put the bash v5 binary at /usr/local/bin/bash. The /usr/local/bin directory is a common choice for custom command line tools, because it is part of the default PATH for interactive shells on macOS and not protected by SIP/SSV. The default PATH on macOS for interactive shells is:

/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin

Some installations will put the binary in a different location in the file system. Then you you have to pre-pend the directory containing the binary to your PATH variable in your shell configuration. The order of the directories in the PATH is important, because the shell and env will stop the search when they find the first match. If /usr/local/bin came after /bin in the PATH the new binary would not be ‘seen’ since the pre-installed, old /bin/bash binary is found first.

Some installations solve this by placing a symbolic link to the binary in /usr/local/bin.

When you run a script from the interactive shell with a shebang of #!/usr/bin/env bash, then env would find the bash v5 binary first in /usr/local/bin, so your script is interpreted with bash v5. This is probably what you were hoping for, when you installed bash v5 in the first place.

When you run the same script on a different Mac (same macOS version, but it doesn’t have bash v5 installed) env will pick up /bin/bash. Your script will work even though that other Mac doesn’t have /usr/local/bin/bash, so you gained portability.

However, /bin/bash is bash v3.2, so your script may behave differently. If the script uses bash v5 features that are not available in the 15-year-old bash v3.2, it will generate errors. Since you actively chose to install bash v5 on the first Mac, it is likely you needed some of these bash v5 features, so it is likely your script will fail on other Macs, which don’t have bash v5 installed.

You lost either predictability and reliability (which version and features are available? Does my script run successfully?), or you lose functionality (the features added to bash v5 since v3.2). To retain reliability, you can restrict the script to features that work in both bash versions. But then using the env shebang gives you no advantage, and you might as well use /bin/bash as the shebang.

Some solutions

One alternative is to use a /usr/local/bin/bash shebang for scripts which use bash v5 functionality and continue to use /bin/bash for scripts that need to run across multiple Macs, where you pay attention to using only features available in bash v3.2. You gain predictability and reliability, but your bash v5 scripts aren’t portable to other Macs. They may even fail on other Macs with bash v5 installed, if the bash v5 binary is installed in a different location.

When you use /usr/bin/env bash for a bash v5 script, it will run fine on all Macs which have bash v5 installed and the PATH configured properly to find it. (Configuring and maintaining the PATH does not happen on its own.) But the script will still fail on Macs without any bash v5. You can (and probably should) add a version check to the script, but now you are increasing code maintenance.

When you are managing a fleet of Macs, you also have the option (or in this case, I would say, the duty) to install bash v5 in a consistent location and version across all Macs in your fleet and pre-configure the proper PATH in the contexts the script will run in. Then you get predictability and functionality, but it requires extra effort in deployment and maintenance.

This requires a decently experienced MacAdmin and the proper tooling, neither of which comes for free.

Note: There are great open source solutions for macOS in this area, but I consider them ‘free, as in puppy,’ so they come with higher skill requirements and/or maintenance effort for the admin. And this isn’t supposed to imply that all commercial solutions are ‘easy to use,’ either. It’s trade-offs all the way down.

Context changes the PATH

Notice that so far I kept talking about “the default PATH for the interactive shell.”

The PATH variable may be different depending on the context, even on the same Mac with the same user. For example, when you run your script with the AppleScript do shell script command, the PATH in that context is not the same as the PATH in your interactive shell. It will be:

/usr/bin:/bin:/usr/sbin:/sbin

You can verify this by opening Script Editor and running the do shell script "echo $PATH". Other context, like scripts in installation packages will see other PATH values.

Most importantly, the PATH in these other contexts, does not contain /usr/local/bin, or any other addition you made to your PATH in the shell configuration files. An /usr/bin/env shebang will not ‘see’ a bash 5 binary you installed on the system. The same script with the same user on the same computer, will behave differently when run in a different context.

These different PATH values are an intentional choice. In these contexts, especially installation package scripts, reliability and predictability are extremely important. You do not want user and third-party installed custom binaries to interfere with the command lookup.

Sidenote on Python

With python and python3 and other run time interpreters, it gets even more difficult. There may multiple different versions installed and the behavior and functionality between versions varies more. My Mac currently has four different Python 3 binaries, each with a different version, and I not even remotely a full-time Python developer. When you call python3 on a non-developer Mac it will trigger the ‘You have to install Developer Command Line Tools’ dialog when Xcode is not installed. (Developers seem to have a hard time considering that there are Macs without Xcode installed.)

With the demise of python 2 in macOS 12.3, some developers reacted by changing the shebang in their python scripts from /usr/bin/python to /usr/bin/env python which solves nothing, when the binary goes away without replacement. Some switched to /usr/bin/env python3 which can makes things worse, by triggering the Developer Tools installation or picking a random python3 binary of the wrong version.

The only reliable solution for the mess that is python is to deploy a consistent version of Python 3 in a consistent location. You can do this by either bundling the python framework and module your tool needs together with the tool, or by deploying and maintaining the Python frameworks and modules with a management system.

MacAdmin perspective

As a MacAdmin, my scripts don’t need to be portable to systems other than macOS. They usually use tools and access files and folders that only exist on macOS. Predictability and reliability, however, are paramount. Configuration and installation scripts need to run reliably on thousands of Macs across multiple versions of macOS (even future versions) regardless of what else is installed.

As MacAdmins, we also (should) have the tools and experience to deploy and maintain binaries in predictable locations. But then, like everyone, we only have limited time, and often need to prioritize business critical software over our own tooling. So, the pre-installed interpreter binaries have little ‘friction’ to use, even if they may have a reduced functionality when compared to the latest version available elsewhere.

This is the reason bash v3.2 is still present on macOS 12.3 and it will never be easy when Apple ultimately decides to remove it. So many tools and scripts rely on /bin/bash.

(I don’t expect the removal to be any time soon, but there is a limit to how long Apple will or can keep this interpreter from 2007 on the system. We got the first warning when Apple switched the default interactive shell to zsh in Catalina. There will be more warnings …I hope. With the removal of the Python 2 binary we saw that Apple can move quickly when they feel the need. They did not even wait for a major macOS release.)

In this context, there is no gain in using /usr/bin/env. The trade-offs favor the absolute shebang very strongly.

Cross-platform portability

After this rant, you may think that I recommend against using /usr/bin/env shebangs always. But there are very good use cases. Using /usr/bin/env shebangs is the solution for workflows where cross-platform portability is required.

When your scripts need to run across multiple platforms, installing the binaries in the same location in the file system may not be possible, or require an unreasonable effort. For example, the /bin and /usr/bin are protected by SIP and the Sealed System Volume on macOS, so you cannot add your tooling there without significantly impacting the integrity and security of the entire system.

In these cases, using a /usr/bin/env shebang provides the required flexibility, so your scripts can work across platforms. But the portability does not come magically from just switching the shebang.

The target platforms need the binary to be installed and the versions should match. The installation location of the binary has to be present in the PATH in the context the script runs in. To get reliable behavior, the systems you are porting between need to be well managed, with a predictable setup and configuration of the interpreter binary and environment.

When your scripts work ‘effortlessly’ across systems with the env shebang, it is thanks to the work of the platform developers and your sysadmins/devops team for creating and maintaining this consistency. Even if you are the sole developer and devops admin, maintaining all the systems, you have to put in this work. Also the platform developers put in a lot of effort to achieve much of this consistency out of the box. As the commenter noted, some platforms don’t even agree where the env binary should be.

You gain portability at the price of increased maintenance.

Trade-offs all the way down

Alternatively, you can keep the scripts simple – restricted to a subset of features common across platforms and versions – so that the differences have no impact. Then you trade for reliability and portability at the price of functionality.

This is often the trade-off with Python (restrict the use of python features to those common among versions) and one of the reasons Python 2 was kept around for so long on macOS.

Using POSIX sh, instead of bash or zsh, is another option to gain portability, but that has its own trade-offs. Most of these trade-offs will be in functionality, but also consider not all sh emulations are equal, and supporting multiple different emulators or the real common subset of functionality, requires extra effort.

Conclusion

Shebangs with absolute paths have their strengths and weaknesses, as do shebangs with /usr/bin/env. Each has their use case and you have to understand the trade-offs when using either. Neither env shebangs nor absolute path shebangs are generally ‘better.’ Either one may get you in trouble when used in the wrong context.

When someone says ‘you should be using,’ or ‘a better way would be,’ you always need to consider their context, use case, and which trade-offs they are accepting. You need to understand the tools to use them efficiently.

Some CLI updates in macOS Monterey

The other day on Twitter, I got a question about a flag for the readlink command that I was not familiar with. As it turns out, the readlink command (which tells you where a symbolic link points to) got an update in macOS 12.3 and now has a -f option. With this new option, readlink will resolve symbolic links anywhere in the path and print the ‘actual’ absolute path to the item. This is equivalent to the realpath command available on Linux and some programming languages.

I have written about this before, and then I mentioned that there is a python function to resolve the path. However, even back then I anticipated the removal of python and suggested using a zsh parameter expansion modifier instead:

resolved_path=${path_var:A}

The removal of Python 2 is the likely explanation for why Apple chose to update readlink in 12.3.

It will be nice to have the new readlink -f option available going forward, but if your script still needs to support versions of macOS older than 12.3 then you should prefer to use the zsh expansion modifier.

More Monterey Command Line Changes?

This was discovered mostly by chance. While Apple’s release notes are improving, there are still nowhere near detailed enough and missing this level of detail, even though that would be amazingly useful.

I remembered that the Kaleidoscope app team had posted a script that allows me to compare man pages between versions of macOS. They published this back when macOS 12 was released to track the changes of the plutil command line tool. With the help of this tool I determined a few more interested changes in macOS 12, the most interesting of which I will summarize here.

(Even with this script, the process was tedious. Many changes to the man pages are just reformatting whitespace and/or typos. I may have missed something. Please, let me know when you find more changes!)

cut

  • new -w option (splits fields on whitespace)

du

  • new -A option (apparent size)
  • new --si option (human-readable, in 1000 based units)
  • new -t option (only show items over a certain threshold)

aa (Apple Archive)

  • new options for encryption
  • new aea command for encrypted Apple Archives

tar

  • new encryption and compression types

find

  • new -quit primary
  • new -sparse primary (so you can find APFS sparse files)

grep

  • new rgrep, bzgrep, bzegrep, and bzfgrep
  • new --label option
  • new -M, --lzma option

hdituil

  • segment subcommand and Segmented images are deprecated
  • UDBZ dmg format (bzip2 compression) is deprecated
  • udifrez and udifderez are deprecated (this allows to embed a license in a dmg)

head

  • new -n and -c options (probably just the man page updated)

killall

  • new -I option (confirm)
  • new -v option (verbose)

ls

open

  • new -u option to open file paths as URLs

pkgbuild

  • new --large-payload option
  • new --compression option
  • new --min-os-version option

I have an article on the new pkgbuild options.

plutil

  • new -type option for extract
  • new -raw option for extract
  • new type subcommand to query type
  • new create subcommand to create a new empty plist

pwd

  • new -p option prints working directory with symbolic links resolved

readlink (12.3)

  • new -f option to resolve symbolic links

rm (12.3)

  • new -I option which prompts only when more than three files will be deleted or a directory is being removed recursively

shortcuts

  • new command to run, list, or interact with Shortcuts

smbutil

  • new multichannel and snapshot verbs

Also, the nano command now actually opens pico. (Thanks, @rgov) Most people won’t notice this, as the two are quite similar. The excision of GNU tools from macOS continues.

Scripting macOS, part 7: Download and Install Firefox

This series is an excerpt from the first chapter of my upcoming book “Scripting macOS” which will teach you to use and create shell scripts on macOS.

I will publish one part every week. Enjoy!

Follow this blog or the Twitter account for updates on the book’s progress!

Download and Install Firefox

To further illustrate the progress from the idea of a workflow to a working script, let us look at another, more involved example.

To download and install the latest version of Firefox a user has to go to the Firefox website and download the latest version, which will come as a disk image (dmg) file. Then the user needs locate the dmg in the ~/Downloads folder and open it to mount the virtual disk image. Finally, they need to copy the Firefox application from the virtual disk to the Applications folder.

When we want to automate the task ‘Download and Install Firefox,’ we have the following steps:

  • download latest Firefox disk image
  • mount downloaded disk image
  • copy Firefox application to /Applications
  • unmount disk image

From this list of steps, we can build the first ‘frame’ of our script:

#!/bin/zsh

# Download Firefox
#
# downloads and installs the latest version of Firefox


# download latest Firefox disk image

# mount downloaded disk image

# copy Firefox application to /Applications

# unmount disk image

This breaks the workflow into smaller pieces, that we will now tackle individually.

Download from the Command Line

You can use the curl command to download data in the command line. The curl command is very complex and has many options. We will only discuss the few options that we require for our task here. As always, you can find a detailed description of the curl command and its options in the curl man page.

The URI to download the latest Firefox is
https://download.mozilla.org/?product=firefox-latest-ssl&os=osx&lang=en-US

However, when you try to curl this URI, you only get the following:

> curl "https://download.mozilla.org/?product=firefox-latest-ssl&os=osx&lang=en-US"
<a href="https://download-installer.cdn.mozilla.net/pub/firefox/releases/86.0.1/mac/en-US/Firefox%2086.0.1.dmg">Found</a>.

This is a re-direction, that is commonly used to have a single URI, that is redirected to different final URIs, so that when the software updates, the same URI always returns the latest version.

We can tell curl to follow these redirections with the --location option.

By default, the curl command will output the download to standard out. To save the download to a file, we can use the --output option with a file name.

> curl --location "https://download.mozilla.org/?product=firefox-latest-ssl&os=osx&lang=en-US" --output Firefox.dmg

This command will download the latest Firefox disk image to a file named Firefox.dmg in your current working directory. We can use this as our first step:

#!/bin/zsh

# Download Firefox
#
# downloads and installs the latest version of Firefox


# download latest Firefox disk image
curl --location "https://download.mozilla.org/?product=firefox-latest-ssl&os=osx&lang=en-US" \
     --output Firefox.dmg

# mount downloaded disk image

# copy Firefox application to /Applications

# unmount disk image

Note: Like many other command line tools, curl has short and long options. The short options for –location and –output are -L and -o.
Short options are convenient in the interactive shell, as they save typing and reduce the potential for typos. But they are much less readable, and you usually have to look up their function in the documentation. For that reason, I recommend using the long, descriptive options in scripts.

Working with Disk Images

The command line tool to work with disk image (dmg) files on macOS is hdiutil. This is also a very powerful command with many verbs and options. You can find all the detail in the hdiutil man page.

To mount a disk image, use the attach verb:

> hdituil attach Firefox.dmg

This will output some information and mount the virtual disk. The last line ends with the path to the mounted virtual disk /Volumes/Firefox.

By default, you can see the mounted volume in Finder. We do not really need the disk image to appear in Finder while the script is running. We can suppress this behavior with the -nobrowse option.

Since we are only going to read from the disk image, we can tell hdiutil to mount the dmg in readonly mode with the -readonly option. This speeds things up a bit.

> hdiutil attach Firefox.dmg -nobrowse -readonly

You can unmount or eject the virtual disk with

> hdiutil detach -force /Volumes/Firefox

The -force option will unmount the disk image, even when another process is still using it.

Thehdiutil command covers two of our steps, so we can fill them in:

#!/bin/zsh

# Download Firefox
#
# downloads and installs the latest version of Firefox


# download latest Firefox disk image
curl --location "https://download.mozilla.org/?product=firefox-latest-ssl&os=osx&lang=en-US" \
     --output Firefox.dmg

# mount downloaded disk image
hdiutil attach Firefox.dmg -nobrowse -readonly

# copy Firefox application to /Applications

# unmount disk image
hdiutil detach /Volumes/Firefox -force

Copying the Application

When you manually install Firefox the disk image shows you a nice graphic that reminds you to drag the app to the Applications folder. Once the disk image is mounted, the cp command can be used to do this in the shell:

> cp -R /Volumes/Firefox/Firefox.app /Applications/

This provides the last missing step in our script:

#!/bin/zsh

# Download Firefox
#
# downloads and installs the latest version of Firefox


# download latest Firefox disk image
curl --location "https://download.mozilla.org/?product=firefox-latest-ssl&os=osx&lang=en-US" \
     --output Firefox.dmg

# mount downloaded disk image
hdiutil attach Firefox.dmg -nobrowse -readonly

# copy Firefox application to /Applications
echo "copying Firefox to /Applications"
cp -R /Volumes/Firefox/Firefox.app /Applications/

# unmount disk image
hdiutil detach /Volumes/Firefox/ -force

You can now test the script. If Firefox is running, you want to quit it before you run the script. You may also want to delete the existing copy of Firefox from the Applications folder, to be sure that your script is doing the work.

Lists of Commands—Conclusion

We have been able to automate a fairly complex workflow with a script of four commands.

To be perfectly honest, this script (as well as all the others we have built so far) is not complete yet.

A ‘proper’ script needs to be able to react to errors that occur. In our example, imagine the download fails. The script should be able to detect the failure before it overwrites the installed, functional Firefox application.

We will get to this kind of error handling later.

Nevertheless, this script is already useful in its current form. You can try to adapt this script to work with some other software you can download as a disk image.

You can also add extra commands that

  • delete the downloaded disk image at the end
  • open the newly installed Firefox app after installation
  • quit or kill the Firefox process before copying the new version

In the book “Scripting macOS”, you will learn more scripting techniques, and we will re-visit some of these sample scripts and keep improving them.

Follow this blog or the Twitter account for updates on the book’s progress!

Note: After using different variations of these kinds of workflows, I did put together a more generic script to download and install various kinds of software, called ‘Installomator.’ You can see the script at its open source repository on GitHub.

Scripting macOS, part 6: Turn it off and on again

This series is an excerpt from the first chapter of my upcoming book “Scripting macOS” which will teach you to use and create shell scripts on macOS.

I will publish one part every week. Enjoy!

Turn it off an on again

A common strategy to solve problems with computers is ‘turning it off and on again.’ For example, when you have Wi-Fi connectivity problems, one of the first things you should try it to turn the Wi-Fi off, wait a few seconds, and then turn it on again.

Let’s write a script to do that.

Create the ‘frame’

When you start out writing a script, you will usually have only a vague idea of what needs to be done, and even less an idea of how to actually do it. In this case is helps to write out the steps that your script should perform:

  • turn off Wi-Fi
  • wait a few seconds
  • turn on Wi-Fi

With this list we have broken down the problem into smaller, more manageable steps, which we can each solve on their own.

I usually take this list of steps and build a script ‘frame’ from them:

#!/bin/zsh

# Reset Wi-Fi
# by turning it off and on again


# turn off Wi-Fi

# wait for a few seconds

# turn on Wi-Fi

We just copied our list of steps into the text file for our script and made them comments by adding the # character at the beginning of the line. We also added the shebang in the first line and two more comment lines at the beginning, which name the script and have short description on what it is supposed to do.

Since the script in this form only consists of the shebang and comments, it does nothing. But it provides a frame to fill in. Now we can tackle these steps one at a time.

Control Wi-Fi

When you want to control network related settings on macOS, the networksetup command is the first place you should look. The networksetup command allows you to configure the settings in the ‘Network’ pane in ‘System Preferences’— and a few more. The networksetup command has dozens of options. You can get a list and descriptions by running networksetup -help or in the networksetup man page.

To turn off Wi-Fi, you can use this option:

> networksetup -setairportpower <hwport> off

The value you need to use for <hwport> depends on what kind of Mac you are working on. For MacBooks, it will be en0 (unless you have a very old MacBook with a built-in ethernet port). For Macs with a single built-in ethernet port, it will be en1. For Macs with two built-in ethernet ports it will be en2.

You can also use the networksetup command to list all available hardware ports and their names:

> networksetup -listallhardwareports

Hardware Port: Wi-Fi
Device: en0
Ethernet Address: 12:34:56:78:9A:BC

Look for the Hardware Port named Wi-Fi. The value shown next to Device is the one you have to use. So, for me, on a MacBook, I will use:

> networksetup -setairportpower en0 off

We will use en0 in our sample script going forward. If your Wi-Fi port is different, remember to change it in your script going forward

Note: Apple used to brand Wi-Fi as ‘Airport’ and this naming still lingers in some parts of macOS. Changing networksetup’s options to match the new branding would break all existing scripts.

When you replace the off with on it will turn the Wi-Fi back on:

> networksetup -setairportpower en0 on

We have solved the first and third step for our script and tested them in the interactive shell. Now, we can fill them into our script frame:

#!/bin/zsh

# Reset Wi-Fi
# by turning it off and on again


# turn off Wi-Fi
networksetup -setairportpower en0 off 

# wait for a few seconds

# turn on Wi-Fi
networksetup -setairportpower en0 on

You can now save the script as reset_wifi.sh, set its executable bit and run it:

> chmod +x reset_wifi.sh
> ./reset_wifi.sh

This should already work. You should see the Wi-Fi icon in the dock switch to the animation indicating that it is re-connecting with your network.

Taking a break

We still want the script to ‘take a break’ and wait for a few seconds between the turning off and turning back on commands. This will allow other parts of system to ‘settle’ and react to the change.

In the shell you can use the sleep command to achieve this. It takes a single argument, the time in seconds it should pause before continuing:

> sleep 5

When you enter this command in the interactive shell, you should notice that it takes 5 seconds for the next prompt to appear. The sleep command is delaying progress for the given time.

When you watch the CPU load in Activity Monitor while you run the sleep command, you will not see a spike in load. The sleep command merely waits, letting other processes and the system do their thing.

Let us insert a ten second break into our script between the commands to turn Wi-Fi off and on:

#!/bin/zsh

# Reset Wi-Fi
# by turning it off and on again

# turn off Wi-Fi
networksetup -setairportpower en0 off 

# wait for ten seconds
sleep 10

# turn on Wi-Fi
networksetup -setairportpower en0 on

Now, when you run the script. You can see that Wi-Fi icon in the menu bar should switch to ‘disabled’ and then return to the ‘searching’ animation and eventually reconnect after ten seconds.

Feedback

While you are running this script, there is no feedback in the Terminal though. It just takes a disconcerting time for the prompt to return. We can add some output to let the user of the script know what is happening:

#!/bin/zsh

# Reset Wi-Fi
# by turning it off and on again


# turn off Wi-Fi
echo "Disabling Wi-Fi"
networksetup -setairportpower en0 off 

# wait for a ten seconds
echo "Waiting..."
sleep 10

# turn on Wi-Fi
networksetup -setairportpower en0 on
echo "Re-enabled Wi-Fi"

Now, the user will get some feedback in Terminal that lets them know what is going on.

>  ./reset_wifi.sh
Disabling Wi-Fi
Waiting...
Re-enabled Wi-Fi

Script Building Process

We formed an idea or a goal: ‘Turn Wi-Fi off and back on’

Then we described the steps necessary to achieve that goal:

  • turn off Wi-Fi
  • wait a few seconds
  • turn on Wi-Fi

We used these descriptive steps to create a frame or scaffolding script to ‘fill in’ with the proper commands.

Then we explored commands that would achieve these steps in the interactive terminal.

Once we determined the correct commands and options we placed them into our script ‘frame’ in the correct order.

As you add the commands, test whether the script shows the expected behavior.

Then we also added some output, to provide user feedback.

This is a really simple example script to illustrate this process. Nevertheless, we will re-visit these process steps with every script we build and you should follow this process (or a similar flow) when building your own scripts.

The process will not always be quite so clearly defined and you may have to iterate or repeat some of these tasks multiple times, before you find a combination of commands that do what you want. Less experienced scripters will naturally spend more time on ‘exploring commands.’ This is part of the normal learning process.

With larger, more complex scripts, each descriptive step may need to broken into even smaller sub-steps to manage the complexity. Again, experienced scripters will be able tackle more complex steps faster. Do not let that intimidate you when you are just beginning! Experience has to be built ‘the hard way’ by learning and doing.

In many situations you can learn from other people’s scripts that solve a similar problem. This is a well-proven learning strategy. I would still recommend to ‘explore’ the commands used in other people’s scripts before adding them to your scripts, as it will deepen your knowledge and experience with the commands. Sometimes, you may even be able to anticipate and avoid problems.

Next Post: Download and Install Firefox

Scripting macOS, part 5: Lists of Commands

This series is an excerpt from the first chapter of my upcoming book “Scripting macOS” which will teach you to use and create shell scripts on macOS.

I will publish one part every week. Enjoy!

Beyond Hello, World

Now that you have a minimal working script, let’s extend it a bit.

Create a copy of the hello.sh script file, set its executable bit, and open it in your favored text editor:

> cp hello.sh hello_date.sh
> chmod +x hello_date.sh
> bbedit hello_date.sh

A script is a list of commands that the interpreter will process sequentially. Up to now, we only have a single command, the line with echo, in our script. We will add another one:

Change the text in the script file:

#!/bin/zsh

# Greetings
echo "Hello, World!"
date

We have added a line to the script with the date command.

If you are unfamiliar with this command, you can try it out in the interactive command line:

> date    
Tue Feb 23 10:23:05 CET 2021

When you invoke date with out any arguments it will print out the current date and time. The date command has many other functions for doing date and time calculations which we will not use right now, but you can read about in the date man page.

Save the modified script and execute it:

> ./hello_date.sh
Hello, World!
Tue Feb 23 10:27:06 CET 2021

As we can tell from the output, each command in the script was executed one after the other and the output of each command was shown in Terminal.

You can insert more echo commands before the date to make the output prettier:

#!/bin/zsh

# Greetings
echo "Hello, World!"

# print an empty line
echo

# print without line break
echo -n "Today is: "

date

As we have learned earlier, empty lines and lines starting with a # character will be ignored, but can serve to explain and clarify your code. I will be using comments in the example scripts for quick explanations of new commands or options.

Here I added the -n option to the third echo command. This option suppresses the new line character or line break that echo adds automatically at the end of the text. This will then result in the output of the date command to print right after the ‘Today is:’ label, rather than in a line of its own.

Take a look:

> ./hello_date.sh
Hello, World!

Today is: Tue Feb 23 10:30:44 CET 2021

The date command allows for changing the format of the date and time output. It uses the same formatting tokens as the C strftime (string format time) function. You can read the strftime man page for details. The %F format will print the output as ‘year-month-date:’

> date +%F
2021-02-23

You can combine place holders:

> date +"%A, %F"
Tuesday, 2021-02-23

You can experiment with the date formatting placeholders from the strftime man page in the interactive shell. Once you have built a formatter that you like, you can add it to the date command in your script.

#!/bin/zsh

# Greetings
echo "Hello, World!"

# print an empty line
echo

# print without line break
echo -n "Today is: "

date +"%A, %B %d"

It is very common that you will test and iterate a command and arguments in the interactive shell before you add or insert it into your script. This can be a much easier and safer means of testing variations of commands and options than changing and saving the entire script and running it repeatedly.

Scripts are basically ‘lists of commands.’ Once you know the steps to perform a workflow in the interactive terminal, you can start building a script. Let’s look at another example.

Desktop Picture Installer

When you copy an image file to /Library/Desktop Pictures, it will appear in the list of pictures in the ‘Desktop & Screen Saver’ pane in System Preferences. On macOS 10.15 Catalina and higher you may have to create that directory.

You can easily build an installer package (pkg file) that installs an image file into that location with the pkgbuild command.

First, create a directory to hold all the sub-directories files we will need. There needs to be a payload directory in the project directory. Copy the image file into the payload directory:

> mkdir BoringDesktop
> cd BoringDesktop
> mkdir payload
> cp /path/to/BoringBlueDesktop.png payload

You can then build an installer package with the following command:

> pkgbuild --root payload --install-location "/Library/Desktop Pictures/" --identifier blog.scripting.BoringBlueDesktop --version 1.0 BoringDesktop-1.0.pkg
pkgbuild: Inferring bundle components from contents of payload
pkgbuild: Wrote package to BoringDesktop-1.0.pkg

This will create a file BoringDesktop-1.0.pkg. When you double-click this file, it will open the Installer application and, when you proceed with the installation, put the image file in /Library/Desktop Pictures.

Note: To learn more about using and building installer package files for macOS, read my book “Packaging for Apple Administrators.”

The pkgbuild command has a lot of arguments and when you get any of them just slightly wrong, it may affect how the installer package works. This is a very simple example, but when you build more complex installer packages, you may be building and re-building many times and you want to avoid errors due to typos.

We can copy this big command and place it in a script file:

#!/bin/zsh
pkgbuild --root payload --install-location "/Library/Desktop Pictures/" --identifier blog.scripting.BoringBlueDesktop --version 1.0 BoringDesktop-1.0.pkg

Then make the script file executable:

> chmod +x buildBoringDesktopPkg.sh

Now you can just run the script and not worry about getting the arguments ‘just right.’

But we can go one step further and make the script more readable. We can add a comment describing what the script does.

The shell usually assumes the line break to be the end of the command. But when you place a single backslash \ as the last character in a line, the shell will continue reading the command in the next line. That way, you can break this long, difficult to read command into multiple lines:

#!/bin/zsh

# builds the pkg in the current directory

pkgbuild --root payload \
         --install-location "/Library/Desktop Pictures/" \
         --identifier blog.scripting.BoringBlueDesktop \
         --version 1.0 \
         BoringDesktop-1.0.pkg

The arguments are now more readable. When you want to change one of the values, e.g. the version, it is easier to locate.

Even though this script only contains a single command, it improves the workflow significantly, as you do not have to remember a long complex command with many arguments.

Of course, you can copy and modify this script for other package building projects.

Next Post: Turning it off an on again

Scripting macOS, part 4: Running the Script

This series is an excerpt from the first chapter of my upcoming book “Scripting macOS” which will teach you to use and create shell scripts on macOS.

I will publish one part every week over the summer. Enjoy!

Running the Script

Now that we have the code for a functional (even though minimal) script, you can run it from the command line with:

> ./hello.sh

(Verify that the current directory is your script project folder.) You probably have wondered why you need the ./ before the script name. When you type the script name without the ./ it will fail:

> hello.sh
zsh: command not found: hello.sh

What does the ./ mean and why do we need it?

Current Working Directory

In the shell, the . character represents the current working directory or the directory that you ‘cd-ed’ to. Usually, the working directory is shown in the window title bar of Terminal and in the shell prompt (the status text shown before your cursor in the Terminal window). You can also have the system show your current working directory with the pwd command:

> pwd
/Users/armin/Projects/ScriptingMacOS

When you type ./hello.sh the shell will substitute the current working directory for the . character to get

/Users/armin/Projects/ScriptingMacOS/hello.sh

Your path will of course look different.

This means that when I use the ./hello.sh form, the shell knows exactly which file I want to execute and where to look for it. There is no ambiguity.

Now we know what the ./ means, but why is it necessary?

Finding Commands

A command you enter in the shell is just a string of text to begin with. The shell has to parse this text into pieces to determine what needs to be done.
We will use the chmod command from earlier as an example:

> chmod +x hello.sh

The text you entered is ‘chmod +x hello.sh’. The shell will split this text into pieces on the spaces (or tab characters). This will yield three elements:
chmod,’ ‘+x,’ and ‘hello.sh

The shell is only really interested in the first element. The shell will try to interpret the first element of your entry as a command.

Note: Many programming languages start counting at zero. This first element is also called ‘argument zero’ or $0.

The remaining elements are arguments, which the shell will pass on to the command. Arguments are optional. Not all commands require or even have arguments.

Note: This is a simplified description of the parsing process in shells. The reality is quite a bit more complex. But this description is ‘close enough’ for most situations. We will explore some of the nuances later.

If you are interested, you can get all the details in the shell’s documentation:

  • Zsh Documentation: Shell Grammar
  • bash man page, search for ‘Command Execution’

When there is a ‘/‘ character anywhere in the first element, the shell will interpret the first element as a full or relative path to an executable file and attempt to run that.

This is the behavior we are using when we type ./hello.sh. Since that contains a ‘/‘ the shell will resolve the path and execute our script.

We are intentionally using the seemingly redundant ./ prefix to tell the shell to run the script from the current working directory.

When the first element does not contain a ‘/,’ the shell will check if it is one of the following:

  • a shell function
  • a shell built-in command or reserved word
  • an external command

Shell Functions

Shell functions are (mainly) customized shell behavior declared by the user. They look and work like commands.

Note: If you are interested in customizing your shell environment, you can find details in my books “Moving to zsh” and “macOS Terminal and Shell.”

Shell Built-ins

Shell built-in commands cover tasks that are either inherent to the shell or can be performed much faster within the shell than as an external command. These are commands that affect the internal state of the current shell process like cd, alias, or history, or commands that are simpler and faster to implement as built-ins like echo or read.

You rarely need to worry about whether a command is a built-in.

Many built-in commands will have an executable external command file as well. This serves as a ‘fallback’ for the rare situation that the shell built-in is not available.

You can use the (aptly, but confusingly, named) command built-in to determine if a command is built-in or external:

> command -V cd
cd is a shell builtin
> command -V sw_vers
sw_vers is /usr/bin/sw_vers

External Commands

When the command entered is neither a function, nor a built-in, and does not contain a ‘/,’ then the shell will go search for an executable file with that name.

The PATH environment variable determines the locations in the file system and the order in which to search them.

The default PATH on macOS in an interactive shell is:

/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin

The PATH variable contains a colon-separated list of directories. When you have third-party software installed, or customized your shell configuration, your shell may have additional directories. The default PATH splits into the following five directories:

/usr/local/bin
/usr/bin
/bin
/usr/sbin
/sbin

Note: Variable names in the shell are case-sensitive. The variable names MY_VAR and my_var represent different variable and values.
Zsh, however, has the concept of ‘connected variables.’ In zsh, PATH and path are connected variables. The upper-case PATH contains the colon-separated list of directory paths, while the lower-case path is an array of paths. The actual list of directories will be the same and changing one variable will change the other. Zsh has this concept to maintain compatibility with other shells with the PATH, while also allowing you to use array operators on the path.
I will use the colon-separated PATH, because it is more compatible with other shells.

When a user enters a command like this:

> system_profiler

This is neither a function or alias, nor a built-in command. It also does not contain a ‘/.’

The shell will check for the presence of an executable file with the name system_profiler in all the directories given in the PATH. It will start with /usr/local/bin, then /usr/bin, then /bin, until it finally finds a matching file in /usr/sbin.

Then the shell will attempt to execute /usr/sbin/system_profiler.

When no matching files can be found in any of the directories listed in the PATH, the shell will present a ‘command not found’ error.

> cantFindMe
zsh: command not found: cantFindMe

If you are curious which file the shell will use for a given command you can use the command or the which tool:

> command -V system_profiler
system_profiler is /usr/sbin/system_profiler
> which system_profiler
/usr/sbin/system_profiler

PATH precedence

Once the shell finds a matching file, it will stop searching the remaining paths. If there were a second matching executable in /sbin, the shell would never find and execute it. The order of the directory paths given in the PATH variable determines the precedence.

We can test this by placing an executable with a file name matching an existing command in /usr/local/bin. Since /usr/local/bin comes first in the default interactive PATH, the shell should prefer our executable over the default command.

> sudo ditto hello.sh /usr/local/bin/system_profiler

You need administrator privileges to modify the contents of /usr/local/bin. The ditto command preserves all the file’s metadata (like privileges and extended attributes), which makes it preferable to cp for this task.

Then open a new Terminal window. You need to do this because every shell instance will cache or ‘remember’ the lookup for a command to speed up the process later. The shell instance in your current terminal window will remember the last lookup for the system_profiler command. A new Terminal window will start a new ‘fresh’ shell instance, forcing a new lookup:

> which system_profiler
/usr/local/bin/system_profiler
> system_profiler
Hello, World!

Obviously, overriding a system provided command this way can break your workflows and scripts. To be safe, remove our script from /usr/local/bin right away:

> sudo rm /usr/local/bin/system_profiler

There are some situations where overriding a system-provided command is desirable, though. For example, you could install the latest version of bash 5 as /usr/local/bin/bash. And then, when you invoke bash from your interactive terminal, you will launch that version, instead of the outdated version that comes with macOS.

Note: You cannot simply overwrite /bin/bash with the newer version on macOS. The /usr/bin, /bin, /usr/sbin, and /sbin folders are protected by System Integrity Protection and the read-only system volume.

When bash is invoked with the absolute path /bin/bash, the path will need to be updated to use the newer version, though. This includes the shebangs in scripts, and the UserShell attribute in a user’s account record for their default shell.

Extending your tool set

As you get more confident and experienced with scripting, you will assemble a set of scripts that you will use regularly. When you use a script often, it would be nice if the shell recognized them as commands, without having to type the path to them.

To achieve that you can add the directory containing the scripts to the PATH variable. I put my frequently used tools in ~/bin. The name is chosen to be somewhat consistent with the four standard locations for tools, but really does not matter. You can append your tool directory to the PATH with:

> export PATH=$PATH:~/bin

This reads as: replace the value of the environment variable PATH with its current value and append :~/bin. You can verify the new value with

> echo $PATH
/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:/Users/armin/bin

We added our custom directory to the end of the PATH variable, so you cannot accidentally override system tools.

To prevent other local users from changing your command files, you should set the directory’s privileges, so that only you can access:

> chmod 700 ~/bin

Now you can copy our hello.sh script to that folder. We will rename it to just hello, so it looks more like a command:

> ditto hello.sh ~/bin/hello

Then you can run your script just by typing

> hello
Hello, World!

Most of the scripts you build will not need to be as accessible as this.

Since you do not want to manually change the PATH every time you create a new Terminal window, you should add this line to your shell configuration file:

export PATH=$PATH:~/bin

Context Matters

Leveraging PATH to find and possibly override commands can be a useful and powerful tool. However, when scripting, you have to always remember, that the interactive Terminal environment may not be the environment that your script will run in ‘production.’

This is especially important for scripts run by

  • LaunchDaemons or LaunchAgents
  • AppleScripts
  • applications other than Terminal (such as Xcode)
  • installer packages
  • management systems

When run from any of these environments, the environment the script runs in will be different from your interactive shell environment. Most likely the PATH in any of these environments will be set to the minimal four system folders:

/usr/bin:/bin:/usr/sbin:/sbin

This should work well for most commands and tools. But when you are working with third party software, especially newer versions, then you need to pay very close attention.

When you build scripts for these environments, I recommend setting the PATH at the beginning of your script explicitly to the value you need:

export PATH=/usr/bin:/bin:/usr/sbin:/sbin

When your scripts require tools, include their locations in the PATH. This way, the PATH environment is declared explicitly at the beginning of the script and there can be no confusion.

For the scripts in this series, the default PATH will be sufficient, so we will not need to worry about this.

Next: Lists of Commands

Scripting macOS, part 3: The Code

This series is an excerpt from the first chapter of my upcoming book “Scripting macOS” which will teach you to use and create shell scripts on macOS.

I will publish one part every week over the summer. Enjoy!

The Code

A script file is a text file with the executable bit set.

You can set the executable bit on any file. That alone does not turn it into a working script file. To get a working script file, the contents need to have the right structure or ‘syntax.’

Our example above is close to the minimum you need to have a working script file. We will look at the contents one line at a time.

#!/bin/zsh

# Greetings
echo "Hello, World!"

Shebang

The first line in our script is:

#!/bin/zsh

The first two characters #! form a special file signature or ‘magic number.’ They tell the shell and the system that this is not just any file, but a script file that should be interpreted.

These two characters have special names. The number sign is called the ‘hash’ and the exclamation mark is called the ‘bang.’ Together they are the ‘hashbang’ or the ‘shebang.’

The shebang characters have to be followed by the full path to the interpreter binary. There are no spaces in between the shebang characters and the interpreter path.

In this file, we have designated the zsh binary /bin/zsh.

When you type ./hello.sh to execute your script in Terminal, the shell will check if the file is executable. If it is executable, the shell will look at the first characters of the file. It ‘sees’ the shebang #! characters, and determines from them that this is a script file. Then it will read the rest of the first line to determine the interpreter. It will then hand over the entire script file to that interpreter binary.

This will have the same effect as running your script like this:

> /bin/zsh hello.sh

A new zsh process starts which reads and interprets the script file.

It does not matter which shell you use as your interactive shell. The shebang determines the interpreter for the script. That means you can run bash and sh scripts from zsh and vice versa.

When you are writing your scripts against sh or bash, you will have a shebang line of #!/bin/sh or #!/bin/bash, respectively.

The three shells are pre-installed as part of macOS and protected by System Integrity Protection and the read-only system volume. When you install newer versions of the interpreters, such as bash v5, then that binary will be in a different location. Most commonly /usr/local/bin.

If, for example, you have bash v5 installed as /usr/local/bin/bash, the shebang lines in the scripts that should use bash v5 will be:

#!/usr/local/bin/bash

When you then try to run these scripts on a mac that does not have bash v5 installed (or installed in a different location) then the scripts will fail.

> ./hello.sh
zsh: ./hello.sh: bad interpreter: /usr/local/bin/bash: no such file or directory

This can also happen when you run your script on a different platform. Even though the other platform may have the bash or zsh binary installed, it could be at a different path than on macOS. And the interpreter binary may be a different version than on macOS (especially with bash) which can lead to the script being interpreted differently.

For scripts that need to work in many different environments you will often see a shebang that uses the env command:

#!/usr/bin/env bash

The env command will use the current user’s environment, especially the PATH, to determine where the bash binary is and passes the script on to that. This will make your shebang more flexible, but you also give up some control as the environment will differ from system to system and from user to user.

For situations where you need control—such as management scripts—a fixed path for the shebang should be preferred, but then you also have ensure that the interpreter binary will be available at that path.

Whitespace

The line right after the shebang is empty. In shell scripts (and most other programming languages) empty lines are just ignored. Adding empty lines will make your code more readable.

Comments

The third line in our script starts with a number character or ‘hash.’

# Greetings

In shell scripts, lines that start with the hash character are ignored by the interpreter. You can and should use this to leave explanations and comments in your code.

Use comments in your code to leave explanations and notes.

echo Command

Now, finally, we will get to the actual code. The part of the script that does something.

echo "Hello, World!"

The echo command is one command that you rarely use in the interactive shell, but very frequently within scripts. It tells the shell to print (repeat or ‘echo’) text to the terminal’s output.

As usual in shells, the text we pass in to echo as an argument is ‘held together’ by quotes. This kind of text used in code is also called a ‘string’ because it is a ‘string of characters.’

Next: Running the Script

Scripting macOS, part 2: The Script File

This series is an excerpt from the first chapter of my upcoming book “Scripting macOS” which will teach you to use and create shell scripts on macOS.

I will publish one part every week over the summer. Enjoy!

The Script File

In the previous part, we created a text file and named it hello.sh. As we have mentioned before, all shell script files are text files.

File Extension

The file extension .sh is entirely optional. When you rename the file to merely hello it will remain a functional script:

> mv hello.sh hello
> ./hello
Hello, World!

When you use the script frequently as a command line tool, typing the additional .sh is cumbersome and does not ‘fit in’ very well with all the other command line tools. It is very common to remove the file extension in these cases.

However, the .sh file extension tells Finder and other applications what kind of file this is. You can assign the .sh file extension to be opened in your favored text editor when you double-click the file in Finder.

To do this, rename the file so it has the extension again:

> mv hello hello.sh

Then select the script file in Finder and open its Info panel (⌘I).

In the ‘General’ section of the info panel, you can see that Finder recognizes this file extension as a shell script.

In the ‘Open with’ section you can tell Finder which application to use to open this file. The popup list will show all applications you have installed that can open .sh files. Select your favorite text editor.

To tell the system to open all files with a .sh extension with your favorite text editor, click on the ‘Change All…’ button here. This will set your favorite text editor as the default application for the .sh file extension.

Some people like to use .bash and .zsh file extensions to further distinguish scripts written specifically in bash or zsh. While this can be useful in some workflows—especially when translating scripts from bash to zsh—it is generally not necessary. The .sh file extension works well for all these scripts. The .bash and .zsh file extensions are also not automatically recognized by the Finder or text editors as shell scripts.

Executable Bit

Aside from the actual script code, a shell script requires one more thing that distinguishes it from any other text file. You have to the tell the system that this file can be executed as a command.

This information is stored in the executable bit of the file privileges or file mode. You can see this information with the long form of the ls command:

> ls -l hello.sh 
-rwxr-xr-x@ 1 armin  staff  hello.sh

The file mode is displayed in the first ten characters in this output: -rwxr-xr-x.

The leading dash designates this item as a file. (A directory will show d, a symbolic link will show l, etc.)

The file access privileges are shown next. There are three sets of three flags or bits. The first three (in this case rwx) are the privileges for the file’s owner. The second set (r-x) are the privileges for group access and the last set (also r-x) shows the privileges for all other user on this system.

The three flags in each set are r, w, and x, in that order. They stand for read (r), write (w) and execute (x) and show the actions the users associated with each set can perform.

The file’s owner can read, write (or change), and execute (or run) this file. Users who are a member of the staff group, can read and execute this file, as well as every other user on this system.

Most documents and files merely contain text and data. The execute bit is not required. When you create a new file, the executable flag is disabled by default:

> touch newfile.txt
> ls -l newfile.txt
-rw-r--r--  1 armin  staff  newfile.txt

Having the execute bit disabled by default is a sensible security measure. You will have to explicitly enable it when building scripts or other executables.

When you are writing shell scripts, you eventually want to execute them. To do this you have to enable the execute bit. When then execute bit is unset, you cannot run the script from the command line.

To demonstrate this—and to gain some familiarity with the command line tools required to manipulate the file mode—disable the executable bit of our hello.sh script:

> chmod -x hello.sh

You can verify that the executable bit has been removed with the ls -l command:

> ls -l hello.sh
-rw-r--r--@ 1 armin  staff  45 Feb 17 14:36 hello.sh

When you now attempt to run the command, it will fail and the shell will tell you the reason: you do not have the right permissions. Without the executable script, your shell script is merely a text file.

> ./hello.sh
zsh: permission denied: ./hello.sh

You can re-set the executable bit with

> chmod +x hello.sh

You do not need write (w) permissions to execute a script, but you require read (r) permissions.

You can remove the executable privilege for group and/or other users for security. Since most Macs are used as single user systems this is not really relevant here. When you create, install, and run shell scripts on shared servers with other Unix or unix-like servers with many users, the proper access privileges may be extremely important. Please consult with the system administrator.

You will usually have to remember to set the executable bit when you create a new script. Some text editors may set it for you when they recognize you are working on a shell script.

Note: The executable bit has a different meaning for directories than it does for files. You can find the details in the chmod man page or in my book ‘macOS Terminal and Shell

Next: The Code

Scripting macOS, part 1: First Script

This series is an excerpt from the first chapter of my upcoming book “Scripting macOS” which will teach you to use and create shell scripts on macOS.

I will publish one part every week over the summer. Enjoy!

First Script

When learning programming languages it is tradition that the first program you write displays ‘Hello, World!’

Printing these words to the screen is usually a really simple task. In most programming languages it requires just a single line. Nevertheless, creating a simple program or script like this will teach you a lot about all the other tasks you need to create and run a script.

Before you start, you should create a folder where you store all the scripts you will create. A Documents subfolder is a good location. I have a Projects folder in my home directory where I create subfolders for all my scripting and programming projects. You may already have some structure like this that makes sense for you.

You can create this in Finder and the navigate there in Terminal. But, just to practice working in Terminal, you can create your script project folder in the command line:

> cd ~/Documents
> mkdir ScriptingMacOS
> cd ScriptingMacOS

Where- and however you create this folder, we will refer to it as your script project folder from now on. When you interact with the scripts from the command line, always remember to change the working directory there.

Note: When you know how to use a version control system to manage code, such as git, you can set up or initialize this project folder as repository now. Shell scripts are well suited to be managed with version control systems and I would recommend to use it when building scripts. But explaining the details of git as well as teaching shell scripting would be overwhelming and beyond the scope of this series.

Create your first script

Create a new text file in your favored text editor.

Note: If you do not have a favorite text editor yet, I recommend BBEdit. You can use it for free or pay the license fee to unlock the full feature set. After installing BBEdit, be sure to run ‘Install Command Line Tools…’ from the BBEdit menu.

I will be using the bbedit command line tools in my examples, but other text editors have similar commands.

When you are using a text editor with a command line tool, you can create a new empty file from the command line and open it in the text editor like this:

> bbedit hello.sh

Enter the following text into the text document:

#!/bin/zsh

# Greetings
echo "Hello, World!"

Tip: You can copy and paste the code from this post into the text editor. In general, I approve of this, since it speeds up the process and avoids typing errors. But in the beginning, I would recommend typing out the commands and modifying the script manually. This will engage your brain and memory differently and help you memorize some of the standard steps and commands.

Save the text file you created to a file named hello.sh in the script project folder.

In Terminal, ensure that the working directory is your script project folder. Then enter the following:

> chmod +x hello.sh

This will make your script file executable. You are telling the system this file has code that can be run as a program rather than just data. We will look at what this means in detail later.

Now you can run or execute your script:

> ./hello.sh
Hello, World!

If you get a different result, please verify that you typed the script exactly as given above. Pay extra attention to spaces, quotes and other special characters. Then save the text file and try running the script again:

> ./hello.sh
Hello, World!

Even for a script as simple as this, there is a lot that happened here. We will look at the pieces and steps in detail in the next part.

Next: The Script File

Get Password from Keychain in Shell Scripts

MacAdmin scripts often require passwords, mostly for interactions with APIs.

It is easiest to store the password in clear text, but that is obviously a terrible solution from a security perspective. You can pass the password as an argument to your script, but that is inconvenient and may still appear in clear text in the ps output or the shell history.

You can obfuscate the password with base64, but that is easily reversible. You can even try to encrypt the password, but since the script needs to be able to decrypt the password, you are just adding a layer of complexity to the problem.

macOS has a keychain, where the user can store passwords and allow applications and processes to retrieve them. We can have our script retrieve a password from a local keychain.

There are limitations to this approach:

  • the password item has to be created in the keychain
  • the user has to approve access to the password at least once
  • the keychain has to be unlocked when item is created and when the script runs—this usually requires the user to be logged in
  • the user and other scripts can find and read the password in the Keychain Access application or with the security tool

Because of these limitations, this approach is not useful for scripts that run without any user interaction, e.g. from a management system. Since the user can go and inspect the key in the Keychain Access is also not well suited for critical passwords and keys.

However, it is quite useful for workflow scripts that you run interactively on your Mac. This approach has the added benefit, that you do not have to remember to remove or anonymize any keys or passwords when you upload a script to GitHub or a similar service.

Note: Mischa used this in his ‘OnAirScanner’ script.

Update: I didn’t remember this, but Graham Pugh has written about this before.

How to Store a Password in the Keychain

Since adding the password to your keychain is a one-time task, you can create the password manually.

Open the Keychain Access application and choose “New Password Item…” from the Menu. Then enter the Keychain Item Name, Account Name and the password into the fields. The “Keychain Item Name” is what we are going to use later to retrieve the password, so watch that you are typing everything correctly.

You can also add the password from the command line with the security command.

> security add-generic-password -s 'CLI Test'  -a 'armin' -w 'password123' 

This will create an item in the Keychain with the name CLI Test and the account name armin and the horribly poor password password123.

How to Retrieve the Password in the Script

To retrieve a password from the keychain in a script, use the security command.

> security find-generic-password -w -s 'CLI Test' -a 'armin'

This will search for an item in the keychain with a name of CLI Test and an account name of armin. When it finds an item that matches the name and account it will print the password.

The first time you run this command, the system will prompt to allow access to this password. Enter your keychain password and click the ‘Always Allow’ button to approve the access.

This will grant the /usr/bin/security binary access to this password. You can see this in the Keychain Access application in the ‘Access Control’ tab for the item.

When you create the item with the security add-generic-password binary, you can add the -T /usr/bin/security option to immediately grant the security binary access.

Whether you grant access through the UI or with the command, keep in mind that a every other script that uses the security binary will also gain access to this password.

For very sensitive passwords, you can just click ‘Allow’ rather than ‘Always Allow.’ Then the script will prompt interactively for access every time. This is more secure, but also requires more user interaction.

Once you have tested that you can retrieve the password in the interactive shell, and you have granted access to the security binary, you can use command substitution in the script to get the password:

cli_password=$(security find-generic-password -w -s 'CLI Test' -a 'armin')

This command might fail for different reasons. The keychain could be locked, or the password cannot be found. (Because it was either changed, deleted or hasn’t been created yet.) You want to catch that error and exit the script when that happens:

pw_name="CLI Test"
pw_account="armin"

if ! cli_password=$(security find-generic-password -w -s "$pw_name" -a "$pw_account"); then
  echo "could not get password, error $?"
  exit 1
fi

echo "the password is $cli_password"