Bash & PowerShell Scripts To Distribute Your CLI Tool
I had a lot of problems figuring out how to distribute my CLI tool gitnr which generates .gitignore
files from templates.
I wanted users to easily install my CLI tool on Windows, Mac, and Linux without having to manually download binaries and add them to their system path.
Initially I attempted to publish it on package managers like brew
but honestly it seemed like way more trouble than it was worth at the project's current stage.
Eventually I gave up on package managers and decided it would be easier to just create an installation command which users on Windows, Mac, and Linux could run in their terminal. This is nothing new, I'm sure you've seen or used it before.
But I had no idea how to do this...
Overview
After a lot of research and exploring installation scripts from various open-source projects, I created 2 scripts.
- Bash - Linux & Mac
- PowerShell - Windows
These scripts aren't perfect but they work well and can form the basis for
more complex installation scripts.
With these scripts as your base, you could allow users to:
- Easily install your application (CLI, GUI, etc.)
- Install your application on Windows, Mac and Linux
- Customize the installation process via parameters (e.g. install directory, etc.)
- Update an existing installation with a new version of your application
Security Note: Installation scripts like these inherently pose a security risk as they enable execution of code from a remote source. Ensure scripts can only be controlled by trusted parties and that they can be easily audited by your users.
Some points to consider:
- These scripts assume your app is distributed via GitHub releases. You can modify the download URL variables to change this
- If you are not using GitHub releases, you may need to implement a way to retrieve the download URL of the latest or desired version
Install script - Bash
The Bash script includes the following features:
- Configure your GitHub repo and version to install
- Declare a list of supported OS & architecture combinations
- Allow the user to specify whether to install the binary system-wide, or to their user bin directory
- Allow the user to specify a custom installation directory
- Display a post-install message
Users can execute the script as is or pass the following parameters:
-u
: install to the current user bin directory instead of the system bin directory-d <path>
: specify a custom installation directory
You will need to replace the following variables in the script:
<name>
- The name of your application and executable<version>
- The version of your application to install, e.glatest
<github-repo>
- Your GitHub repo, e.g.reemus-dev/gitnr
<download-binary-name>
- The name of the binary to download for a particular OS/Arch, e.g.gitnr-win-amd64.exe
#!/usr/bin/env bash
set -euo pipefail
# =============================================================================
# Define helper functions
# =============================================================================
text_bold() {
echo -e "\033[1m$1\033[0m"
}
text_title() {
echo ""
text_bold "$1"
if [ "$2" != "" ]; then echo "$2"; fi
}
text_title_error() {
echo ""
echo -e "\033[1;31m$1\033[00m"
}
# =============================================================================
# Define base variables
# =============================================================================
NAME="<name>"
VERSION="<version>"
GITHUB_REPO="<github-repo>"
DOWNLOAD_BASE_URL="https://github.com/$GITHUB_REPO/releases/download/$VERSION"
if [ "$VERSION" == "latest" ]; then
# The latest version is accessible from a slightly different URL
DOWNLOAD_BASE_URL="https://github.com/$GITHUB_REPO/releases/latest/download"
fi
# =============================================================================
# Define binary list for supported OS & Arch
# - this is a map of "OS:Arch" -> "download binary name"
# - you can remove or add to this list as needed
# =============================================================================
declare -A BINARIES=(
["Linux:x86_64"]="<download-binary-name>"
["Darwin:x86_64"]="<download-binary-name>"
["Darwin:arm64"]="<download-binary-name>"
)
# =============================================================================
# Get the user's OS and Arch
# =============================================================================
OS="$(uname -s)"
ARCH="$(uname -m)"
SYSTEM="${OS}:${ARCH}"
# =============================================================================
# Match a binary to check if the system is supported
# =============================================================================
if [[ ! ${BINARIES["$SYSTEM"]+_} ]]; then
text_title_error "Error"
echo " Unsupported OS or arch: ${SYSTEM}"
echo ""
exit 1
fi
# =============================================================================
# Set the default installation variables
# =============================================================================
INSTALL_DIR="/usr/local/bin"
BINARY="${BINARIES["$SYSTEM"]}"
DOWNLOAD_URL="$DOWNLOAD_BASE_URL/$BINARY"
# =============================================================================
# Handle script arguments if passed
# -u: install to user bin directory
# -d <path>: specify installation directory
# =============================================================================
if [ $# -gt 0 ]; then
while getopts ":ud:" opt; do
case $opt in
u)
# Set default install dir based on OS
[ "$OS" == "Darwin" ] && INSTALL_DIR="$HOME/bin" || INSTALL_DIR="$HOME/.local/bin"
# Check that the user bin directory is in their PATH
IFS=':' read -ra PATHS <<< "$PATH"
INSTALL_DIR_IN_PATH="false"
for P in "${PATHS[@]}"; do
if [[ "$P" == "$INSTALL_DIR" ]]; then
INSTALL_DIR_IN_PATH="true"
fi
done
# If user bin directory doesn't exist or not in PATH, exit
if [ ! -d "$INSTALL_DIR" ] || [ "$INSTALL_DIR_IN_PATH" == "false" ]; then
text_title_error "Error"
echo " The user bin directory '$INSTALL_DIR' does not exist or is not in your environment PATH variable"
echo " To fix this error:"
echo " - Omit the '-u' option and to install system-wide"
echo " - Specify an installation directory with -d <path>"
echo ""
exit 1
fi
;;
d)
# Get absolute path in case a relative path is provided
INSTALL_DIR=$(cd "$OPTARG"; pwd)
if [ ! -d "$INSTALL_DIR" ]; then
text_title_error "Error"
echo " The installation directory '$INSTALL_DIR' does not exist or is not a directory"
echo ""
exit 1
fi
;;
\?)
text_title_error "Error"
echo " Invalid option: -$OPTARG" >&2
echo ""
exit 1
;;
:)
text_title_error "Error"
echo " Option -$OPTARG requires an argument." >&2
echo ""
exit 1
;;
esac
done
fi
# =============================================================================
# Create and change to temp directory
# =============================================================================
cd "$(mktemp -d)"
# =============================================================================
# Download binary
# =============================================================================
text_title "Downloading Binary" " $DOWNLOAD_URL"
curl -LO --proto '=https' --tlsv1.2 -sSf "$DOWNLOAD_URL"
# =============================================================================
# Make binary executable and move to install directory with appropriate name
# =============================================================================
text_title "Installing Binary" " $INSTALL_DIR/$NAME"
chmod +x "$BINARY"
mv "$BINARY" "$INSTALL_DIR/$NAME"
# =============================================================================
# Display post install message
# =============================================================================
text_title "Installation Complete" " Run $NAME --help for more information"
echo ""
Executing the script
For your users to execute the script, they can use one of the following commands in their terminal:
Install system-wide
curl -s https://raw.githubusercontent.com/reemus-dev/gitnr/main/scripts/install.sh | sudo bash -s
Naturally, this requires the script to be executed with sudo
.
Install for current user
curl -s https://raw.githubusercontent.com/reemus-dev/gitnr/main/scripts/install.sh | bash -s -- -u
Install in specific directory
curl -s https://raw.githubusercontent.com/reemus-dev/gitnr/main/scripts/install.sh | bash -s -- -d <dir>
Passing custom parameters
As you can tell from the above commands, custom parameters can be passed to the script after the | bash -s --
part.
Install script - PowerShell
This script is not as customizable as the above Bash script, but you could easily modify it to achieve the same as above.
This PowerShell script will allow you to:
- Configure your GitHub repo and version to install
- Declare a list of supported OS & architecture combinations
- Install your app to the
AppData\Local
directory - Add the installation directory to the user's PATH if not already present
- Display a post-install message
You will need to replace the following variables in the script:
<name>
- The name of your application and executable<version>
- The version of your application to install, e.glatest
<github-repo>
- Your GitHub repo, e.g.reemus-dev/gitnr
<download-binary-name>
- The name of the binary to download, e.g.gitnr-win-amd64.exe
$ErrorActionPreference = "Stop"
# =============================================================================
# Define base variables
# =============================================================================
$name = "<name>"
$binary="$name.exe"
$version="<version>"
$githubRepo="<github-repo>"
$downloadBaseUrl="https://github.com/$githubRepo/releases/download/$version"
if ($version -eq "latest") {
# The latest version is accessible from a slightly different URL
$downloadBaseUrl="https://github.com/$githubRepo/releases/latest/download"
}
# =============================================================================
# Determine system architecture and obtain the relevant binary to download
# - you can add more "if" conditions to support additional architectures
# =============================================================================
$type = (Get-ComputerInfo).CsSystemType.ToLower()
if ($type.StartsWith("x64")) {
$downloadFile = "<download-binary-name>"
} else {
Write-Host "[Error]" -ForegroundColor Red
Write-Host "Unsupported Architecture: $type" -ForegroundColor Red
[Environment]::Exit(1)
}
# =============================================================================
# Create installation directory
# =============================================================================
$destDir = "$env:USERPROFILE\AppData\Local\$name"
$destBin = "$destDir\$binary"
Write-Host "Creating Install Directory" -ForegroundColor White
Write-Host " $destDir"
# Create the directory if it doesn't exist
if (-Not (Test-Path $destDir)) {
New-Item -ItemType Directory -Path $destDir
}
# =============================================================================
# Download the binary to the installation directory
# =============================================================================
$downloadUrl = "$downloadBaseUrl/$downloadFile"
Write-Host "Downloading Binary" -ForegroundColor White
Write-Host " From: $downloadUrl"
Write-Host " Path: $destBin"
Invoke-WebRequest -Uri $downloadUrl -OutFile "$destBin"
# =============================================================================
# Add installation directory to the user's PATH if not present
# =============================================================================
$currentPath = [System.Environment]::GetEnvironmentVariable('Path', [System.EnvironmentVariableTarget]::User)
if (-Not ($currentPath -like "*$destDir*")) {
Write-Host "Adding Install Directory To System Path" -ForegroundColor White
Write-Host " $destBin"
[System.Environment]::SetEnvironmentVariable('Path', "$currentPath;$destDir", [System.EnvironmentVariableTarget]::User)
}
# =============================================================================
# Display post installation message
# =============================================================================
Write-Host "Installation Complete" -ForegroundColor Green
Write-Host " Restart your shell to starting using '$binary'. Run '$binary --help' for more information"
Executing the script
The script can be executed using the command below in a PowerShell terminal:
Set-ExecutionPolicy Unrestricted -Scope Process; iex (iwr "https://raw.githubusercontent.com/reemus-dev/gitnr/main/scripts/install.ps1").Content
Missing features
Here are some features missing from the scripts which you can consider implementing.
- Implement passing parameters to the PowerShell script
- Verify the downloaded binary against a checksum
- Allow the user to specify the installation version
- Allow the user to uninstall the application
Testing the scripts
The easiest way to test the scripts is to run them yourself and validate that they work as intended. Naturally you will need access to different OS's.
For a more thorough approach, you could implement a CI/CD pipeline to test the scripts but this is beyond the scope of this article.
If you choose to do this, consider:
- Testing all the parameters that could be passed and their combinations
- Validating that the installation directories exist and are in the
$PATH
variable - Validating that the binary is installed in the correct location
I would love to hear from you if you do implement automated testing these scripts.
Conclusion
I hope you found these scripts useful whether you use it as is or as a starting point. If you have any improvements or suggestions, please leave a comment below and I will review the scripts.
Comments