#!/usr/bin/env bash # install-trio.sh — bootstrap the trio CLI on a new machine. # # Usage (interactive): # bash install-trio.sh # # Usage (curl-pipe — must use bash -c so prompts can read from /dev/tty): # bash -c "$(curl -fsSL https://api.clinicalcircuit.com/install.sh)" # # What it does: # 1. Detects darwin/linux and arm64/amd64. # 2. Prompts for email + password (or reads $TRIO_EMAIL / $TRIO_PASSWORD). # 3. Logs in via the GraphQL mutation, caches the JWT at ~/.trio/token (0600). # 4. Downloads the latest binary from /cli/download, verifies SHA-256 against # /cli/manifest and the X-Trio-Sha256 response header, installs to # ~/.local/bin/trio (mode 0755). # 5. Strips the quarantine xattr on macOS so Apple Silicon doesn't SIGKILL. # 6. Adds ~/.local/bin to PATH in the user's shell rc if it isn't already # there, and prints a "open a new shell" reminder. # # Env overrides: # TRIO_API_URL Full GraphQL URL (default: https://api.clinicalcircuit.com/graphql). # The script strips a trailing /graphql to derive the base URL # used for /cli/manifest and /cli/download. # TRIO_INSTALL_DIR Install directory (default: ~/.local/bin). # TRIO_EMAIL Pre-supply email — skips the prompt. # TRIO_PASSWORD Pre-supply password — skips the prompt. Avoid passing on # the command line; export from a mode-0600 file instead. set -euo pipefail # --- config --------------------------------------------------------------- GRAPHQL_URL_DEFAULT="https://api.clinicalcircuit.com/graphql" GRAPHQL_URL="${TRIO_API_URL:-$GRAPHQL_URL_DEFAULT}" # Derive base URL by stripping trailing /graphql, mirroring update.go. BASE_URL="$GRAPHQL_URL" case "$BASE_URL" in */graphql) BASE_URL="${BASE_URL%/graphql}";; esac INSTALL_DIR="${TRIO_INSTALL_DIR:-$HOME/.local/bin}" BIN_PATH="$INSTALL_DIR/trio" TOKEN_DIR="$HOME/.trio" TOKEN_PATH="$TOKEN_DIR/token" # --- pretty output -------------------------------------------------------- if [ -t 1 ]; then C_BLUE=$'\033[1;34m'; C_RED=$'\033[1;31m'; C_GREEN=$'\033[1;32m'; C_DIM=$'\033[2m'; C_OFF=$'\033[0m' else C_BLUE=""; C_RED=""; C_GREEN=""; C_DIM=""; C_OFF="" fi log() { printf '%s==>%s %s\n' "$C_BLUE" "$C_OFF" "$*"; } ok() { printf '%s ok%s %s\n' "$C_GREEN" "$C_OFF" "$*"; } warn() { printf '%s ! %s %s\n' "$C_RED" "$C_OFF" "$*" >&2; } die() { printf '%serror:%s %s\n' "$C_RED" "$C_OFF" "$*" >&2; exit 1; } # Single cleanup path. EXIT trap walks this list so temp files get removed # on both happy-path completion and `die` failure. Declared at file scope so # the trap can read it even after main() returns and locals go out of scope. _TRIO_TMP_FILES=() _trio_cleanup() { local f for f in "${_TRIO_TMP_FILES[@]:-}"; do [ -n "$f" ] && rm -f "$f" 2>/dev/null || true done } trap _trio_cleanup EXIT # --- helpers -------------------------------------------------------------- require_cmd() { command -v "$1" >/dev/null 2>&1 || die "$1 is required but not installed" } # detect_platform → "darwin/arm64" | "darwin/amd64" | "linux/amd64" | "linux/arm64" detect_platform() { local os arch case "$(uname -s)" in Darwin) os=darwin;; Linux) os=linux;; *) die "unsupported OS: $(uname -s) (only darwin and linux are supported)";; esac case "$(uname -m)" in arm64|aarch64) arch=arm64;; x86_64|amd64) arch=amd64;; *) die "unsupported arch: $(uname -m) (only amd64 and arm64 are supported)";; esac printf '%s/%s\n' "$os" "$arch" } # sha256_of → hex digest, lowercase sha256_of() { if command -v sha256sum >/dev/null 2>&1; then sha256sum "$1" | awk '{print $1}' elif command -v shasum >/dev/null 2>&1; then shasum -a 256 "$1" | awk '{print $1}' elif command -v openssl >/dev/null 2>&1; then openssl dgst -sha256 "$1" | awk '{print $NF}' else die "no SHA-256 helper found (need sha256sum, shasum, or openssl)" fi } # JSON helpers — perl with JSON::PP is in core perl on macOS and every Linux # distribution we ship for, so this avoids depending on python3 (not in macOS # baseline) or jq (not in macOS baseline). require_perl_json() { perl -MJSON::PP -e 1 >/dev/null 2>&1 \ || die "perl JSON::PP module not available — please install perl 5.14+ or run on a different machine" } # json_login_payload → JSON body for the login mutation json_login_payload() { EMAIL="$1" PASSWORD="$2" perl -MJSON::PP -e ' my $payload = { query => "mutation Login(\$input: LoginInput!) { login(input: \$input) { success message token user { email firstName lastName } } }", variables => { input => { email => $ENV{EMAIL}, password => $ENV{PASSWORD} } }, }; print encode_json($payload); ' } # extract_login_token → token string (empty if login failed) # Also prints "name|email" on success or "ERROR: msg" on failure to stderr. extract_login_token() { perl -MJSON::PP -e ' my $body = do { local $/; }; my $r = eval { decode_json($body) }; if (!$r) { print STDERR "ERROR: invalid JSON from server\n"; exit 0 } if ($r->{errors} && @{$r->{errors}}) { print STDERR "ERROR: " . $r->{errors}[0]{message} . "\n"; exit 0 } my $login = $r->{data}{login}; if (!$login || !$login->{success}) { my $msg = ($login && $login->{message}) ? $login->{message} : "login failed"; print STDERR "ERROR: $msg\n"; exit 0 } my $u = $login->{user}; my $name = join " ", grep { defined && length } ($u->{firstName}, $u->{lastName}); $name = $u->{email} unless length $name; print STDERR "WHOAMI: $name|$u->{email}\n"; print $login->{token}; ' <<<"$1" } # extract_manifest_field extract_manifest_field() { PLATFORM_KEY="$2" FIELD="$3" perl -MJSON::PP -e ' my $body = do { local $/; }; my $r = eval { decode_json($body) }; exit 1 unless $r; my $f = $ENV{FIELD}; if ($f eq "version" || $f eq "built_at") { print $r->{$f} // ""; } else { print $r->{platforms}{$ENV{PLATFORM_KEY}}{$f} // ""; } ' <<<"$1" } # detect_tty_input — sets TTY_INPUT to /dev/tty when a controlling terminal # is reachable (covers the curl-pipe case where stdin is the script body), # falls back to /dev/stdin if stdin itself is a tty, or empty if neither. TTY_INPUT="" detect_tty_input() { # Subshell + stderr-suppress so bash's "cannot open" message stays quiet # when there is no controlling terminal (the no-tty case dies later with # a clear "set TRIO_EMAIL" message instead). if ( : /dev/null 2>&1; then TTY_INPUT=/dev/tty elif [ -t 0 ]; then TTY_INPUT=/dev/stdin else TTY_INPUT="" fi } # read_password — prompt with no echo, reading from $TTY_INPUT. read_password() { local prompt="$1" pwd="" [ -n "$TTY_INPUT" ] || die "no terminal — set TRIO_PASSWORD to install non-interactively" printf '%s' "$prompt" >&2 stty -echo <"$TTY_INPUT" 2>/dev/null || true IFS= read -r pwd <"$TTY_INPUT" || true stty echo <"$TTY_INPUT" 2>/dev/null || true printf '\n' >&2 printf '%s' "$pwd" } read_line() { local prompt="$1" line="" [ -n "$TTY_INPUT" ] || die "no terminal — set TRIO_EMAIL to install non-interactively" printf '%s' "$prompt" >&2 IFS= read -r line <"$TTY_INPUT" || true printf '%s' "$line" } # --- main flow ------------------------------------------------------------ main() { require_cmd curl require_cmd uname require_cmd awk require_cmd perl require_perl_json detect_tty_input PLATFORM=$(detect_platform) GOOS="${PLATFORM%/*}" GOARCH="${PLATFORM#*/}" log "Platform: $PLATFORM" log "API: $BASE_URL" log "Install: $BIN_PATH" # ---- existing install ---- if [ -e "$BIN_PATH" ]; then local current="" if [ -x "$BIN_PATH" ]; then current=$("$BIN_PATH" --version 2>/dev/null | head -n1 || true) fi warn "$BIN_PATH already exists${current:+ ($current)}" if [ -t 0 ] || [ -e /dev/tty ]; then ans=$(read_line "Overwrite? [y/N]: ") case "$ans" in y|Y|yes|YES) ;; *) die "aborted";; esac else die "non-interactive: refusing to overwrite existing install (set TRIO_INSTALL_DIR or remove $BIN_PATH)" fi fi # ---- credentials ---- local email password email="${TRIO_EMAIL:-}" password="${TRIO_PASSWORD:-}" if [ -z "$email" ]; then email=$(read_line "Trio email: ") fi [ -n "$email" ] || die "email is required" if [ -z "$password" ]; then password=$(read_password "Trio password: ") fi [ -n "$password" ] || die "password is required" # ---- login ---- log "Authenticating against $GRAPHQL_URL ..." local payload login_body token whoami_line payload=$(json_login_payload "$email" "$password") # Pipe the body via stdin so the password never lands in argv (`ps`). if ! login_body=$(printf '%s' "$payload" \ | curl -fsS --max-time 30 \ -H 'Content-Type: application/json' \ -H 'X-Trio-Client: install.sh' \ --data-binary @- \ "$GRAPHQL_URL"); then die "login request failed (HTTP error from $GRAPHQL_URL)" fi # extract_login_token writes the token to stdout, one of WHOAMI:/ERROR: to stderr. local stderr_file err_line="" stderr_file=$(mktemp); _TRIO_TMP_FILES+=("$stderr_file") token=$(extract_login_token "$login_body" 2>"$stderr_file" || true) whoami_line=$(grep '^WHOAMI:' "$stderr_file" | head -n1 | sed 's/^WHOAMI: //' || true) err_line=$(grep '^ERROR:' "$stderr_file" | head -n1 | sed 's/^ERROR: //' || true) if [ -n "$err_line" ] || [ -z "$token" ]; then die "login failed: ${err_line:-unknown error}" fi ok "Logged in as $whoami_line" # ---- save token ---- mkdir -p "$TOKEN_DIR"; chmod 700 "$TOKEN_DIR" umask 077 printf '%s' "$token" >"$TOKEN_PATH" chmod 600 "$TOKEN_PATH" ok "Cached JWT at $TOKEN_PATH (0600)" # ---- fetch manifest ---- log "Fetching release manifest ..." local manifest expected_sha expected_size version built_at if ! manifest=$(curl -fsS --max-time 30 \ -H "Authorization: Bearer $token" \ "$BASE_URL/cli/manifest"); then die "GET /cli/manifest failed" fi version=$(extract_manifest_field "$manifest" "" "version") built_at=$(extract_manifest_field "$manifest" "" "built_at") expected_sha=$(extract_manifest_field "$manifest" "$PLATFORM" "sha256") expected_size=$(extract_manifest_field "$manifest" "$PLATFORM" "size") if [ -z "$version" ]; then die "no release has been published yet — ask whoever owns the api-server to run the release-cli workflow" fi if [ -z "$expected_sha" ] || [ -z "$expected_size" ]; then die "manifest has no entry for $PLATFORM (only published platforms can be installed)" fi ok "Latest version: ${version} ${built_at:+(built $built_at)}" # ---- download ---- mkdir -p "$INSTALL_DIR" local tmp hdrs tmp="$INSTALL_DIR/.trio.install.tmp.$$"; _TRIO_TMP_FILES+=("$tmp") hdrs=$(mktemp); _TRIO_TMP_FILES+=("$hdrs") log "Downloading binary (${expected_size} bytes) ..." if ! curl -fsS --max-time 300 \ -H "Authorization: Bearer $token" \ -D "$hdrs" \ -o "$tmp" \ "$BASE_URL/cli/download?os=$GOOS&arch=$GOARCH"; then die "download failed" fi # Verify size. local got_size got_size=$(wc -c <"$tmp" | awk '{print $1}') if [ "$got_size" != "$expected_size" ]; then die "size mismatch: got $got_size, manifest says $expected_size" fi # Verify SHA-256 against manifest and X-Trio-Sha256 header. local got_sha header_sha got_sha=$(sha256_of "$tmp") header_sha=$(awk 'tolower($1)=="x-trio-sha256:"{print $2}' "$hdrs" | tr -d '\r' | tr 'A-Z' 'a-z') if [ "$got_sha" != "$expected_sha" ]; then die "sha256 mismatch vs manifest: got $got_sha, manifest says $expected_sha" fi if [ -n "$header_sha" ] && [ "$got_sha" != "$header_sha" ]; then die "sha256 mismatch vs X-Trio-Sha256 header: got $got_sha, header says $header_sha" fi ok "SHA-256 verified ($got_sha)" # ---- install ---- chmod 0755 "$tmp" mv "$tmp" "$BIN_PATH" # macOS: strip the quarantine attribute the browser/Finder added when the # binary was downloaded. Without this Apple Silicon gives a generic # "trio cannot be opened because the developer cannot be verified" error. # Ad-hoc-signed binaries from CI launch without this on a clean download, # but we still scrub xattrs in case the binary came in via Dropbox/AirDrop. if [ "$GOOS" = "darwin" ] && command -v xattr >/dev/null 2>&1; then xattr -cr "$BIN_PATH" 2>/dev/null || true fi ok "Installed $BIN_PATH" # ---- PATH update ---- update_path "$INSTALL_DIR" # ---- final smoke ---- if "$BIN_PATH" --version >/dev/null 2>&1; then local ver ver=$("$BIN_PATH" --version 2>/dev/null | head -n1) ok "Smoke test passed: $ver" else warn "binary installed but --version failed; check '$BIN_PATH --version' manually" fi printf '\n' log "Done. Try:" printf ' %strio api whoami%s\n' "$C_DIM" "$C_OFF" printf ' %strio --help%s\n' "$C_DIM" "$C_OFF" printf ' %strio update%s # pull future releases\n' "$C_DIM" "$C_OFF" } # update_path — append `export PATH=...:$dir:...` to the user's shell rc # if $dir isn't already on PATH. Picks the rc file based on the login shell: # zsh → ~/.zshrc, bash on Linux → ~/.bashrc, bash on macOS → ~/.bash_profile. update_path() { local dir="$1" case ":$PATH:" in *":$dir:"*) ok "$dir is already on PATH"; return;; esac local shell_name rc line shell_name=$(basename "${SHELL:-}") case "$shell_name" in zsh) rc="$HOME/.zshrc";; bash) if [ "$(uname -s)" = "Darwin" ]; then rc="$HOME/.bash_profile"; else rc="$HOME/.bashrc"; fi;; *) rc="$HOME/.profile";; esac line='export PATH="'$dir':$PATH"' touch "$rc" if grep -Fq "$dir" "$rc" 2>/dev/null; then ok "$rc already references $dir" else { printf '\n# Added by install-trio.sh on %s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" printf '%s\n' "$line" } >>"$rc" ok "Added '$dir' to PATH in $rc" fi warn "open a new shell or run: source $rc" } main "$@"