priv/static/install.sh

58e6347eeb72 · 13 KB

#!/bin/sh
#
# OpenAgents CLI installer — https://openagents.com/install.sh
#
# Usage:
#   curl -fsSL https://openagents.com/install.sh | sh            # latest stable
#   curl -fsSL https://openagents.com/install.sh | sh -s 0.1.0   # specific version
#
# Windows: run under Git for Windows / MSYS2 Bash; WSL uses the Linux binary.
#
# This is POSIX shell, not bash, and the difference is the point. The musl
# builds exist for Alpine above all, and Alpine ships no bash: piping this into
# `bash` there fails before the first line runs, with an error about the shell
# rather than about anything the reader did. `sh` is on every system this
# installs to. Piping into `bash` still works and every published form of the
# command keeps working, so nothing that already ran stops running.
#
# What that costs: no `[[ ]]`, no `=~`, and no arrays. Version matching goes
# through `is_version` below, and the parallel download tracks its background
# jobs in a space-separated string.

set -e

TARGET="$1"

# The grammar the release publishes under, and the same one `ops/release-cli.sh`
# and `oa update` apply. A version one of them accepts and another rejects is a
# release nobody can ask for.
is_version() {
    printf '%s' "$1" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+(-[A-Za-z0-9._]+)?$'
}

if [ -n "$TARGET" ] && ! is_version "$TARGET"; then
    echo "Invalid version format: $TARGET (expected X.Y.Z or X.Y.Z-suffix)" >&2
    exit 1
fi

DOWNLOADER=""
if command -v curl >/dev/null 2>&1; then
    DOWNLOADER="curl"
elif command -v wget >/dev/null 2>&1; then
    DOWNLOADER="wget"
else
    echo "Either curl or wget is required but neither is installed" >&2
    exit 1
fi

download_file() {
    local url="$1" output="$2"
    if [ "$DOWNLOADER" = "curl" ]; then
        if [ -n "$output" ]; then
            curl -fsSL -o "$output" "$url"
        else
            curl -fsSL "$url"
        fi
    else
        if [ -n "$output" ]; then
            wget -q -O "$output" "$url"
        else
            wget -q -O - "$url"
        fi
    fi
}

download_file_parallel() {
    local url="$1" output="$2"
    if [ "$DOWNLOADER" != "curl" ]; then
        download_file "$url" "$output"
        return
    fi
    local size
    size=$(curl -fsSL --head "$url" 2>/dev/null | awk -F'[: \r\n]+' 'tolower($1)=="content-length"{print $2; exit}')
    if [ -z "$size" ] || ! [ "$size" -ge 16777216 ] 2>/dev/null; then
        download_file "$url" "$output"
        return
    fi
    local n=8
    local chunk_size=$(( (size + n - 1) / n ))
    local tmpdir
    tmpdir=$(mktemp -d 2>/dev/null) || { download_file "$url" "$output"; return; }
    # A space-separated list rather than an array: `wait` takes one pid at a
    # time either way, and this is the part of bash the script does without.
    local pids="" i=0 start end
    while [ "$i" -lt "$n" ]; do
        start=$((i * chunk_size))
        end=$((start + chunk_size - 1))
        [ $end -ge $size ] && end=$((size - 1))
        curl -fsSL -r "${start}-${end}" -o "${tmpdir}/$(printf 'chunk.%03d' "$i")" "$url" &
        pids="$pids $!"
        i=$((i + 1))
    done
    local all_ok=true pid
    for pid in $pids; do
        wait "$pid" || all_ok=false
    done
    if [ "$all_ok" = true ] && cat "${tmpdir}"/chunk.* > "$output" 2>/dev/null; then
        rm -rf "$tmpdir"
        return 0
    fi
    rm -rf "$tmpdir"
    download_file "$url" "$output"
}

is_not_found() {
    local url="$1" code
    if [ "$DOWNLOADER" = "curl" ]; then
        code=$(curl -o /dev/null -sSL -w '%{http_code}' --head "$url" 2>/dev/null) || true
    else
        code=$(wget --server-response --spider "$url" 2>&1 | awk '/HTTP\//{print $2}' | tail -1) || true
    fi
    [ "$code" = "404" ]
}

case "$(uname -s)" in
    Darwin) os="macos" ;;
    Linux)  os="linux" ;;
    MINGW* | MSYS* | CYGWIN*) os="windows" ;;
    *)      echo "Unsupported OS: $(uname -s)" >&2; exit 1 ;;
esac

case "$(uname -m)" in
    x86_64|amd64|AMD64) arch="x86_64" ;;
    arm64|aarch64|ARM64) arch="aarch64" ;;
    *)                    echo "Unsupported architecture: $(uname -m)" >&2; exit 1 ;;
esac

# Rosetta shell translation detection on Apple Silicon
if [ "$os" = "macos" ] && [ "$arch" = "x86_64" ]; then
    sysctl_bin="$(command -v sysctl || echo /usr/sbin/sysctl)"
    if [ "$("$sysctl_bin" -n hw.optional.arm64 2>/dev/null)" = "1" ]; then
        echo "Apple Silicon detected (Rosetta shell); installing the native arm64 build." >&2
        arch="aarch64"
    fi
fi

# Which C library, on Linux. Two Linux builds exist per architecture because a
# glibc-linked executable does not run on a musl system: the kernel reports the
# missing interpreter as "no such file or directory" against a file that
# plainly exists, which is one of the least legible errors a first install can
# produce.
#
# The test is not "which distribution is this". Distribution detection needs
# files a minimal image may not carry, and `ldd` disagrees with itself across
# implementations -- GNU's prints a version banner to stdout and exits 0, musl's
# prints "musl libc" to stderr, and BusyBox's does neither. The question that
# actually decides the answer is narrower: is the glibc dynamic loader this
# artifact would ask for present on the system?
#
# The gnu artifact is dynamically linked and names that loader in its
# PT_INTERP. The musl artifact is statically linked and names no interpreter at
# all, so it runs anywhere. A system with the loader can run either and gets
# gnu; a system without it can only run musl. That reads correctly on Alpine,
# on a distroless or BusyBox-only image with no `ldd` to ask, on a Debian host
# that happens to have the `musl` package installed, and on NixOS, where the
# loader lives in the Nix store and the static build is genuinely the right
# answer. Every way this test can be wrong sends the reader to the artifact
# that still runs.
#
# $1 is the architecture; $2 is a filesystem root to search under, empty for
# the real one, so the choice can be exercised against a fixture.
linux_libc() {
    libc_arch="$1"
    libc_root="$2"

    case "$libc_arch" in
        x86_64)
            loaders="/lib64/ld-linux-x86-64.so.2 /lib/ld-linux-x86-64.so.2 /lib/x86_64-linux-gnu/ld-linux-x86-64.so.2"
            ;;
        aarch64)
            loaders="/lib/ld-linux-aarch64.so.1 /lib64/ld-linux-aarch64.so.1 /lib/aarch64-linux-gnu/ld-linux-aarch64.so.1"
            ;;
        *)
            echo "gnu"
            return
            ;;
    esac

    for loader in $loaders; do
        if [ -e "${libc_root}${loader}" ]; then
            # A system whose own `ldd` identifies as musl is musl even with a
            # glibc compatibility loader installed beside it, and that is the
            # one case where the loader alone would answer wrongly in the
            # direction that does not run.
            if [ -z "$libc_root" ] && command -v ldd >/dev/null 2>&1; then
                case "$(ldd --version 2>&1 || true)" in
                    *musl*) echo "musl"; return ;;
                esac
            fi
            echo "gnu"
            return
        fi
    done

    echo "musl"
}

libc=""
if [ "$os" = "linux" ]; then
    libc="$(linux_libc "$arch" "")"
fi

BASE_URL_PRIMARY="https://openagents.com/releases"
DOWNLOAD_DIR="$HOME/.openagents/downloads"
BIN_DIR="${OPENAGENTS_BIN_DIR:-$HOME/.openagents/bin}"
mkdir -p "$DOWNLOAD_DIR" "$BIN_DIR"

platform="${os}-${arch}"

# The glibc Linux artifact keeps the unsuffixed name it has always had, so
# every installer already in circulation keeps resolving. musl is the addition
# and carries the suffix.
if [ "$libc" = "musl" ]; then
    platform="${platform}-musl"
    echo "musl libc detected; installing the statically linked build." >&2
fi

CHANNEL="${OPENAGENTS_CHANNEL:-stable}"

# A channel is a pointer file naming the version it currently means, so
# `stable` can move without every installed script having to. A hardcoded
# default would make the channel a decoration: it was read and then ignored.
if [ -n "$TARGET" ]; then
    version="$TARGET"
else
    version="$(download_file "${BASE_URL_PRIMARY}/${CHANNEL}" "" 2>/dev/null | tr -d '[:space:]')" || version=""
    if [ -z "$version" ]; then
        echo "Could not resolve the '${CHANNEL}' channel from ${BASE_URL_PRIMARY}/${CHANNEL}." >&2
        echo "Pass a version explicitly: curl -fsSL https://openagents.com/install.sh | sh -s X.Y.Z" >&2
        echo "Or follow another channel: OPENAGENTS_CHANNEL=beta" >&2
        exit 1
    fi
    if ! is_version "$version"; then
        echo "The '${CHANNEL}' channel returned something that is not a version: ${version}" >&2
        exit 1
    fi
fi

echo "Installing OpenAgents CLI v$version ($platform)..." >&2

binary_path="$DOWNLOAD_DIR/openagents-$platform"
artifact_base="${BASE_URL_PRIMARY}/openagents-${version}-${platform}"

if [ "$os" = "windows" ]; then
    binary_path="${binary_path}.exe"
fi

binary_tmp="${binary_path}.tmp.$$"
rm -f "$binary_tmp" 2>/dev/null || true

# The download is the only source. This used to fall back to `./target/debug/oa`
# or an already-installed binary when the fetch failed, which meant running the
# installer from any directory holding a stale or foreign build silently
# installed that build while printing the version it meant to fetch. An
# installer people pipe into bash cannot have a path that installs something
# nobody named.
if ! download_file_parallel "$artifact_base" "$binary_tmp" 2>/dev/null; then
    echo "Could not download ${artifact_base}." >&2
    echo "No local fallback is used: an installer must install what it says it did." >&2
    exit 1
fi

# Verify before the bytes are ever made executable. A checksum fetched over the
# same connection as the artifact proves only that they arrived together, which
# is why the sums file is fetched separately and the artifact is refused when it
# is absent rather than installed unverified.
checksum_tool=""
if command -v shasum >/dev/null 2>&1; then
    checksum_tool="shasum -a 256"
elif command -v sha256sum >/dev/null 2>&1; then
    checksum_tool="sha256sum"
else
    echo "Neither shasum nor sha256sum is available; refusing to install unverified bytes." >&2
    rm -f "$binary_tmp"
    exit 1
fi

sums_tmp="${binary_tmp}.sums"
if ! download_file "${BASE_URL_PRIMARY}/SHA256SUMS-${version}" "$sums_tmp" 2>/dev/null; then
    echo "Could not download SHA256SUMS-${version}; refusing to install unverified bytes." >&2
    rm -f "$binary_tmp" "$sums_tmp"
    exit 1
fi

artifact_name="openagents-${version}-${platform}"
[ "$os" = "windows" ] && artifact_name="${artifact_name}.exe"

expected="$(awk -v name="$artifact_name" '$2 == name || $2 == "*" name { print $1 }' "$sums_tmp" | head -1)"
if [ -z "$expected" ]; then
    echo "SHA256SUMS-${version} names no entry for ${artifact_name}; refusing to install." >&2
    rm -f "$binary_tmp" "$sums_tmp"
    exit 1
fi

actual="$($checksum_tool "$binary_tmp" | awk '{ print $1 }')"
if [ "$actual" != "$expected" ]; then
    echo "Checksum mismatch for ${artifact_name}." >&2
    echo "  expected ${expected}" >&2
    echo "  actual   ${actual}" >&2
    rm -f "$binary_tmp" "$sums_tmp"
    exit 1
fi
rm -f "$sums_tmp"
echo "  Verified sha256 ${actual}." >&2

# Installs three names for one binary: `openagents`, `coder`, and `oa`.
#
# The binary answers both surfaces -- bare it is the coder session, and given a
# command it dispatches to that command -- so `coder` and `openagents` are two
# doors onto the same thing, and `oa` is the short one.
if [ "$os" = "windows" ]; then
    mv -f "$binary_tmp" "$binary_path"
    for name in openagents coder oa; do
        rm -f "$BIN_DIR/$name.exe.old" 2>/dev/null || true
        cp -f "$binary_path" "$BIN_DIR/$name.exe" 2>/dev/null || true
    done
else
    chmod +x "$binary_tmp"
    mv -f "$binary_tmp" "$binary_path"

    if [ "$(dirname "$BIN_DIR")" = "$(dirname "$DOWNLOAD_DIR")" ]; then
        link_target="../$(basename "$DOWNLOAD_DIR")/$(basename "$binary_path")"
    else
        link_target="$binary_path"
    fi
    ln -sf "$link_target" "$BIN_DIR/openagents"
    ln -sf "$link_target" "$BIN_DIR/coder"
    ln -sf "$link_target" "$BIN_DIR/oa"
fi

path_has_dir() {
    case ":$PATH:" in *":$1:"*) return 0 ;; *) return 1 ;; esac
}

SYMLINK_CREATED=""
if [ "$os" != "windows" ] && ! path_has_dir "$BIN_DIR"; then
    for candidate in "$HOME/.local/bin" "/usr/local/bin"; do
        if path_has_dir "$candidate" && [ -d "$candidate" ] && [ -w "$candidate" ]; then
            ln -sf "$BIN_DIR/openagents" "$candidate/openagents"
            ln -sf "$BIN_DIR/coder" "$candidate/coder"
            ln -sf "$BIN_DIR/oa" "$candidate/oa"
            SYMLINK_CREATED="$candidate"
            break
        fi
    done
fi

user_shell="$(basename "${SHELL:-}")"
config_file=""

case "$user_shell" in
    bash) config_file="$HOME/.bashrc" ;;
    zsh)  config_file="$HOME/.zshrc" ;;
    fish) config_file="$HOME/.config/fish/config.fish" ;;
esac

if [ -n "$config_file" ]; then
    mkdir -p "$(dirname "$config_file")"

    if [ "$user_shell" = "fish" ]; then
        new_block='# >>> openagents installer >>>
fish_add_path $HOME/.openagents/bin
# <<< openagents installer <<<'
    else
        new_block='# >>> openagents installer >>>
export PATH="$HOME/.openagents/bin:$PATH"
# <<< openagents installer <<<'
    fi

    if grep -qs "openagents installer" "$config_file" 2>/dev/null; then
        tmp="$config_file.tmp.$$"
        awk '
            /# >>> openagents installer >>>/ { skip=1; next }
            /# <<< openagents installer <<</ { skip=0; next }
            !skip { print }
        ' "$config_file" > "$tmp" && mv "$tmp" "$config_file"
    else
        [ -f "$config_file" ] && cp "$config_file" "$config_file.bak.$(date +%s)"
    fi

    printf '\n%s\n' "$new_block" >> "$config_file"
    echo "  Updated $BIN_DIR in PATH in $config_file." >&2
fi

echo "" >&2
echo "OpenAgents v$version installed." >&2
echo "Loading Coder..." >&2

coder_path="$BIN_DIR/coder"
[ "$os" = "windows" ] && coder_path="${coder_path}.exe"

# The installer usually runs from a pipe. Coder detects the controlling
# terminal and opens it for input itself; redirecting stdin here makes
# Crossterm fail to initialize its macOS input reader.
exec "$coder_path"