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.
18set -eu
19
20repo="pingdotgg/t3code"
21base_url="${T3CODE_RELEASE_BASE_URL:-https://github.com/${repo}/releases/download}"
22t3_home="${T3CODE_HOME:-$HOME/.t3}"
23bin_dir="${T3CODE_INSTALL_BIN_DIR:-$HOME/.local/bin}"
24
25fail() {
26 printf '\nt3 install: %s\n' "$1" >&2
27 exit 1
28}
29
30# ANSI stays on stderr, so `curl ... | sh` still gets progress.
31interactive=false
32if [ -t 2 ] && [ "${TERM:-}" != dumb ]; then interactive=true; fi
33reset= bold= muted= accent= green=
34if "$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')"
37fi
38step() {
39 if "$interactive"; then printf '\r\033[2K %s%s%s' "$muted" "$1" "$reset" >&2
40 else printf ' %s\n' "$1" >&2; fi
41}
42if "$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
50fi
51step "Finding your release..."
52
53# Exit 44 on a 404 so callers can tell "no such asset" from a network failure.
54fetch() {
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
71mb() {
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.
76download() {
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
126case "$(uname -s)" in
127 Darwin) platform="darwin" ;;
128 Linux) platform="linux" ;;
129 *) fail "unsupported operating system $(uname -s); use the desktop app or npm" ;;
130esac
131case "$(uname -m)" in
132 arm64 | aarch64) arch="arm64" ;;
133 x86_64 | amd64) arch="x64" ;;
134 *) fail "unsupported architecture $(uname -m)" ;;
135esac
136command -v tar >/dev/null 2>&1 || fail "tar is required"
137if command -v sha256sum >/dev/null 2>&1; then
138 checksum() { sha256sum "$1" | cut -d' ' -f1; }
139elif command -v shasum >/dev/null 2>&1; then
140 checksum() { shasum -a 256 "$1" | cut -d' ' -f1; }
141else
142 fail "sha256sum or shasum is required"
143fi
144
145channel="${T3CODE_CHANNEL:-stable}"
146version="${T3CODE_VERSION:-}"
147if [ -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"
161fi
162case "$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 ;;
173esac
174
175stem="t3-${version}-${platform}-${arch}"
176archive="${stem}.tar.gz"
177versions_dir="${t3_home}/runtime/versions"
178target_dir="${versions_dir}/${version}"
179
180if [ -f "${target_dir}/.install-complete" ] && [ "$(cat "${target_dir}/.install-complete")" = "$version" ]; then
181 step "Version ${version} is already downloaded."
182else
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
217fi
218
219step "Setting up the t3 command..."
220mkdir -p "$bin_dir"
221ln -sfn "${target_dir}/t3" "${bin_dir}/t3"
222if "$interactive"; then printf '\r\033[2K' >&2; fi
223printf ' %sInstalled T3 Code %s%s\n\n' "$green" "$version" "$reset" >&2
224case ":${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" ;;
227esac