| 1 | #!/bin/sh |
| 2 | # Installs the T3 Code CLI from a GitHub Release archive. Needs only sh, tar, |
| 3 | # sha256sum or shasum, and curl or wget; no Node, npm, or compiler. |
| 4 | # |
| 5 | # curl -fsSL https://t3.codes/install.sh | sh |
| 6 | # |
| 7 | # Environment: |
| 8 | # T3CODE_CHANNEL release train to follow: stable, nightly, or preview |
| 9 | # (default: stable; preview is a maintainers' test train) |
| 10 | # T3CODE_VERSION exact version to install (overrides T3CODE_CHANNEL) |
| 11 | # T3CODE_HOME T3 home directory (default: ~/.t3) |
| 12 | # T3CODE_INSTALL_BIN_DIR where the `t3` symlink goes (default: ~/.local/bin) |
| 13 | # T3CODE_RELEASE_BASE_URL mirror for releases/download (default: GitHub) |
| 14 | # |
| 15 | # The archive is unpacked into $T3CODE_HOME/runtime/versions/<version>, the |
| 16 | # same layout `t3 service install` uses, so the service reuses this download |
| 17 | # instead of fetching the release again. |
| 18 | set -eu |
| 19 | |
| 20 | repo="pingdotgg/t3code" |
| 21 | base_url="${T3CODE_RELEASE_BASE_URL:-https://github.com/${repo}/releases/download}" |
| 22 | t3_home="${T3CODE_HOME:-$HOME/.t3}" |
| 23 | bin_dir="${T3CODE_INSTALL_BIN_DIR:-$HOME/.local/bin}" |
| 24 | |
| 25 | fail() { |
| 26 | printf '\nt3 install: %s\n' "$1" >&2 |
| 27 | exit 1 |
| 28 | } |
| 29 | |
| 30 | # ANSI stays on stderr, so `curl ... | sh` still gets progress. |
| 31 | interactive=false |
| 32 | if [ -t 2 ] && [ "${TERM:-}" != dumb ]; then interactive=true; fi |
| 33 | reset= bold= muted= accent= green= |
| 34 | if "$interactive" && [ -z "${NO_COLOR:-}" ]; then |
| 35 | reset="$(printf '\033[0m')"; bold="$(printf '\033[1m')" |
| 36 | muted="$(printf '\033[2m')"; accent="$(printf '\033[94m')"; green="$(printf '\033[32m')" |
| 37 | fi |
| 38 | step() { |
| 39 | if "$interactive"; then printf '\r\033[2K %s%s%s' "$muted" "$1" "$reset" >&2 |
| 40 | else printf ' %s\n' "$1" >&2; fi |
| 41 | } |
| 42 | if "$interactive"; then |
| 43 | printf '\n%s' "$bold" >&2 |
| 44 | printf ' %s\n' '██████████ ████████ ' >&2 |
| 45 | printf ' %s\n' ' ███ ▄██▀ T3 Code' >&2 |
| 46 | printf ' %s%s %sCLI installer%s\n' ' ███ ████▄ ' "$reset" "$muted" "$reset$bold" >&2 |
| 47 | printf ' %s\n' ' ███ ▄ ███' >&2 |
| 48 | printf ' %s\n' ' ███ ███████▀ ' >&2 |
| 49 | printf '%s\n' "$reset" >&2 |
| 50 | fi |
| 51 | step "Finding your release..." |
| 52 | |
| 53 | # Exit 44 on a 404 so callers can tell "no such asset" from a network failure. |
| 54 | fetch() { |
| 55 | if command -v curl >/dev/null 2>&1; then |
| 56 | status="$(curl -sSL -w '%{http_code}' "$1" -o "$2")" || return 1 |
| 57 | case "$status" in |
| 58 | 2??) return 0 ;; |
| 59 | 404) return 44 ;; |
| 60 | *) printf 'GET %s returned HTTP %s\n' "$1" "$status" >&2; return 1 ;; |
| 61 | esac |
| 62 | elif command -v wget >/dev/null 2>&1; then |
| 63 | wget -q --server-response "$1" -O "$2" 2>"$2.headers" && rm -f "$2.headers" && return 0 |
| 64 | if grep -q ' 404 ' "$2.headers" 2>/dev/null; then rm -f "$2.headers"; return 44; fi |
| 65 | cat "$2.headers" >&2; rm -f "$2.headers"; return 1 |
| 66 | else |
| 67 | fail "curl or wget is required" |
| 68 | fi |
| 69 | } |
| 70 | |
| 71 | mb() { |
| 72 | tenths=$((($1 * 10 + 524288) / 1048576)) |
| 73 | printf '%s.%s' "$((tenths / 10))" "$((tenths % 10))" |
| 74 | } |
| 75 | # Poll the file written by the downloader; no progress-output parsing or extra request. |
| 76 | download() { |
| 77 | if ! "$interactive"; then fetch "$1" "$2"; return; fi |
| 78 | if command -v curl >/dev/null 2>&1; then |
| 79 | curl -fsSL -D "$2.headers" "$1" -o "$2" 2>"$2.errors" & |
| 80 | else |
| 81 | wget -q --server-response "$1" -O "$2" 2>"$2.headers" & |
| 82 | fi |
| 83 | download_pid=$! |
| 84 | previous=-1 |
| 85 | cr="$(printf '\r')" |
| 86 | while kill -0 "$download_pid" 2>/dev/null; do |
| 87 | bytes=0; total=0 |
| 88 | if [ -f "$2" ]; then bytes="$(wc -c < "$2")"; fi |
| 89 | if [ -f "$2.headers" ]; then |
| 90 | while read -r key value; do |
| 91 | case "$key" in |
| 92 | HTTP/*) total=0 ;; |
| 93 | [Cc]ontent-[Ll]ength:) total="${value%"$cr"}" ;; |
| 94 | esac |
| 95 | done < "$2.headers" |
| 96 | fi |
| 97 | case "$total" in ''|*[!0-9]*) total=0 ;; esac |
| 98 | if [ "$bytes" -ne "$previous" ]; then |
| 99 | if [ "$total" -gt 0 ]; then |
| 100 | percent=$((bytes * 100 / total)); [ "$percent" -le 100 ] || percent=100 |
| 101 | filled=$((percent * 32 / 100)); bar=; rest=; n=0 |
| 102 | while [ "$n" -lt 32 ]; do |
| 103 | if [ "$n" -lt "$filled" ]; then bar="${bar}■"; else rest="${rest}·"; fi |
| 104 | n=$((n + 1)) |
| 105 | done |
| 106 | printf '\r\033[2K %s%s%s%s%s %3d%% %s%s / %s MB%s' "$accent" "$bar" "$reset$muted" "$rest" "$reset" "$percent" "$muted" "$(mb "$bytes")" "$(mb "$total")" "$reset" >&2 |
| 107 | else |
| 108 | printf '\r\033[2K %sDownloading%s %s MB' "$muted" "$reset" "$(mb "$bytes")" >&2 |
| 109 | fi |
| 110 | previous="$bytes" |
| 111 | fi |
| 112 | sleep 0.1 |
| 113 | done |
| 114 | result=0; wait "$download_pid" || result=$? |
| 115 | download_pid= |
| 116 | if [ "$result" -ne 0 ]; then |
| 117 | printf '\n' >&2 |
| 118 | if [ -f "$2.errors" ]; then cat "$2.errors" >&2; else cat "$2.headers" >&2; fi |
| 119 | return "$result" |
| 120 | fi |
| 121 | size="$(mb "$(wc -c < "$2")")" |
| 122 | printf '\r\033[2K %s■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■%s 100%% %s%s / %s MB%s\n' "$accent" "$reset" "$muted" "$size" "$size" "$reset" >&2 |
| 123 | rm -f "$2.headers" "$2.errors" |
| 124 | } |
| 125 | |
| 126 | case "$(uname -s)" in |
| 127 | Darwin) platform="darwin" ;; |
| 128 | Linux) platform="linux" ;; |
| 129 | *) fail "unsupported operating system $(uname -s); use the desktop app or npm" ;; |
| 130 | esac |
| 131 | case "$(uname -m)" in |
| 132 | arm64 | aarch64) arch="arm64" ;; |
| 133 | x86_64 | amd64) arch="x64" ;; |
| 134 | *) fail "unsupported architecture $(uname -m)" ;; |
| 135 | esac |
| 136 | command -v tar >/dev/null 2>&1 || fail "tar is required" |
| 137 | if command -v sha256sum >/dev/null 2>&1; then |
| 138 | checksum() { sha256sum "$1" | cut -d' ' -f1; } |
| 139 | elif command -v shasum >/dev/null 2>&1; then |
| 140 | checksum() { shasum -a 256 "$1" | cut -d' ' -f1; } |
| 141 | else |
| 142 | fail "sha256sum or shasum is required" |
| 143 | fi |
| 144 | |
| 145 | channel="${T3CODE_CHANNEL:-stable}" |
| 146 | version="${T3CODE_VERSION:-}" |
| 147 | if [ -z "$version" ]; then |
| 148 | # Tags are v<semver>; the channel is the prerelease identifier, or none for |
| 149 | # stable. Only tags of the requested train are considered, so a stable |
| 150 | # install can never pick up a nightly or preview build by accident. |
| 151 | case "$channel" in |
| 152 | stable) tag_pattern='v\([0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*\)' ;; |
| 153 | nightly | preview) tag_pattern="v\([0-9][^\"]*-${channel}\.[0-9]*\.[0-9]*\)" ;; |
| 154 | *) fail "T3CODE_CHANNEL must be stable, nightly, or preview" ;; |
| 155 | esac |
| 156 | tmp_index="$(mktemp)" |
| 157 | fetch "https://api.github.com/repos/${repo}/releases?per_page=100" "$tmp_index" |
| 158 | version="$(sed -n "s/.*\"tag_name\": *\"${tag_pattern}\".*/\1/p" "$tmp_index" | head -n 1)" |
| 159 | rm -f "$tmp_index" |
| 160 | [ -n "$version" ] || fail "could not find a ${channel} release; set T3CODE_VERSION" |
| 161 | fi |
| 162 | case "$version" in |
| 163 | *-preview.*) |
| 164 | printf '%s\n' \ |
| 165 | "t3 ${version} is a preview build." \ |
| 166 | " Preview builds are cut by maintainers from unreleased branches to exercise the release" \ |
| 167 | " pipeline. They can be broken, receive no fixes, and are never offered as updates." \ |
| 168 | " Set T3CODE_CHANNEL=stable (the default) for a supported build." >&2 |
| 169 | if [ "$channel" != "preview" ] && [ -z "${T3CODE_VERSION:-}" ]; then |
| 170 | fail "refusing a preview build that was not explicitly requested" |
| 171 | fi |
| 172 | ;; |
| 173 | esac |
| 174 | |
| 175 | stem="t3-${version}-${platform}-${arch}" |
| 176 | archive="${stem}.tar.gz" |
| 177 | versions_dir="${t3_home}/runtime/versions" |
| 178 | target_dir="${versions_dir}/${version}" |
| 179 | |
| 180 | if [ -f "${target_dir}/.install-complete" ] && [ "$(cat "${target_dir}/.install-complete")" = "$version" ]; then |
| 181 | step "Version ${version} is already downloaded." |
| 182 | else |
| 183 | mkdir -p "$versions_dir" |
| 184 | staging="$(mktemp -d "${versions_dir}/.staging-XXXXXX")" |
| 185 | download_pid= |
| 186 | trap '[ -z "$download_pid" ] || { kill "$download_pid" 2>/dev/null || true; wait "$download_pid" 2>/dev/null || true; }; rm -rf "$staging"' EXIT |
| 187 | trap 'printf "\n" >&2; exit 130' INT |
| 188 | trap 'printf "\n" >&2; exit 143' TERM |
| 189 | |
| 190 | if "$interactive"; then printf '\r\033[2K' >&2; fi |
| 191 | printf ' %sInstalling%s T3 Code %s%s%s\n\n' "$muted" "$reset" "$bold" "$version" "$reset" >&2 |
| 192 | step "Downloading..." |
| 193 | fetch_status=0 |
| 194 | fetch "${base_url}/v${version}/SHA256SUMS" "${staging}/SHA256SUMS" || fetch_status=$? |
| 195 | if [ "$fetch_status" -eq 44 ]; then |
| 196 | fail "t3 ${version} has no release archive for ${platform}-${arch}; releases before the self-contained CLI can only be installed with \`npm install -g t3@${version}\`" |
| 197 | elif [ "$fetch_status" -ne 0 ]; then |
| 198 | fail "could not download the release checksums" |
| 199 | fi |
| 200 | download "${base_url}/v${version}/${archive}" "${staging}/${archive}" |
| 201 | |
| 202 | step "Verifying the download..." |
| 203 | expected="$(grep " \*\{0,1\}${archive}\$" "${staging}/SHA256SUMS" | cut -d' ' -f1)" |
| 204 | [ -n "$expected" ] || fail "${archive} is not listed in SHA256SUMS" |
| 205 | actual="$(checksum "${staging}/${archive}")" |
| 206 | [ "$actual" = "$expected" ] || fail "checksum mismatch for ${archive}" |
| 207 | |
| 208 | step "Extracting T3 Code..." |
| 209 | tar -xzf "${staging}/${archive}" -C "$staging" --strip-components=1 |
| 210 | rm -f "${staging}/${archive}" "${staging}/SHA256SUMS" |
| 211 | "${staging}/t3" --version >/dev/null || fail "the downloaded executable does not run" |
| 212 | printf '%s\n' "$version" > "${staging}/.install-complete" |
| 213 | |
| 214 | rm -rf "$target_dir" |
| 215 | mv "$staging" "$target_dir" |
| 216 | trap - EXIT |
| 217 | fi |
| 218 | |
| 219 | step "Setting up the t3 command..." |
| 220 | mkdir -p "$bin_dir" |
| 221 | ln -sfn "${target_dir}/t3" "${bin_dir}/t3" |
| 222 | if "$interactive"; then printf '\r\033[2K' >&2; fi |
| 223 | printf ' %sInstalled T3 Code %s%s\n\n' "$green" "$version" "$reset" >&2 |
| 224 | case ":${PATH}:" in |
| 225 | *":${bin_dir}:"*) printf ' Run %st3%s to get started.\n\n' "$bold" "$reset" ;; |
| 226 | *) printf ' Add %s to your PATH, then run %st3%s.\n\n' "$bin_dir" "$bold" "$reset" ;; |
| 227 | esac |