Table of contents
- Let's start with some examples
- Look closer...
- bash -c "$(curl url)" or curl url | bash
- Not all shells are created equal! Specify the shell to run the installer, if possible. Maybe bash.
- Getting the user's default shell can sometimes be challenging.
- If your software installs a shell function, let the binary emit it. Do not write directly to ~/.xxshrc
Most people are not fluent in shell scripting (like me). However, if you want people to install your CLI-based software (like me again), you might want to write an installer in shell script.
In this post, I will share what I learned while writing an installer for my software. I won't delve into the specifics of shell script syntax in this post; for that, I recommend consulting ChatGPT. I will share the Kata of shell installers. (You will understand why all shell installers look similar!)
Let's start with some examples
You will see some patterns:
Homebrew: A popular package manager for macOS
Command:
/bin/bash -c "$(curl -fsSL
https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh
)"
Source: https://github.com/Homebrew/install/blob/master/install.sh
direnv: A popular environment variable manager
Command:
curl -sfL
https://direnv.net/install.sh
| bash
Source: https://github.com/direnv/direnv/blob/master/install.sh
Nix: A modern, cross-platform package manager
Command:
sh <(curl -L
https://nixos.org/nix/install
) --daemon
Source: https://github.com/NixOS/nix/blob/master/scripts/install.in
Pyenv: A popular Python version manager
Command:
git clone
https://github.com/pyenv/pyenv.git
~/.pyenv
No shell script is run, but you will also need to manually run these commands to set up shell hooks (in bash):
echo 'export PYENV_ROOT="$HOME/.pyenv"' >> ~/.bashrc
echo 'command -v pyenv >/dev/null || export PATH="$PYENV_ROOT/bin:$PATH"' >> ~/.bashrc
echo 'eval "$(pyenv init -)"' >> ~/.bashrc
Pyenv-installer: Installer for pyenv
Command: curl https://pyenv.run | bash
Installer: https://github.com/pyenv/pyenv-installer/blob/master/bin/pyenv-installer
- You will also need to run the commands above to install shell functions.
Look closer...
bash -c "$(curl url)" or curl url | bash
All of the above examples use curl
to get the installer and feed the script's content to the shell. However, how the script's content is passed to bash can occur in two ways.
bash -c "$(curl url)"
style is used by Homebrew and Nix. curl url | bash
style is used by direnv and pyenv-installer. What's the difference?
The difference lies in whether you run the shell script in interactive or non-interactive mode. If you pipe the script to the shell (curl url | bash
style), the standard input is piped to the curl command, meaning you cannot interact with the command via terminal input. Consequently, you cannot use some commands such as read
in the curl url | bash
style.
If your installer interacts with the user (e.g., asking for confirmation), then you need to adopt the bash -c "$(curl url)"
style.
Not all shells are created equal! Specify the shell to run the installer, if possible. Maybe bash.
You may notice that most of the examples above specify Bash to run the installer. This is quite understandable as bash is practically the most common shell.
Of course, you can let the user use their default shell. However, each shell has slightly different syntax. For example, if you want the user to type one character and input it to the variable REPLY:
zsh
read -r -k 1 REPLY
bash
read -r -n 1 REPLY
As you might not want to deal with such differences, in most cases, you will prefer to use Bash.
Getting the user's default shell can sometimes be challenging.
If your script requires users to add some hooks or functions to their default shell's rc file (Bash: ~/.bashrc, zsh: ~/.zshrc), you might want to know the user's default shell to automatically insert such codes. You can determine their default shell, even if the installer is running in a non-default shell, using the $SHELL variable. For example, the Homebrew installer shows customized instructions based on the user's default shell.
However, the $SHELL variable is not always set. The variable is only set if the shell is run as a login shell, etc. I won't go into detail, but you may sometimes encounter this situation, e.g., shells in a not properly configured docker container.
To address such cases, you can do the following:
Let the user insert the shell hook
pyenv-installer chooses this method. The installer runs only on Bash and doesn't alter rc files.
Let the user decide the shell to run the installer
You will need to write the installer to work in various shells. Use
$0
to get the shell name (sh, bash, etc...). Or you can use shell-specific variables such as$ZSH_VERSION
or$BASH_VERSION
to check the running shell.
Why not use sh?
sh is the most basic shell syntax, which can be executed in various shells such as Bash, zsh, etc. Therefore, you can, of course, use sh to write the installer. However, the syntax of sh is more limited than Bash. Thus, you may not want to write in the syntax. Nix adopts sh. I don't know why but maybe it is because Nix is a very low-level application that prefers a more basic and universal shell.
If your software installs a shell function, let the binary emit it. Do not write directly to ~/.xxshrc
You may need to insert a shell function into the user's default shell file like pyenv. As seen earlier, you can let the installer check the user's shell and write to the rc file directly. What should you write to it? Let's see what other software is doing:
Pyenv:
eval "$(pyenv init -)"
Homebrew:
eval "$(/opt/homebrew/bin/brew shellenv)"
direnv:
eval "$(direnv hook zsh)"
Indeed, none of them write shell functions and environment variables directly. Instead, they let the binary (or script) emit the shell codes upon loading of rc files.
Why? Because you can update the shell function without editing the user's rc file. And you don't need to worry about the difference between versions!