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}"
23case "$t3_home" in
24 /*) ;;
25 *)
26 # Preserve cwd's trailing newlines; trim only pwd's newline and the sentinel.
27 t3_cwd="$(pwd -P && printf .)"
28 t3_home="${t3_cwd%??}/$t3_home"
29 ;;
30esac
31bin_dir="${T3CODE_INSTALL_BIN_DIR:-$HOME/.local/bin}"
32# Trailing slashes would put `//` in the paths the t3 command guard compares.
33while [ "${t3_home%/}" != "$t3_home" ] && [ "$t3_home" != / ]; do t3_home="${t3_home%/}"; done
34while [ "${bin_dir%/}" != "$bin_dir" ] && [ "$bin_dir" != / ]; do bin_dir="${bin_dir%/}"; done
35# Absolute, so the PATH hint never adds a folder that changes with the working directory.
36case "$bin_dir" in /*) ;; *) bin_dir="$PWD/$bin_dir" ;; esac
37
38fail() {
39 printf '\nt3 install: %s\n' "$1" >&2
40 exit 1
41}
42
43# ANSI stays on stderr, so `curl ... | sh` still gets progress.
44interactive=false
45if [ -t 2 ] && [ "${TERM:-}" != dumb ]; then interactive=true; fi
46reset= bold= muted= accent= green=
47if "$interactive" && [ -z "${NO_COLOR:-}" ]; then
48 reset="$(printf '\033[0m')"; bold="$(printf '\033[1m')"
49 muted="$(printf '\033[2m')"; accent="$(printf '\033[94m')"; green="$(printf '\033[32m')"
50fi
51step() {
52 if "$interactive"; then printf '\r\033[2K %s%s%s' "$muted" "$1" "$reset" >&2
53 else printf ' %s\n' "$1" >&2; fi
54}
55if "$interactive"; then
56 printf '\n%s' "$bold" >&2
57 printf ' %s\n' '██████████ ████████ ' >&2
58 printf ' %s\n' ' ███ ▄██▀ T3 Code' >&2
59 printf ' %s%s %sCLI installer%s\n' ' ███ ████▄ ' "$reset" "$muted" "$reset$bold" >&2
60 printf ' %s\n' ' ███ ▄ ███' >&2
61 printf ' %s\n' ' ███ ███████▀ ' >&2
62 printf '%s\n' "$reset" >&2
63fi
64step "Finding your release..."
65
66# Sleeps 1, 2, then 4 s before retrying a 408, 429, 5xx, or network error. Not curl
67# --retry: older curl waits out any Retry-After, however long.
68backoff() {
69 case "$1" in 408 | 429) ;; 4??) return 1 ;; esac
70 [ "$2" -lt 4 ] && sleep "$((1 << ($2 - 1)))"
71}
72
73# Exit 44 on a 404 so callers can tell "no such asset" from a network failure.
74fetch() {
75 if command -v curl >/dev/null 2>&1; then
76 attempt=1
77 while :; do
78 code=0; status="$(curl -sSL -H "Accept: ${3:-*/*}" -w '%{http_code}' "$1" -o "$2")" || code=$?
79 case "$code:$status" in 0:2??) break ;; esac
80 backoff "$status" "$attempt" || break
81 attempt=$((attempt + 1))
82 done
83 [ "$code" -eq 0 ] || return 1
84 case "$status" in
85 2??) return 0 ;;
86 404) return 44 ;;
87 *) printf 'GET %s returned HTTP %s\n' "$1" "$status" >&2; return 1 ;;
88 esac
89 elif command -v wget >/dev/null 2>&1; then
90 wget -q --server-response --header="Accept: ${3:-*/*}" "$1" -O "$2" 2>"$2.headers" && rm -f "$2.headers" && return 0
91 if grep -q ' 404 ' "$2.headers" 2>/dev/null; then rm -f "$2.headers"; return 44; fi
92 cat "$2.headers" >&2; rm -f "$2.headers"; return 1
93 else
94 fail "curl or wget is required"
95 fi
96}
97
98mb() {
99 tenths=$((($1 * 10 + 524288) / 1048576))
100 printf '%s.%s' "$((tenths / 10))" "$((tenths % 10))"
101}
102download_start() {
103 if command -v curl >/dev/null 2>&1; then
104 curl -fsSL -w '%{http_code}' -D "$2.headers" "$1" -o "$2" >"$2.status" 2>"$2.errors" &
105 else
106 wget -q --server-response "$1" -O "$2" 2>"$2.headers" &
107 fi
108 download_pid=$!
109}
110# Reaps the finished download, then restarts it if curl hit a transient error.
111download_retry() {
112 result=0; wait "$download_pid" || result=$?
113 download_pid=
114 [ "$result" -ne 0 ] && [ -f "$2.status" ] && backoff "$(cat "$2.status")" "$attempt" || return 1
115 attempt=$((attempt + 1))
116 download_start "$1" "$2"
117}
118# Poll the file written by the downloader; no progress-output parsing or extra request.
119download() {
120 if ! "$interactive"; then fetch "$1" "$2"; return; fi
121 attempt=1
122 download_start "$1" "$2"
123 previous=-1
124 cr="$(printf '\r')"
125 while kill -0 "$download_pid" 2>/dev/null || download_retry "$1" "$2"; do
126 bytes=0; total=0
127 if [ -f "$2" ]; then bytes="$(wc -c < "$2")"; fi
128 if [ -f "$2.headers" ]; then
129 while read -r key value; do
130 case "$key" in
131 HTTP/*) total=0 ;;
132 [Cc]ontent-[Ll]ength:) total="${value%"$cr"}" ;;
133 esac
134 done < "$2.headers"
135 fi
136 case "$total" in ''|*[!0-9]*) total=0 ;; esac
137 if [ "$bytes" -ne "$previous" ]; then
138 if [ "$total" -gt 0 ]; then
139 percent=$((bytes * 100 / total)); [ "$percent" -le 100 ] || percent=100
140 filled=$((percent * 32 / 100)); bar=; rest=; n=0
141 while [ "$n" -lt 32 ]; do
142 if [ "$n" -lt "$filled" ]; then bar="${bar}■"; else rest="${rest}·"; fi
143 n=$((n + 1))
144 done
145 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
146 else
147 printf '\r\033[2K %sDownloading%s %s MB' "$muted" "$reset" "$(mb "$bytes")" >&2
148 fi
149 previous="$bytes"
150 fi
151 sleep 0.1
152 done
153 if [ "$result" -ne 0 ]; then
154 printf '\n' >&2
155 if [ -f "$2.errors" ]; then cat "$2.errors" >&2; else cat "$2.headers" >&2; fi
156 return "$result"
157 fi
158 size="$(mb "$(wc -c < "$2")")"
159 printf '\r\033[2K %s■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■%s 100%% %s%s / %s MB%s\n' "$accent" "$reset" "$muted" "$size" "$size" "$reset" >&2
160 rm -f "$2.headers" "$2.errors" "$2.status"
161}
162
163case "$(uname -s)" in
164 Darwin) platform="darwin" ;;
165 Linux) platform="linux" ;;
166 *) fail "unsupported operating system $(uname -s); use the desktop app or npm" ;;
167esac
168case "$(uname -m)" in
169 arm64 | aarch64) arch="arm64" ;;
170 x86_64 | amd64) arch="x64" ;;
171 *) fail "unsupported architecture $(uname -m)" ;;
172esac
173if [ "${platform}-${arch}" = darwin-x64 ]; then
174 fail "there is no t3 CLI for Intel Macs. Install the desktop app from https://t3.codes/download, or run a server from source: https://github.com/${repo}/blob/main/docs/user/install.md#intel-macs"
175fi
176command -v tar >/dev/null 2>&1 || fail "tar is required"
177if command -v sha256sum >/dev/null 2>&1; then
178 checksum() { sha256sum < "$1" | cut -d' ' -f1; }
179elif command -v shasum >/dev/null 2>&1; then
180 checksum() { shasum -a 256 < "$1" | cut -d' ' -f1; }
181else
182 fail "sha256sum or shasum is required"
183fi
184
185channel="${T3CODE_CHANNEL:-stable}"
186version="${T3CODE_VERSION:-}"
187if [ -z "$version" ]; then
188 case "$channel" in
189 stable) tag_pattern='v\([0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*\)' ;;
190 nightly | preview) tag_pattern="v\([0-9][^\"]*-${channel}\.[0-9]*\.[0-9]*\)" ;;
191 *) fail "T3CODE_CHANNEL must be stable, nightly, or preview" ;;
192 esac
193 tmp_index="$(mktemp)"
194 if [ "$channel" = stable ]; then
195 # release.yml marks only plain stable releases latest. This is a github.com
196 # page, so it is not cut off by a page of newer nightlies or the API quota.
197 fetch "https://github.com/${repo}/releases/latest" "$tmp_index" application/json
198 version="$(sed -n "s/.*\"tag_name\": *\"${tag_pattern}\".*/\1/p" "$tmp_index" | head -n 1)"
199 else
200 page=1
201 while [ -z "$version" ] && [ "$page" -le 10 ]; do
202 fetch "https://api.github.com/repos/${repo}/releases?per_page=100&page=${page}" "$tmp_index"
203 version="$(sed -n "s/.*\"tag_name\": *\"${tag_pattern}\".*/\1/p" "$tmp_index" | head -n 1)"
204 grep -q '"tag_name"' "$tmp_index" || break
205 page=$((page + 1))
206 done
207 fi
208 rm -f "$tmp_index"
209 [ -n "$version" ] || fail "could not find a ${channel} release; set T3CODE_VERSION"
210fi
211case "$version" in
212 *-preview.*)
213 printf '%s\n' \
214 "t3 ${version} is a preview build." \
215 " Preview builds are cut by maintainers from unreleased branches to exercise the release" \
216 " pipeline. They can be broken, receive no fixes, and are never offered as updates." \
217 " Set T3CODE_CHANNEL=stable (the default) for a supported build." >&2
218 if [ "$channel" != "preview" ] && [ -z "${T3CODE_VERSION:-}" ]; then
219 fail "refusing a preview build that was not explicitly requested"
220 fi
221 ;;
222esac
223
224stem="t3-${version}-${platform}-${arch}"
225archive="${stem}.tar.gz"
226versions_dir="${t3_home}/runtime/versions"
227target_dir="${versions_dir}/${version}"
228
229if [ -f "${target_dir}/.install-complete" ] && [ "$(cat "${target_dir}/.install-complete")" = "$version" ]; then
230 step "Version ${version} is already downloaded."
231else
232 mkdir -p "$versions_dir"
233 staging="$(mktemp -d "${versions_dir}/.staging-XXXXXX")"
234 download_pid=
235 trap '[ -z "$download_pid" ] || { kill "$download_pid" 2>/dev/null || true; wait "$download_pid" 2>/dev/null || true; }; rm -rf "$staging"' EXIT
236 trap 'printf "\n" >&2; exit 130' INT
237 trap 'printf "\n" >&2; exit 143' TERM
238
239 if "$interactive"; then printf '\r\033[2K' >&2; fi
240 printf ' %sInstalling%s T3 Code %s%s%s\n\n' "$muted" "$reset" "$bold" "$version" "$reset" >&2
241 step "Downloading..."
242 fetch_status=0
243 fetch "${base_url}/v${version}/SHA256SUMS" "${staging}/SHA256SUMS" || fetch_status=$?
244 if [ "$fetch_status" -eq 44 ]; then
245 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}\`"
246 elif [ "$fetch_status" -ne 0 ]; then
247 fail "could not download the release checksums"
248 fi
249 expected="$(grep " \*\{0,1\}${archive}\$" "${staging}/SHA256SUMS" | cut -d' ' -f1)"
250 [ -n "$expected" ] || fail "t3 ${version} has no release archive for ${platform}-${arch}"
251 download "${base_url}/v${version}/${archive}" "${staging}/${archive}" || fail "could not download ${archive}"
252
253 step "Verifying the download..."
254 actual="$(checksum "${staging}/${archive}")"
255 [ "$actual" = "$expected" ] || fail "checksum mismatch for ${archive}"
256
257 step "Extracting T3 Code..."
258 tar -xzf "${staging}/${archive}" -C "$staging" --strip-components=1
259 rm -f "${staging}/${archive}" "${staging}/SHA256SUMS"
260 "${staging}/t3" --version >/dev/null || fail "the downloaded executable does not run"
261 printf '%s\n' "$version" > "${staging}/.install-complete"
262
263 rm -rf "$target_dir"
264 mv "$staging" "$target_dir"
265 trap - EXIT
266fi
267
268step "Setting up the t3 command..."
269mkdir -p "$bin_dir"
270# Replace only this home's own t3 links: into its versions, or the desktop app's launcher.
271# Anything that is not a symlink (a script, a copied binary) belongs to someone else.
272# Exactly <versions>/<version>/t3 ($1 under $2), never another program inside a version.
273version_t3() {
274 case "$1" in "$2"/*) ;; *) return 1 ;; esac
275 case "${1#"$2"/}" in */*/*) return 1 ;; */t3) return 0 ;; *) return 1 ;; esac
276}
277slot_empty=true
278if [ -e "${bin_dir}/t3" ] || [ -L "${bin_dir}/t3" ]; then
279 slot_empty=false
280 owned=false
281 if [ -L "${bin_dir}/t3" ]; then
282 existing="$(readlink "${bin_dir}/t3" 2>/dev/null || true)"
283 case "$existing" in /*) ;; *) existing="${bin_dir}/${existing}" ;; esac
284 real_versions_dir="$(CDPATH='' cd -P -- "$versions_dir" && pwd)"
285 # Through a symlinked T3 home the link text names the alias; resolve its folder to compare.
286 existing_dir="$(CDPATH='' cd -P -- "$(dirname -- "$existing")" 2>/dev/null && pwd || true)"
287 # The text alone counts only without . or .. segments (a pruned version leaves a dangling link).
288 case "$existing" in
289 */./* | */../* | */. | */..) ;;
290 *)
291 if version_t3 "$existing" "$versions_dir" || version_t3 "$existing" "$real_versions_dir" ||
292 [ "$existing" = "${t3_home}/bin/t3" ]; then owned=true; fi
293 ;;
294 esac
295 if [ -n "$existing_dir" ] && version_t3 "${existing_dir}/${existing##*/}" "$real_versions_dir"; then
296 owned=true
297 fi
298 fi
299 "$owned" || fail "another t3 is already at ${bin_dir}/t3. Remove it and run this again, or run ${target_dir}/t3 directly."
300fi
301# An empty spot is linked without -f, so a file that appears after the check above is kept.
302if "$slot_empty"; then
303 ln -s "${target_dir}/t3" "${bin_dir}/t3" || fail "could not link ${bin_dir}/t3; something else was created there."
304else
305 # Known race, accepted: a file written here after the ownership check above is overwritten.
306 ln -sfn "${target_dir}/t3" "${bin_dir}/t3"
307fi
308# Pruning keeps what this launcher points at; a background service cannot see bin_dir otherwise.
309launcher="$(CDPATH='' cd -P -- "$bin_dir" && pwd)/t3"
310launchers="${t3_home}/runtime/launchers"
311grep -qxF "$launcher" "$launchers" 2>/dev/null || printf '%s\n' "$launcher" >> "$launchers"
312if "$interactive"; then printf '\r\033[2K' >&2; fi
313printf ' %sInstalled T3 Code %s%s\n\n' "$green" "$version" "$reset" >&2
314# The folder a shell reads its startup file from: $1 when set and safe to paste, else $2 when
315# unset. A set but unsafe folder prints nothing, since the default would never be read.
316read_from() {
317 case "$1" in
318 "") printf '%s' "$2" ;;
319 /*) case "$1" in *[!A-Za-z0-9_./-]*) ;; *) printf '%s' "$1" ;; esac ;;
320 esac
321}
322# A command to paste for zsh, Linux bash, or fish, like `t3 install` prints. macOS bash reads only
323# one of several startup files, so no single file is safe to change there.
324path_command=
325path_rest="$bin_dir"
326path_prefix=
327case "$bin_dir" in "$HOME"/*) path_rest="${bin_dir#"$HOME"}" path_prefix='$HOME' ;; esac
328case "$path_rest" in *[!A-Za-z0-9_./-]*) path_rest= ;; esac
329if [ -n "$path_rest" ]; then
330 # Last on PATH, so it never shadows another t3.
331 path_line="export PATH=\"\$PATH:${path_prefix}${path_rest}\""
332 shell_name="${SHELL:-}"
333 case "${shell_name##*/}" in
334 zsh)
335 # zsh reads .zshrc from ZDOTDIR when it is set; ~/.zshrc would never be read then.
336 zsh_dir="$(read_from "${ZDOTDIR:-}" '~')"
337 [ -z "$zsh_dir" ] || path_command="printf '\\n%s\\n' '${path_line}' >> ${zsh_dir}/.zshrc" ;;
338 bash) [ "$platform" = darwin ] || path_command="printf '\\n%s\\n' '${path_line}' >> ~/.bashrc" ;;
339 fish)
340 # --path changes $PATH itself; fish_user_paths would go ahead of the system folders.
341 config_dir="$(read_from "${XDG_CONFIG_HOME:-}" '~/.config')"
342 fish_dir="${config_dir}/fish"
343 [ -z "$config_dir" ] || path_command="mkdir -p ${fish_dir} && printf '\\n%s\\n' 'fish_add_path --append --path ${path_prefix}${path_rest}' >> ${fish_dir}/config.fish" ;;
344 esac
345fi
346# A t3 earlier on PATH runs instead; the PATH command adds this folder last.
347# A PATH entry with a trailing slash gives `dir//t3`.
348first_t3="$(command -v t3 2>/dev/null | tr -s / || true)"
349case "$first_t3" in "" | "${bin_dir}/t3") first_t3= ;; esac
350if [ -n "$first_t3" ]; then
351 printf ' Another t3 at %s runs first, so run %s%s/t3%s to use this one.\n\n' "$first_t3" "$bold" "$bin_dir" "$reset"
352else
353 case ":${PATH}:" in
354 *":${bin_dir}:"* | *":${bin_dir}/:"*) printf ' Run %st3%s to use T3 Code here, or %st3 up%s to use it from your other devices.\n\n' "$bold" "$reset" "$bold" "$reset" ;;
355 *)
356 if [ -n "$path_command" ]; then
357 printf ' %s is not on your PATH yet. Add it with:\n\n %s\n\n Then open a new terminal and run %st3%s.\n\n' "$bin_dir" "$path_command" "$bold" "$reset"
358 else
359 printf ' Add %s to your PATH, then run %st3%s.\n\n' "$bin_dir" "$bold" "$reset"
360 fi
361 ;;
362 esac
363fi