Reemus Icon

Bash & PowerShell Scripts To Distribute Your CLI Tool

👍🔥❤️😂😢😕
views
comments

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.g latest
  • <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 ""
bash-icon

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
sh-icon

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
sh-icon

Install in specific directory

curl -s https://raw.githubusercontent.com/reemus-dev/gitnr/main/scripts/install.sh | bash -s -- -d <dir>
sh-icon

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.g latest
  • <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"
powershell-icon

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
powershell-icon

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

...

Your name will be displayed publicly

Loading comments...