#!/bin/sh # # Install sill — https://sill.sh # # curl sill.sh | sh # # If you are reading this before piping it into a shell, that is the right instinct. # Here is everything the script does, in order: # # 1. Works out your operating system and CPU, and stops if there is no build for them. # 2. Asks the GitHub API for the latest release tag of sill-sh/client, unless # SILL_VERSION says which version to install. # 3. Downloads that release's tarball and its SHA256SUMS file into a temporary # directory. # 4. Verifies the tarball against SHA256SUMS *before* unpacking it. If neither # sha256sum nor `shasum -a 256` exists, it stops rather than install something it # could not check. # 4b. If the GitHub CLI is installed, also checks the build provenance attestation, # which ties the file to the workflow that built it rather than to a checksum # published beside it. Set SILL_REQUIRE_PROVENANCE=1 to make this mandatory. # 5. Unpacks the single `sill` binary into $SILL_INSTALL_DIR, default ~/.local/bin. # 6. Deletes the temporary directory, runs `sill --version` to confirm the binary # works here, and tells you if the install directory is not on your PATH. # # It never uses sudo. It never writes outside the temporary directory and # $SILL_INSTALL_DIR. The only downloaded thing it executes is the final # `sill --version`. # # What the checksum does and does not do # # The checksum proves the tarball you received is the one SHA256SUMS describes, so a # corrupted download or a swapped asset is caught. On its own it does not prove the # release is ours: SHA256SUMS comes from the same GitHub release as the tarball, so # anyone able to alter one can alter both. # # The provenance attestation is what closes that. It is signed by GitHub's identity for # the workflow that built the artifact and recorded in a public transparency log, so it # ties the file to this repository and that workflow rather than to a file sitting next # to it. Checking it needs the GitHub CLI, which most machines do not have — so it is # attempted when available and reported either way rather than skipped in silence. # # You can always check by hand: # gh attestation verify --repo sill-sh/client # # Environment: # SILL_VERSION Install this version instead of the latest. "0.2.0" or "v0.2.0". # SILL_INSTALL_DIR Where to put the binary. Default: $HOME/.local/bin # # MIT. Source: https://github.com/sill-sh/client set -eu REPO="sill-sh/client" BIN="sill" # --- output -------------------------------------------------------------------------- # # Every function takes at most one argument so that nothing here depends on how a given # shell expands "$@" when it is empty, which is not consistent across shells. say() { printf '%s\n' "${1:-}"; } err() { printf '%s\n' "${1:-}" >&2; } die() { err "error: ${1:-}" exit 1 } need_cmd() { command -v "$1" >/dev/null 2>&1 || die "$1 is required but is not on your PATH." } # --- platform ------------------------------------------------------------------------ # Map `uname -s` and `uname -m` onto one of the four target triples we publish. Prints # the triple, or returns 1 if there is no build for that pair. This is pure: it reads # nothing from the environment, so it can be tested on its own. target_for() { case "$1" in Darwin) case "$2" in arm64 | aarch64) printf '%s\n' "aarch64-apple-darwin" ;; x86_64 | amd64) printf '%s\n' "x86_64-apple-darwin" ;; *) return 1 ;; esac ;; Linux) case "$2" in arm64 | aarch64) printf '%s\n' "aarch64-unknown-linux-gnu" ;; x86_64 | amd64) printf '%s\n' "x86_64-unknown-linux-gnu" ;; *) return 1 ;; esac ;; *) return 1 ;; esac } # --- fetching ------------------------------------------------------------------------ # fetch . Both downloaders are told to fail on an HTTP error # rather than write the error page to the destination. fetch() { if [ "$downloader" = curl ]; then curl --proto '=https' --tlsv1.2 --location --fail --silent --show-error \ --retry 3 --output "$2" "$1" else wget --quiet --output-document "$2" "$1" fi } # --- checksums ----------------------------------------------------------------------- sha256_of() { if [ "$sha_tool" = sha256sum ]; then sha256sum "$1" | cut -d ' ' -f 1 else shasum -a 256 "$1" | cut -d ' ' -f 1 fi } # --- shell configuration ------------------------------------------------------------- # The file a login shell reads, so the PATH advice at the end names a real path. Best # effort: $SHELL is your login shell, which is not necessarily the shell you are in. rc_file() { case "$(basename "${SHELL:-/bin/sh}")" in zsh) printf '%s\n' "${ZDOTDIR:-$HOME}/.zshrc" ;; bash) # On macOS every new terminal window is a login shell, and login bash reads # .bash_profile rather than .bashrc. if [ "$(uname -s)" = Darwin ]; then printf '%s\n' "$HOME/.bash_profile" else printf '%s\n' "$HOME/.bashrc" fi ;; fish) printf '%s\n' "$HOME/.config/fish/config.fish" ;; *) printf '%s\n' "$HOME/.profile" ;; esac } # --- clean up on the way out --------------------------------------------------------- # # Registered before the temporary directory exists so that there is no window where a # signal leaves a downloaded tarball behind. work="" cleanup() { if [ -n "$work" ] && [ -d "$work" ]; then rm -rf "$work" fi } trap cleanup EXIT trap 'cleanup; exit 130' INT trap 'cleanup; exit 143' TERM HUP # PIPE matters more than it looks: `curl sill.sh | sh | head` closes stdout early, and an # untrapped SIGPIPE kills the shell without running the EXIT trap, leaving a downloaded # tarball in the temporary directory. trap 'cleanup; exit 141' PIPE # --- where the binary goes ----------------------------------------------------------- if [ -n "${SILL_INSTALL_DIR:-}" ]; then install_dir="$SILL_INSTALL_DIR" elif [ -n "${HOME:-}" ]; then install_dir="$HOME/.local/bin" else die "HOME is not set, so there is no default install dir. Set SILL_INSTALL_DIR." fi # --- 1. what are we running on? ------------------------------------------------------ need_cmd uname need_cmd tar need_cmd mktemp os="$(uname -s)" arch="$(uname -m)" case "$os" in MINGW* | MSYS* | CYGWIN* | Windows_NT) err "sill has no Windows build." err "" err "The published binaries are macOS and Linux only. Under WSL this script will" err "work as it does on Linux. Otherwise, build from source: cargo install sill" exit 1 ;; esac # A shell running under Rosetta reports x86_64 on an Apple Silicon Mac, which would # install the slower build on a machine that can run the native one. if [ "$os" = Darwin ] && [ "$arch" = x86_64 ]; then if [ "$(sysctl -n sysctl.proc_translated 2>/dev/null || printf '0')" = 1 ]; then say "This shell is running under Rosetta; installing the arm64 build." arch="arm64" fi fi # The Linux builds link against glibc. On a musl system — Alpine, and most distroless # images — they exist but do not run, and the failure is an unhelpful "not found" from # the loader. Say so here instead. if [ "$os" = Linux ]; then if [ -f /etc/alpine-release ] || { command -v ldd >/dev/null 2>&1 && ldd --version 2>&1 | grep -qi musl; }; then err "this looks like a musl system, and sill only publishes glibc Linux builds." err "" err "A glibc binary on musl fails at exec time with a misleading error, so this" err "script stops rather than install one. Build from source instead:" err " cargo install sill" exit 1 fi fi target="$(target_for "$os" "$arch")" || { err "no sill build for $os $arch." err "" err "Prebuilt binaries exist for:" err " macOS aarch64, x86_64" err " Linux aarch64, x86_64 (glibc)" err "" err "Anything else has to be built from source: cargo install sill" exit 1 } # --- 2. pick a downloader, and refuse to continue without a checksum tool ------------ # # The checksum tool is resolved now, before anything is downloaded, so that a machine # without one fails before it has fetched a binary rather than after. if command -v curl >/dev/null 2>&1; then downloader=curl elif command -v wget >/dev/null 2>&1; then downloader=wget else die "neither curl nor wget is on your PATH, so nothing can be downloaded." fi if command -v sha256sum >/dev/null 2>&1; then sha_tool=sha256sum elif command -v shasum >/dev/null 2>&1; then sha_tool=shasum else err "no SHA-256 tool found: neither sha256sum nor shasum is on your PATH." err "" err "This script will not install a binary it cannot verify. It is meant to be piped" err "into a shell, where skipping verification quietly would be the worst possible" err "default. Install GNU coreutils or perl, or build from source:" err " cargo install sill" exit 1 fi work="$(mktemp -d 2>/dev/null || mktemp -d -t sill)" # --- 3. which version? --------------------------------------------------------------- if [ -n "${SILL_VERSION:-}" ]; then # Accept "0.2.0" and "v0.2.0"; the tags are v-prefixed. tag="v${SILL_VERSION#v}" else say "Resolving the latest release of $REPO..." fetch "https://api.github.com/repos/$REPO/releases/latest" "$work/release.json" || { err "could not reach the GitHub API to find the latest release." err "" err "Unauthenticated API requests are rate limited per IP address, so this can" err "also mean you have simply made too many. Name a version to skip the lookup:" err " curl sill.sh | SILL_VERSION=0.1.0 sh" exit 1 } # Splitting on commas first keeps this from matching some other field's value if the # response ever arrives on a single line. tag="$(tr ',' '\n' <"$work/release.json" | sed -n 's/.*"tag_name"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n 1)" if [ -z "$tag" ]; then err "the GitHub API replied, but the response had no tag_name in it." err "" err "Name a version to skip the lookup:" err " curl sill.sh | SILL_VERSION=0.1.0 sh" exit 1 fi fi asset="$BIN-$tag-$target.tar.gz" base="https://github.com/$REPO/releases/download/$tag" # --- 4. download the tarball and the checksums --------------------------------------- say "Downloading $asset..." fetch "$base/$asset" "$work/$asset" || { err "could not download $base/$asset" err "" err "The release exists but may not publish a build for $target, or the version" err "$tag may not exist. Releases are listed at:" err " https://github.com/$REPO/releases" exit 1 } fetch "$base/SHA256SUMS" "$work/SHA256SUMS" || { err "could not download $base/SHA256SUMS" err "" err "The tarball downloaded but its checksums did not, so it cannot be verified." err "Nothing was installed." exit 1 } # --- 5. verify before unpacking ------------------------------------------------------ # # `sha256sum -c` is not used because SHA256SUMS lists every platform's tarball and only # one of them is here; it would report the others as missing. Instead, pull out the one # line that names this asset and compare it. A file that is not listed at all is a # failure, not something to shrug at. expected="$(awk -v want="$asset" ' { name = $2 sub(/^\*/, "", name) # binary-mode marker from sha256sum -b sub(/^\.\//, "", name) if (name == want) { print tolower($1); exit } } ' "$work/SHA256SUMS")" if [ -z "$expected" ]; then err "SHA256SUMS for $tag does not list $asset." err "" err "Without a published checksum there is nothing to verify against, so this stops" err "here. Nothing was installed." exit 1 fi actual="$(sha256_of "$work/$asset" | tr 'A-F' 'a-f')" if [ "$expected" != "$actual" ]; then err "checksum mismatch for $asset." err "" err " expected $expected" err " actual $actual" err "" err "The download does not match the checksum published with the release. Nothing" err "was installed and the download has been deleted. Do not retry blindly: a" err "corrupted transfer and a tampered file look identical from here." exit 1 fi say "Checksum verified ($sha_tool)." # --- 5b. provenance, where it can be checked --------------------------------------------- # # Not fatal by default. `gh attestation verify` fails for several reasons that are not # tampering — no network, an old gh, a release predating attestations — and blocking an # install on those would trade a real failure for a hypothetical one. It says which of # the two checks actually happened rather than implying both. SILL_REQUIRE_PROVENANCE=1 # inverts that for anyone who would rather not install than not know. if command -v gh >/dev/null 2>&1; then if gh attestation verify "$work/$asset" --repo "$REPO" >/dev/null 2>&1; then say "Provenance verified: built by $REPO in GitHub Actions." elif [ "${SILL_REQUIRE_PROVENANCE:-0}" = 1 ]; then err "provenance could not be verified for $asset, and SILL_REQUIRE_PROVENANCE is set." err "" err "This can mean the artifact was not built by $REPO. It can also mean gh could" err "not reach GitHub, or this release predates build attestations. To see which:" err " gh attestation verify --repo $REPO" exit 1 else say "Provenance not verified: gh could not check it. The checksum above did pass." fi elif [ "${SILL_REQUIRE_PROVENANCE:-0}" = 1 ]; then die "SILL_REQUIRE_PROVENANCE is set but the GitHub CLI is not installed, so provenance cannot be checked." else say "Provenance not checked: the GitHub CLI is not installed. The checksum above did pass." fi # --- 6. unpack and install ----------------------------------------------------------- mkdir -p "$work/unpack" tar -xzf "$work/$asset" -C "$work/unpack" [ -f "$work/unpack/$BIN" ] || die "$asset did not contain an executable named $BIN. Nothing was installed." mkdir -p "$install_dir" 2>/dev/null || true [ -d "$install_dir" ] || die "could not create $install_dir. Set SILL_INSTALL_DIR to somewhere writable." [ -w "$install_dir" ] || { err "$install_dir is not writable by you." err "" err "This script does not use sudo. Choose a directory you own instead:" err " curl sill.sh | SILL_INSTALL_DIR=\$HOME/bin sh" exit 1 } mv "$work/unpack/$BIN" "$install_dir/$BIN" chmod 755 "$install_dir/$BIN" # --- 7. confirm it runs, and say where it went --------------------------------------- installed="$("$install_dir/$BIN" --version 2>/dev/null)" || { err "$install_dir/$BIN was installed but does not run on this machine." err "" err "That usually means the wrong build was selected for $os $arch. Please report it" err "at https://github.com/$REPO/issues with the output of: uname -sm" exit 1 } say "" say "$installed installed to $install_dir/$BIN" # `case` on a padded PATH is how you ask "is this exact entry present" without matching # a directory that merely has it as a prefix. case ":$PATH:" in *":$install_dir:"*) say "" say "Next: $BIN doctor" ;; *) rc="$(rc_file)" say "" say "$install_dir is not on your PATH. Add this line to $rc:" say "" if [ "$(basename "${SHELL:-/bin/sh}")" = fish ]; then printf ' fish_add_path %s\n' "$install_dir" else # $PATH is deliberately literal here: this is the line to paste into a config # file, not something to expand now. # shellcheck disable=SC2016 printf ' export PATH="%s:$PATH"\n' "$install_dir" fi say "" say "Then open a new shell. Until you do, run it by full path:" say "" say " $install_dir/$BIN doctor" ;; esac