#!/bin/sh # Graphlit: install the terminal client. # # curl -fsSL https://graphlit.co/install.sh | sh # # Read before you run it. This URL serves plain text and always will: # # curl -fsSL https://graphlit.co/install.sh | less # # In order, and nothing else: # 1. work out which build this machine needs, from its OS and CPU # 2. download the client. ONE compiled binary. No Python, no runtime, no # package manager, nothing installed that you did not ask for # 3. put ~/.local/bin on PATH, by appending one line to your shell's rc file # 4. hand over to `graph setup`, which signs you in and then ASKS which # folder to turn into a project. Run this from anywhere # # No sudo. Nothing is written outside your home directory. Undo the whole thing # with: rm -f ~/.local/bin/graph ~/.local/bin/graphlit # # Flags: # --no-setup install only; do not sign in or link anything # --api URL point the client at a different deployment set -eu API="https://graphlit.co" APP="https://graphlit.co/app" RUN_SETUP=1 while [ $# -gt 0 ]; do case "$1" in --no-setup) RUN_SETUP=0 ;; --api) API="$2"; shift ;; --api=*) API="${1#--api=}" ;; # Printed inline rather than read back out of the file. Piped from curl, # "$0" is "sh" and the script has no path on disk to re-read, so a # self-quoting help is silence exactly where someone asked a question. -h|--help) printf '%s\n' \ "Graphlit installer" \ "" \ " curl -fsSL https://graphlit.co/install.sh | sh" \ " curl -fsSL https://graphlit.co/install.sh | sh -s -- --no-setup" \ "" \ " --no-setup install only; do not sign in or link anything" \ " --api URL point the client at a different deployment" \ "" \ "Read the whole script: curl -fsSL https://graphlit.co/install.sh | less" exit 0 ;; *) printf 'unknown option: %s\n' "$1" >&2; exit 2 ;; esac shift done # Colour only when a human is watching. Piped into a log or a CI transcript, # escape codes are noise that makes the failure harder to read, not easier. if [ -t 1 ] && [ -z "${NO_COLOR:-}" ]; then B=$(printf '\033[1m'); D=$(printf '\033[2m'); R=$(printf '\033[0m') G=$(printf '\033[32m'); Y=$(printf '\033[33m') else B=''; D=''; R=''; G=''; Y='' fi say() { printf '%s\n' "$*"; } step() { printf '%s==>%s %s%s%s\n' "$G" "$R" "$B" "$*" "$R"; } note() { printf '%s %s%s\n' "$D" "$*" "$R"; } die() { printf '\n%serror:%s %s\n' "$Y" "$R" "$*" >&2; exit 1; } have() { command -v "$1" >/dev/null 2>&1; } # --------------------------------------------------------------------------- # 0. Is this machine one we can install onto? # --------------------------------------------------------------------------- # Checked up front rather than discovered three minutes in. The failure that # matters is not "unsupported OS" — it is a half-installed tool and a PATH entry # pointing at nothing. OS=$(uname -s 2>/dev/null || echo unknown) case "$OS" in Linux|Darwin) ;; MINGW*|MSYS*|CYGWIN*) note "Git Bash detected. This works, but PowerShell is the better door on Windows:" note " irm https://graphlit.co/install.ps1 | iex" ;; *) die "unsupported operating system: $OS. Ask us at https://graphlit.co/contact and we will look." ;; esac if [ "$(id -u 2>/dev/null || echo 1)" = "0" ] && [ -z "${ALLOW_ROOT:-}" ]; then # Not a security theatre check: installed as root, the tool lands in /root/.local # and is invisible to the account that will actually use it — so the user's # next command is "command not found" with a successful install behind it. die "running as root would install into root's home, where your own shell cannot see it. Run it as yourself (no sudo). If you really mean it: ALLOW_ROOT=1 sh -c '...'" fi have curl || have wget || die "needs curl or wget to fetch anything." say "" say " ${B}Graphlit${R} · installing the terminal client" say "" # --------------------------------------------------------------------------- # 1. Which build # --------------------------------------------------------------------------- # **The client is a compiled binary now, so there is no Python step at all.** # This section used to install `uv`, which shipped its own Python, which then # installed a wheel — three moving parts to run one program, and the last of # them never existed: nothing was ever published as `graphlit-cli` # (BLOCKERS.md §1). A single 10MB download replaces all of it. # # The architecture is asked for explicitly rather than guessed. `uname -m` # spells the same machine `arm64` on macOS and `aarch64` on Linux, and # `x86_64` / `amd64` interchangeably — a wrong guess downloads a binary that # fails with "cannot execute binary file", which reads as a corrupt download # rather than a wrong build. MACHINE=$(uname -m 2>/dev/null || echo unknown) case "$MACHINE" in x86_64|amd64) ARCH=x86_64 ;; arm64|aarch64) ARCH=aarch64 ;; *) die "unsupported CPU: $MACHINE. Tell us at https://graphlit.co/contact and we will add it." ;; esac case "$OS" in Linux) PLATFORM="linux-$ARCH" ;; Darwin) PLATFORM="darwin-$ARCH" ;; esac step "Building for $PLATFORM" # --------------------------------------------------------------------------- # 2. The client # --------------------------------------------------------------------------- # Downloaded to a temp file and moved into place only once it is complete and # executable. A `curl` writing straight to the destination leaves a truncated # binary on $PATH when the network drops, and the next thing the user runs is # a corrupt `graph` — worse than no install at all, because it looks like the # product is broken rather than the download. step "Downloading the graph CLI" note "from $API/v1/cli/download/$PLATFORM" BIN_DIR="$HOME/.local/bin" mkdir -p "$BIN_DIR" TMP=$(mktemp "${TMPDIR:-/tmp}/graph.XXXXXX") || die "could not create a temporary file" # shellcheck disable=SC2064 trap "rm -f '$TMP'" EXIT INT TERM if have curl; then curl -fsSL "$API/v1/cli/download/$PLATFORM" -o "$TMP" || DOWNLOAD_FAILED=1 else wget -qO "$TMP" "$API/v1/cli/download/$PLATFORM" || DOWNLOAD_FAILED=1 fi if [ -n "${DOWNLOAD_FAILED:-}" ] || [ ! -s "$TMP" ]; then die "could not download the client for $PLATFORM from $API. If your platform is not published yet, this will say so: curl -s $API/v1/cli/platforms" fi chmod +x "$TMP" || die "could not make the download executable" # A last check before it goes on $PATH: a binary that cannot answer --version # is one that will fail on the user's first real command instead, by which time # they have stopped associating it with the install. "$TMP" --version >/dev/null 2>&1 || die "the downloaded client did not run. Try again, or tell us at https://graphlit.co/contact." mv -f "$TMP" "$BIN_DIR/graph" || die "could not install to $BIN_DIR" trap - EXIT INT TERM ln -sf "$BIN_DIR/graph" "$BIN_DIR/graphlit" 2>/dev/null || true note "installed to $BIN_DIR/graph" # --------------------------------------------------------------------------- # 3. PATH # --------------------------------------------------------------------------- # Delegated to uv rather than appending a line ourselves. uv knows where it put # the binaries, knows which rc file this shell reads, and knows how fish spells # it — three things a hand-written export gets wrong on someone's machine and # nowhere else. It is also idempotent, which a naive >> is not. step "Putting it on your PATH" PATH="$BIN_DIR:$PATH" export PATH note "$BIN_DIR" # **Written into the rc file, and this is what `uv tool update-shell` used to # do for us.** Dropping uv means dropping the one component that knew which rc # file this shell reads and how fish spells an export, so it is written here — # guarded, because a naive `>>` appends the same line on every re-run and # people re-run this when something is wrong. # # **Single quotes, and they are load-bearing.** This was written with double # ones — `LINE="export PATH=\"$BIN_DIR:$PATH\""` — where the inner quotes # collapse against the outer pair, so `$PATH` expanded *here*, at install time, # and what landed in the rc file was a snapshot of that moment: # # export PATH=/Users/x/.local/bin:/opt/homebrew/bin:/usr/bin:/bin:… # # A frozen PATH is not a cosmetic problem. It is re-applied by every future # shell, so anything computed later by nvm, pyenv or Homebrew is pinned to # whatever it was on the day this ran, and an entry containing a space is # unquoted in the bargain. The literal is what belongs in an rc file. # # `$HOME/.local/bin` rather than `$BIN_DIR`, for the same reason: the rc file # is sourced by a shell that has never heard of `$BIN_DIR`, and `export # PATH=":$PATH"` is what an empty variable would write. # # The idempotence guard has to move with it. It matched the *expanded* path, # which no longer appears in the file — so a re-run would have appended the # line every time, and people re-run this exactly when something looks wrong. # Matching `$LINE` also means somebody who already wrote this export by hand # is left alone. case "$(basename "${SHELL:-sh}")" in fish) RC="$HOME/.config/fish/config.fish"; LINE='fish_add_path $HOME/.local/bin' ;; zsh) RC="$HOME/.zshrc"; LINE='export PATH="$HOME/.local/bin:$PATH"' ;; bash) RC="$HOME/.bashrc"; LINE='export PATH="$HOME/.local/bin:$PATH"' ;; *) RC=""; LINE="" ;; esac if [ -n "$RC" ] && ! grep -qsF "$LINE" "$RC" 2>/dev/null; then mkdir -p "$(dirname "$RC")" 2>/dev/null || true printf ' # added by %s %s ' "Graphlit" "$LINE" >> "$RC" 2>/dev/null && note "added to $(basename "$RC")" || note "could not write $RC. Add this yourself: $LINE" elif [ -n "$RC" ]; then note "already on your PATH in $(basename "$RC")" fi have graph || die "installed, but 'graph' is not runnable from $BIN_DIR. Open a new terminal and try 'graph'." # --------------------------------------------------------------------------- # 4. Hand over # --------------------------------------------------------------------------- # The rest is a tested command in the client itself, not more shell. `graph setup` signs in, # asks which folder to bind to a project and prints what is connected — and # because it is a real command, anyone can re-run it later without re-running an # installer. # # stdin is the script, so setup gets /dev/tty when there is one. Without this a # browser-open prompt would read the remaining bytes of this file as an answer — # and the folder question below it would be answered by a line of shell. # # **Test the open, not the file.** This was `[ -e /dev/tty ] && [ -r /dev/tty ]`, # which are stat calls: the node exists and its mode bits allow reading. Neither # says a process can OPEN it, and a process with no controlling terminal — CI, a # Docker build, `ssh host 'curl … | sh'` — cannot, even though both tests pass. # The install had already succeeded by then, so the whole run ended # `cannot open /dev/tty` and a non-zero exit, which reads as "the installer # failed" when the binary is sitting on $PATH working perfectly. Redirecting in a # subshell asks the only question that matters. if [ "$RUN_SETUP" = "1" ]; then say "" if (exec /dev/null; then graph setup --api "$API" --app "$APP"