#!/bin/sh # Install the `atok` CLI: detect the platform, fetch the release, VERIFY it # against SHA256SUMS, unpack it, put it on PATH, and ask the binary what it is. # # curl -fsSL https://authtoken.com/install.sh | sh # curl -fsSL https://authtoken.com/install.sh | sh -s -- --version v1.2.3 # # This file lives at scripts/install.sh in the repo and is PUBLISHED as # https://authtoken.com/install.sh — the same bytes uploaded to %h/dl/install.sh # beside the artifacts, with the release, not with the backend deploy (ticket # 129). Nothing copies it out of the repo onto the box automatically. # # POSIX sh, no bashisms (WSL2's /bin/sh is dash, and dash is the first consumer): # no `local`, no arrays, no [[ ]], no ${x^^}. `shellcheck -s sh` is the gate. # # THE WHOLE BODY IS A FUNCTION INVOKED ON THE LAST LINE. A shell reads a pipe as # it arrives, so a connection cut mid-transfer would otherwise run whatever # prefix of this file made it through. With the call last, a truncated download # defines some functions and runs nothing. # # What it deliberately does NOT do: # * never runs sudo, and never writes to a directory it was not pointed at; # * never edits a dotfile — it REPORTS whether the install dir is on PATH and # leaves the shell config to its owner (ticket 130: "say which was used and # whether it is on PATH"); # * never infers "latest" from anything but /dl/VERSION; # * never installs a byte it has not checksummed. # # --base-url exists to TEST THIS SCRIPT against a local fixture, the way # release-cli.sh's --allow-dirty exists to test that one. It is not part of the # published one-liner and it is what scripts/test-install.sh drives. set -eu ATOK_DEFAULT_BASE_URL="https://authtoken.com" atok_say() { echo "$*"; } atok_warn() { echo "install.sh: $*" >&2; } atok_die() { echo "install.sh: $*" >&2; exit 1; } atok_usage() { cat <<'EOS' usage: install.sh [--version vX.Y.Z] [--bin-dir DIR] --version vX.Y.Z install this exact release instead of the current one (the current one is whatever https://authtoken.com/dl/VERSION names) --bin-dir DIR install into DIR instead of ~/.local/bin. A system-wide install is `--bin-dir /usr/local/bin`, which needs a shell that can write there; this script never calls sudo itself. --help this text Piped into a shell, options go after `-s --`: curl -fsSL https://authtoken.com/install.sh | sh -s -- --version v1.2.3 EOS } # Fetch $1 to $2. On the default (https) origin the protocol is pinned on the # request AND across redirects: `-L` without --proto-redir will happily follow a # 301 into plaintext, which is the one downgrade a pinned-https installer must # not accept. -f so an error page is an error and not the "archive". atok_fetch() { if [ "$atok_plain_http_ok" = yes ]; then curl -fsSL -o "$2" "$1" else curl -fsSL --proto '=https' --proto-redir '=https' -o "$2" "$1" fi } atok_main() { atok_version_arg="" atok_bin_dir="" atok_base_url="$ATOK_DEFAULT_BASE_URL" while [ $# -gt 0 ]; do case "$1" in --version) [ $# -ge 2 ] || atok_die "--version needs a value (e.g. --version v1.2.3)" atok_version_arg="$2" shift 2 ;; --version=*) atok_version_arg="${1#--version=}" shift ;; --bin-dir) [ $# -ge 2 ] || atok_die "--bin-dir needs a value (e.g. --bin-dir /usr/local/bin)" atok_bin_dir="$2" shift 2 ;; --bin-dir=*) atok_bin_dir="${1#--bin-dir=}" shift ;; --base-url) [ $# -ge 2 ] || atok_die "--base-url needs a value" atok_base_url="$2" shift 2 ;; --base-url=*) atok_base_url="${1#--base-url=}" shift ;; --help | -h) atok_usage exit 0 ;; *) atok_die "unknown option '$1' (--help for usage)" ;; esac done atok_base_url="${atok_base_url%/}" atok_plain_http_ok=no case "$atok_base_url" in https://*) ;; http://*) # Only reachable through --base-url, which only the test harness passes. atok_plain_http_ok=yes atok_warn "--base-url is plain http: this is the testing path, not an install anyone should trust" ;; *) atok_die "--base-url must be http(s) — got '$atok_base_url'" ;; esac # ---- the tools, each named ------------------------------------------- # Checked before anything is fetched, and reported one per line rather than # dying at the first: someone on a minimal image who installs curl only to # meet the missing tar on the next run was told a third of what we knew. atok_missing="" for atok_tool in curl tar sha256sum uname mktemp; do command -v "$atok_tool" >/dev/null 2>&1 || atok_missing="$atok_missing $atok_tool" done if [ -n "$atok_missing" ]; then for atok_tool in $atok_missing; do atok_warn "$atok_tool is not on PATH" done atok_die "refusing to install: this script needs curl (fetch), tar (unpack), sha256sum (verify), uname (detect) and mktemp" fi # ---- the platform, refused rather than guessed ------------------------ atok_uname_s="$(uname -s)" atok_uname_m="$(uname -m)" case "$atok_uname_s" in Linux) atok_os=linux ;; Darwin) # Named and refused with the reason, never handed an unnotarized # Mach-O. The download page's own path is ticket 131's to decide, so # this points at the site rather than at a URL that may not exist. atok_die "macOS is not supported yet: the darwin build is not signed and not notarized, so it is not published rather than handed over broken. See https://authtoken.com for what exists today." ;; *) atok_die "unsupported platform $atok_uname_s/$atok_uname_m — this installer publishes linux/amd64 and linux/arm64 only (windows archives are on the download page; macOS is not built)" ;; esac case "$atok_uname_m" in x86_64 | amd64) atok_arch=amd64 ;; aarch64 | arm64) atok_arch=arm64 ;; *) atok_die "unsupported platform $atok_uname_s/$atok_uname_m — this installer publishes linux/amd64 and linux/arm64 only (windows archives are on the download page; macOS is not built)" ;; esac # ---- the version -------------------------------------------------------- atok_tmp="$(mktemp -d)" # The staged binary is named here so the trap can clean it up: it is written # INTO the install directory (see below), so a kill between the copy and the # rename would otherwise leave a dot-file in the user's ~/.local/bin. The # trap bodies are single-quoted, so both variables are read when the trap # fires and not when it is set. atok_stage="" # INT/TERM as well as EXIT: a Ctrl-C halfway through a download otherwise # leaves a directory of unverified bytes in /tmp. trap 'rm -rf "$atok_tmp"; [ -n "$atok_stage" ] && rm -f "$atok_stage"; true' EXIT trap 'rm -rf "$atok_tmp"; [ -n "$atok_stage" ] && rm -f "$atok_stage"; exit 130' INT TERM if [ -n "$atok_version_arg" ]; then atok_version="$atok_version_arg" atok_version_source="--version" else atok_fetch "$atok_base_url/dl/VERSION" "$atok_tmp/VERSION" || atok_die "could not fetch $atok_base_url/dl/VERSION — the pointer that names the current release" # tr -d '\r': a pointer file that ever gets edited on Windows would # otherwise produce a version string with a CR in it, which fails as a # URL path with no legible reason. atok_version="$(tr -d '\r\n' <"$atok_tmp/VERSION")" atok_version_source="$atok_base_url/dl/VERSION" fi # cli.md §Packaging: a release version always starts with `v` and `dev` # never does. Applied to a pinned --version too, not only to the pointer — # a typo'd pin otherwise becomes a 404 three steps later. case "$atok_version" in v[0-9]*.[0-9]*.[0-9]*) ;; *) atok_die "'$atok_version' (from $atok_version_source) is not a release version — releases are v..; 'dev' is an unstamped local build and is never published" ;; esac # The glob above is byte-identical to release-cli.sh's on purpose (a # consumer stricter than its producer is the drift 126/127/130 exist to # forbid) — but a shell glob's `*` matches `/` as well, and this string is # about to become both a URL path segment and a filename under the temp # dir. `v1.2.3/../../…` would satisfy the glob and escape the temp dir, so # the CHARACTER SET is checked separately from the shape. case "$atok_version" in *[!0-9A-Za-z.-]*) atok_die "'$atok_version' (from $atok_version_source) contains characters a version may not have — a version is a path segment here, and this one is not one" ;; esac # ---- where it goes ------------------------------------------------------ if [ -z "$atok_bin_dir" ]; then [ -n "${HOME:-}" ] || atok_die "HOME is not set, so ~/.local/bin cannot be resolved — pass --bin-dir DIR" atok_bin_dir="$HOME/.local/bin" fi # mkdir -p, not install -d: the default location routinely does not exist on # a fresh box and creating it is not a privileged act. mkdir -p "$atok_bin_dir" 2>/dev/null || atok_die "cannot create $atok_bin_dir — pass --bin-dir DIR, or create it yourself" [ -w "$atok_bin_dir" ] || atok_die "$atok_bin_dir is not writable by this user — this script never calls sudo; re-run it with --bin-dir DIR somewhere you can write, or as a user who can write there" atok_target="$atok_bin_dir/atok" # What is already there, reported rather than acted on. The install always # replaces the binary (ticket 130 DoD 4: "running it twice replaces the # binary"), so this is a line of output and not a short circuit — a # short-circuit "already current" would also skip repairing a truncated or # chmod-ed binary that reports the right version. atok_installed="" if [ -x "$atok_target" ]; then atok_installed="$("$atok_target" version --json 2>/dev/null | sed -n 's/.*"version":"\([^"]*\)".*/\1/p' || true)" fi # ---- fetch, then verify, then unpack ------------------------------------ atok_archive="atok_${atok_version}_${atok_os}_${atok_arch}.tar.gz" atok_dir_url="$atok_base_url/dl/$atok_version" atok_say "install.sh: $atok_os/$atok_arch, $atok_version (from $atok_version_source)" atok_fetch "$atok_dir_url/$atok_archive" "$atok_tmp/$atok_archive" || atok_die "could not fetch $atok_dir_url/$atok_archive — is $atok_version a published release for $atok_os/$atok_arch?" atok_fetch "$atok_dir_url/SHA256SUMS" "$atok_tmp/SHA256SUMS" || atok_die "could not fetch $atok_dir_url/SHA256SUMS — refusing to install an archive that cannot be verified" # SHA256SUMS covers every archive of the release and we fetched one, so # `sha256sum -c SHA256SUMS` would report three missing files. The one line # is extracted instead, and it must be EXACTLY one: zero means this archive # is not in the release's checksum file at all — which a --ignore-missing # would have silently passed. grep -F " $atok_archive" "$atok_tmp/SHA256SUMS" >"$atok_tmp/SHA256SUMS.one" || true atok_lines="$(wc -l <"$atok_tmp/SHA256SUMS.one" | tr -d ' ')" [ "$atok_lines" = 1 ] || atok_die "SHA256SUMS at $atok_dir_url has $atok_lines lines for $atok_archive, expected exactly 1 — refusing to install" ( cd "$atok_tmp" && sha256sum -c SHA256SUMS.one ) >/dev/null 2>&1 || atok_die "CHECKSUM MISMATCH for $atok_archive against $atok_dir_url/SHA256SUMS. Nothing was installed and the download was discarded. The bytes you received are not the bytes this release published: retry, and if it repeats, do not run them." atok_say "install.sh: $atok_archive verified against SHA256SUMS" mkdir -p "$atok_tmp/unpacked" tar xzf "$atok_tmp/$atok_archive" -C "$atok_tmp/unpacked" || atok_die "$atok_archive verified but could not be unpacked" [ -f "$atok_tmp/unpacked/atok" ] || atok_die "$atok_archive does not contain 'atok' at its root — this is not an atok release archive" # ---- stage, check, THEN replace ----------------------------------------- # The binary is copied into the DESTINATION directory under a dot-name and # only renamed onto the target once it has answered for itself. Two reasons # for that order, and the second is the one that took a review to see: # # * `mv` within one directory is atomic, so a concurrent `atok` sees the # old binary or the new one and never a half-written file — and it can # replace a binary that is currently RUNNING, which writing in place # cannot (ETXTBSY); # * the version check below runs on the STAGED file, before the rename. An # earlier draft checked after it, which meant a mis-stamped or wrong-arch # archive destroyed a working install and left the bad binary in its # place. Every refusal in this script now leaves what was there alone. # # The staged file is executed from the install directory rather than from # the temp dir on purpose: /tmp is routinely mounted noexec, and the check # must run where the binary is going to live anyway. chmod 0755 "$atok_tmp/unpacked/atok" atok_stage="$atok_bin_dir/.atok.install.$$" cp "$atok_tmp/unpacked/atok" "$atok_stage" || { rm -f "$atok_stage" atok_die "could not write to $atok_bin_dir" } chmod 0755 "$atok_stage" # The third place the version contract is checked (atok version, the release # build's self-check, here) and the only one on the user's machine. atok_reported="$("$atok_stage" version --json 2>/dev/null | sed -n 's/.*"version":"\([^"]*\)".*/\1/p' || true)" [ -n "$atok_reported" ] || atok_die "the downloaded atok printed no version when asked — it does not run on this machine. $atok_target is untouched." [ "$atok_reported" = "$atok_version" ] || atok_die "the downloaded atok reports version '$atok_reported' but the release says '$atok_version' — the archive and the version it was published under disagree. $atok_target is untouched." mv -f "$atok_stage" "$atok_target" || { rm -f "$atok_stage" atok_die "could not replace $atok_target" } atok_stage="" # ---- say what happened -------------------------------------------------- if [ -z "$atok_installed" ]; then atok_say "install.sh: installed atok $atok_version to $atok_target" elif [ "$atok_installed" = "$atok_version" ]; then atok_say "install.sh: $atok_target was already atok $atok_version — reinstalled the same version" else atok_say "install.sh: replaced atok $atok_installed with $atok_version at $atok_target" fi case ":${PATH:-}:" in *":$atok_bin_dir:"*) atok_say "install.sh: $atok_bin_dir is on PATH — run 'atok help' to start" ;; *) # Reported, not fixed. Editing a shell rc file from a piped installer # means guessing which of .profile/.bashrc/.zshrc is read, appending to # a file this script does not own, and doing it again on the next run; # printing the line the user can paste costs them one command and costs # nobody a surprise. atok_say "install.sh: $atok_bin_dir is NOT on PATH. Run atok as $atok_target, or add it:" atok_say " export PATH=\"$atok_bin_dir:\$PATH\" # and put that line in your shell's rc file" ;; esac } atok_main "$@"