#!/usr/bin/env sh # Copyright (c) 2022 SurrealDB Ltd. # Installs `surreal`, the SurrealDB command line, and the database with it. # # curl -sSf https://download.surrealdb.com/cli/launcher/install.sh | sh # curl -sSf https://download.surrealdb.com/cli/launcher/install.sh | sh -s -- --version 3.3.2 # curl -sSf https://download.surrealdb.com/cli/launcher/install.sh | sh -s -- --with cloud,lsp # curl -sSf https://download.surrealdb.com/cli/launcher/install.sh | sh -s -- --no-db ~/bin # curl -sSf https://download.surrealdb.com/cli/launcher/install.sh | SURREAL_CLI_INSTALL_DB=0 sh # # The launcher goes into the install directory (/usr/local/bin, or # ~/.surrealdb when that is not writable). Unless the database switch turns it # off, the database is installed by the launcher as a component and pinned, so # `surreal db start` works from then on with no network at all. A release named # with --version is pinned whatever was pinned before; one a channel names is # pinned unless a later one is. # # The options, and the database switch, are in `usage` below; `--help` prints # them. # # SURREAL_CLI_DOWNLOAD_ROOT replaces the download host, for mirrors. # # The host must publish the launcher under `cli/launcher/` as well as the # database at its root; this script installs nothing without it. The launcher # is published as the database is: `cli/launcher/latest.txt` names a release, # and `cli/launcher/v/surreal-v..tgz` holds it. # # A `surreal` that comes before the install directory on PATH, such as an # older standalone database, would shadow the launcher, so the script warns # when it finds one. # # Every download is checked against the SHA-256 published beside it, and # nothing is installed if a check fails. set -u VERSION="" CHANNEL="" WITH="" # Whether to install the database: 1 or 0. This is the one place the default # is set. It is on for now, so `surreal db start` works offline straight after # install; it may later flip to installing the database on first use. A caller # that wants one behaviour whatever the default says so with --db or --no-db, # or SURREAL_CLI_INSTALL_DB, and keeps it across that flip. INSTALL_DB_DEFAULT=1 # The switch as the flags set it, and which flag set it last; empty when none # did. A flag wins over SURREAL_CLI_INSTALL_DB, which wins over the default. DB_FLAG="" DB_FLAG_NAME="" INTERACTIVE=false INSTALL_DIR="/usr/local/bin" # Extra wget options; see where FETCH is chosen. WGET_LIMITS="" DOWNLOAD_ROOT="${SURREAL_CLI_DOWNLOAD_ROOT:-https://download.surrealdb.com}" DOWNLOAD_ROOT="${DOWNLOAD_ROOT%/}" err() { echo "Error: $1" >&2 exit 1 } usage() { # The text names the default, which follows INSTALL_DB_DEFAULT, so it is # filled in here rather than written out and left to go stale. if [ "$INSTALL_DB_DEFAULT" = 1 ]; then _usage_default=" (the default for now)" _usage_neither="the database is installed" else _usage_default="" _usage_neither="the database is not installed" fi cat <<'USAGE' | sed -e "s/@DB_DEFAULT@/$_usage_default/" -e "s/@NEITHER@/$_usage_neither/" Installs `surreal`, the SurrealDB command line, and the database with it. Usage: install.sh [OPTIONS] [DIR] Options: --db Install the database and pin it@DB_DEFAULT@ --no-db Install no database; it installs on first use instead --minimal The same as --no-db -v, --version The database release to install and pin -n, --nightly The latest nightly database -b, --beta The latest beta database -a, --alpha The latest alpha database --with Further components to install: ext, cloud, lsp, memory -i, --interactive Ask where to install when the default is not writable -h, --help Print this and exit Where to put `surreal` The database switch is --db or --no-db, or SURREAL_CLI_INSTALL_DB=1 or 0 for callers that cannot pass flags, such as `curl ... | sh`. A flag wins over the variable, the last of --db, --no-db and --minimal wins over the others, and with neither, @NEITHER@. That default may change, so a script that cares which it gets should say so. --version, --nightly, --beta and --alpha choose the database release, so they apply only when the database is installed, and are refused with --no-db or SURREAL_CLI_INSTALL_DB=0. SURREAL_CLI_DOWNLOAD_ROOT replaces the download host, for mirrors. USAGE } # The download root is printed in progress and in every error about a request, # so it may hold nothing that is a secret: it is an http or https URL naming a # host, with no user name or password, no query, no fragment. The message names # the variable, never its value. case "$DOWNLOAD_ROOT" in http://?*|https://?*) ;; *) err "SURREAL_CLI_DOWNLOAD_ROOT is not an http or https URL naming a host, such as https://download.example.com" ;; esac _authority="${DOWNLOAD_ROOT#*://}" _authority="${_authority%%[/?#]*}" case "$_authority" in *@*) err "SURREAL_CLI_DOWNLOAD_ROOT carries a user name or password, which would be printed wherever the download host is named; set it to the mirror's address alone" ;; esac case "$DOWNLOAD_ROOT" in *'?'*|*'#'*) err "SURREAL_CLI_DOWNLOAD_ROOT carries a query or fragment, which would be printed wherever the download host is named; set it to the mirror's address alone" ;; esac # What remains of the authority is a host — a name, an IPv4 address or a # bracketed IPv6 one — and an optional port: never empty, as in `https:///x`, # which would leave the whole value to be read as a path. case "$_authority" in ''|:*|*[!]A-Za-z0-9._:[-]*) err "SURREAL_CLI_DOWNLOAD_ROOT is not an http or https URL naming a host, such as https://download.example.com" ;; esac case "$_authority" in *[A-Za-z0-9]*) ;; *) err "SURREAL_CLI_DOWNLOAD_ROOT is not an http or https URL naming a host, such as https://download.example.com" ;; esac expand() { case "$1" in (\~) echo "$HOME";; (\~/*) echo "$HOME/${1#\~/}";; (*) echo "$1";; esac } # The most an asset download keeps. Releases are tens of megabytes; the cap is # far above any, and stops a host that never ends its response from filling # the disk. The launcher holds its own downloads to the same. ASSET_LIMIT=1073741824 # What `capped` returns for a download that ran past its cap. OVERSIZE=100 # capped : run a command that writes a # download to stdout, keeping at most bytes of it in the file. Returns # $OVERSIZE when it sent more, and otherwise the command's own status. `head` # ends the transfer once it has a byte more than the cap, whatever the tool: # curl's --max-filesize stops a response of undeclared length only from 8.4, # and wget has no limit for a single file at all. capped() { _cap=$1 _cap_out=$2 shift 2 { "$@"; echo "$?" >"$_cap_out.status"; } | head -c "$((_cap + 1))" >"$_cap_out" _cap_size=$(wc -c <"$_cap_out" | tr -d ' ') _cap_status=$(cat "$_cap_out.status" 2>/dev/null || echo 1) rm -f "$_cap_out.status" [ "$_cap_size" -gt "$_cap" ] && return "$OVERSIZE" return "$_cap_status" } # fetch : an asset, at most $ASSET_LIMIT bytes. No overall # budget, so a slow connection can finish a large transfer, but one that takes # a minute to connect, or stalls for a minute once connected, fails — with # wget, where it offers the limit. curl's stall limit does not cover # connecting, so that has its own. curl also refuses up front a response that # declares itself larger than the cap, with its own status 63. fetch() { if [ "$FETCH" = curl ]; then capped "$ASSET_LIMIT" "$2" curl --silent --fail --location --connect-timeout 60 \ --speed-limit 1 --speed-time 60 --max-filesize "$ASSET_LIMIT" "$1" else # shellcheck disable=SC2086 # a list of options, split on purpose capped "$ASSET_LIMIT" "$2" wget --quiet $WGET_LIMITS "$1" -O - fi } # fetch_small : a pointer or a digest, a few bytes, which # must arrive whole within 30 seconds, so a host that accepts the connection # and never answers fails the install rather than hanging it. The deadline is # the installer's own, so it holds whichever tool fetches. fetch_small() { if [ "$FETCH" = curl ]; then bounded 30 curl --silent --fail --location "$1" --output "$2" >/dev/null else bounded 30 wget --quiet "$1" -O "$2" >/dev/null fi } # fetch_pointer : a channel pointer. 0 when it arrived, 1 # when the CDN says it is absent, and an error for anything else. The CDN # answers a missing key with 403 and some hosts with 404; both mean "not # published here", so the installer may try the next channel. A timeout, a # server error or no connection must not fall through to a pre-release, since # a stable release may exist behind them. fetch_pointer() { if [ "$FETCH" = curl ]; then _code=$(bounded 30 curl --silent --location --output "$2" --write-out '%{http_code}' "$1") || err "could not fetch $1 within 30 seconds, so nothing was installed" else _headers="$WORK/pointer.headers" _status=0 # shellcheck disable=SC2016 # the inner shell expands its own $1..$3 bounded 30 sh -c 'exec wget --server-response --quiet -O "$1" "$2" 2>"$3"' sh "$2" "$1" "$_headers" >/dev/null || _status=$? _code=$(awk '/^ *HTTP\// { code = $2 } END { print code }' "$_headers" 2>/dev/null) # A 200 whose transfer did not finish has its status line but a partial # body, so only a transfer wget reports as complete counts as a pointer. if [ "$_status" -ne 0 ] && [ "$_code" = 200 ]; then err "could not finish fetching $1 within 30 seconds, so nothing was installed" fi fi case "$_code" in 200) return 0 ;; 403 | 404) return 1 ;; *) err "could not fetch $1 (HTTP status '${_code:-none}'), so nothing was installed" ;; esac } # sha256 : the bare hex digest. sha256() { if command -v sha256sum >/dev/null 2>&1; then sha256sum "$1" | cut -d' ' -f1 else shasum -a 256 "$1" | cut -d' ' -f1 fi } # fetch_verified # # The digest is fetched first: a release that is only half published costs # one small request, and a missing digest can never read as a pass. fetch_verified() { fetch_small "$1.txt" "$WORK/digest" || err "could not fetch $1.txt within 30 seconds" _expected=$(cut -d' ' -f1 <"$WORK/digest" | tr 'A-F' 'a-f' | tr -d '\r\n') case "$_expected" in *[!0-9a-f]* | "") err "$1.txt does not hold a SHA-256 digest" ;; esac [ "${#_expected}" -eq 64 ] || err "$1.txt does not hold a SHA-256 digest" fetch "$1.$2" "$3" case $? in 0) ;; "$OVERSIZE" | 63) err "$1.$2 is larger than any release, past $ASSET_LIMIT bytes. Nothing was installed." ;; *) err "could not fetch $1.$2" ;; esac _actual=$(sha256 "$3") [ "$_actual" = "$_expected" ] || err "$1.$2 did not arrive intact (expected $_expected, got $_actual). Nothing was installed." } # Run a command with a deadline of $1 seconds, printing its stdout. Fails if it # exits non-zero or is still running at the deadline, when it is killed: a # binary that never answers must fail the install, not hang it. bounded() { _limit=$1 shift "$@" >"$WORK/bounded.out" 2>/dev/null /dev/null 2>&1 & _watch=$! if wait "$_job"; then _ok=true; else _ok=false; fi kill "$_watch" 2>/dev/null [ "$_ok" = true ] && cat "$WORK/bounded.out" } parse() { while [ $# -ge 1 ]; do case "$1" in -n|--nightly) CHANNEL="${CHANNEL:+conflict}nightly" ;; -a|--alpha) CHANNEL="${CHANNEL:+conflict}alpha" ;; -b|--beta) CHANNEL="${CHANNEL:+conflict}beta" ;; -v|--version) [ $# -ge 2 ] || err "--version needs a version, such as 3.3.2" VERSION="$2" shift ;; --with) [ $# -ge 2 ] || err "--with needs components, such as cloud,lsp" WITH="$2" shift ;; --db) DB_FLAG=1; DB_FLAG_NAME="$1" ;; # `--minimal` predates the switch and is kept as another name for # `--no-db`, `--with` included, so scripts that use it keep working. --no-db|--minimal) DB_FLAG=0; DB_FLAG_NAME="$1" ;; -i|--interactive) INTERACTIVE=true ;; -h|--help) usage; exit 0 ;; -*) err "unknown option $1; --help lists them" ;; *) INSTALL_DIR="$1" ;; esac shift done case "$CHANNEL" in conflict*) err "choose one of --nightly, --beta and --alpha" ;; esac if [ -n "$CHANNEL" ] && [ -n "$VERSION" ]; then err "choose either --version or a channel" fi _release="" [ -n "$VERSION" ] && _release="--version" [ -n "$CHANNEL" ] && _release="--$CHANNEL" # `--with db` asks for the database by name, so like a release it implies # the database when nothing else says. _with_db="" _db_spec="" for _component in $(echo "$WITH" | tr ',' ' '); do case "$_component" in db | db@*) _with_db=1 ;; esac case "$_component" in db@*) # Every database selector must name the same release: two in one # --with cannot both be installed as the pinned one. _spec_here=$(normalise_spec "${_component#db@}") if [ -n "$_db_spec" ] && [ "$_spec_here" != "$_db_spec" ]; then err "--with names two database releases, db@$_db_spec and $_component; drop one of them" fi _db_spec=$_spec_here ;; esac done # The switch: a flag, then the variable, then the default. if [ -n "$DB_FLAG" ]; then INSTALL_DB=$DB_FLAG _db_said="$DB_FLAG_NAME" else case "${SURREAL_CLI_INSTALL_DB:-}" in 1|0) INSTALL_DB=$SURREAL_CLI_INSTALL_DB _db_said="SURREAL_CLI_INSTALL_DB=$SURREAL_CLI_INSTALL_DB" ;; "") # A release, or the database named in --with, is a request for # the database, so it holds whatever the default becomes. if [ -n "$_release" ] || [ -n "$_with_db" ]; then INSTALL_DB=1; else INSTALL_DB=$INSTALL_DB_DEFAULT; fi _db_said="" ;; *) err "SURREAL_CLI_INSTALL_DB must be 1 (install the database) or 0 (do not)" ;; esac fi # An error rather than a warning: the caller has asked for a database # release and for no database, and either guess installs what one of the # two said not to. Refusing costs a rerun before anything is downloaded; # guessing costs a machine that is not what the script describes, found # out later. It is also what `--minimal` with a version always did. if [ "$INSTALL_DB" = 0 ] && [ -n "$_release" ]; then err "$_release chooses the database release, but $_db_said installs no database; drop one of them" fi # `--with db` against an explicit opt-out is a conflict, refused before # anything is downloaded. Against the default it is not: it implied the # database above. if [ "$INSTALL_DB" = 0 ] && [ -n "$_with_db" ]; then err "--with db installs the database, which $_db_said turns off; drop one of them" fi # A `db@` in --with names a database release, so it cannot sit beside a # channel, and beside --version only when they name the same release. # Refused, not guessed: either guess silently replaces what was asked for. # The spellings are compared as the launcher reads them: a leading `v` is # dropped and `stable` is `latest`, so `v3.3.2` and `3.3.2` are one release. if [ -n "$_db_spec" ]; then _want=$(normalise_spec "$_db_spec") if [ -n "$CHANNEL" ] && [ "$_want" != "$CHANNEL" ]; then err "--with db@$_db_spec names a database release, which --$CHANNEL would replace; drop one of them" fi if [ -n "$VERSION" ] && [ "$(normalise_spec "$VERSION")" != "$_want" ]; then err "--with db@$_db_spec and --version $VERSION name different database releases; drop one of them" fi fi } # A release spelling as the launcher reads it: `v3.3.2` is `3.3.2`, and # `stable` is `latest`. Kept in step with `Spec::parse` in `src/spec.rs`. normalise_spec() { _spec=$(printf '%s' "$1" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//') _spec=${_spec#v} case "$_spec" in stable) echo latest ;; # A major version is a number, so `02` is `2`, as `u64` reads it. *[!0-9]* | '') echo "$_spec" ;; *) echo "$_spec" | sed 's/^0*\([0-9]\)/\1/' ;; esac } platform() { case "$(uname -s)" in Linux) _os=linux; EXT=tgz ;; Darwin) _os=darwin; EXT=tgz ;; MINGW* | MSYS* | CYGWIN*) _os=windows; EXT=exe ;; *) err "unsupported operating system: $(uname -s)" ;; esac case "$(uname -m)" in arm64 | aarch64) _cpu=arm64 ;; x86_64 | x86-64 | x64 | amd64) _cpu=amd64 ;; *) err "unsupported CPU architecture: $(uname -m)" ;; esac PLATFORM="${_os}-${_cpu}" BINARY=surreal [ "$EXT" = exe ] && BINARY=surreal.exe } # shadowed : warn, on one line, when the `surreal` that comes first # on PATH is not the one just installed. An older standalone database installed # as `surreal` is the usual one, and it would take every command meant for the # launcher as its own. Nothing is said when none is on PATH: the closing # message already says how to put the install directory there. shadowed() { _first=$(command -v surreal 2>/dev/null) || return 0 case "$_first" in /*) ;; *) return 0 ;; esac [ "$_first" = "$1" ] && return 0 # The same file by another path, such as a symlink to it, is no shadow. # shellcheck disable=SC3013 # -ef is in every shell this script supports [ "$_first" -ef "$1" ] && return 0 echo "Warning: $_first comes first on your PATH, so 'surreal' runs it rather than $1; remove it, since an older standalone database there would shadow the launcher." >&2 } main() { parse "$@" for _tool in uname mkdir tar cut tr head wc; do command -v "$_tool" >/dev/null 2>&1 || err "you need '$_tool' installed and in your path" done if command -v curl >/dev/null 2>&1; then FETCH=curl elif command -v wget >/dev/null 2>&1; then FETCH=wget # An idle limit and a single try, each only where this wget lists it: # GNU and BusyBox both offer `-T`, only GNU retries and offers `--tries`, # and an option a wget does not know fails every download. _help=$(wget --help 2>&1) case "$_help" in *"-T"*) WGET_LIMITS="-T 60" ;; esac case "$_help" in *"--tries"*) WGET_LIMITS="$WGET_LIMITS -t 1" ;; esac else err "you need 'curl' or 'wget' installed and in your path" fi command -v sha256sum >/dev/null 2>&1 || command -v shasum >/dev/null 2>&1 || err "you need 'sha256sum' or 'shasum' installed to check what is downloaded" platform WORK=$(mktemp -d 2>/dev/null || mktemp -d -t surreal-install) || err "could not create a temporary directory" trap 'rm -rf "$WORK"' EXIT INT TERM echo "" echo " .d8888b. 888 8888888b. 888888b." echo "d88P Y88b 888 888 'Y88b 888 '88b" echo "Y88b. 888 888 888 888 .88P" echo " 'Y888b. 888 888 888d888 888d888 .d88b. 8888b. 888 888 888 8888888K." echo " 'Y88b. 888 888 888P' 888P' d8P Y8b '88b 888 888 888 888 'Y88b" echo " '888 888 888 888 888 88888888 .d888888 888 888 888 888 888" echo "Y88b d88P Y88b 888 888 888 Y8b. 888 888 888 888 .d88P 888 d88P" echo " 'Y8888P' 'Y88888 888 888 'Y8888 'Y888888 888 8888888P' 8888888P'" echo "" # The launcher echo "Fetching the latest surreal launcher..." # `latest.txt` names a stable launcher. Until one exists a pre-release is # all there is, and it is named by `beta.txt` or `alpha.txt` alone, so fall # back to those in the order the release channels offer them. _pointer="" for _channel in latest beta alpha; do if fetch_pointer "$DOWNLOAD_ROOT/cli/launcher/$_channel.txt" "$WORK/latest"; then _pointer="$_channel" break fi done [ -n "$_pointer" ] || err "could not fetch cli/launcher/latest.txt, beta.txt or alpha.txt from $DOWNLOAD_ROOT within 30 seconds (it may publish no surreal launcher), so nothing was installed" _launcher=$(tr -d ' \r\n' <"$WORK/latest") _launcher="v${_launcher#v}" case "$_launcher" in v[0-9]*.[0-9]*.[0-9]*) ;; *) err "$DOWNLOAD_ROOT/cli/launcher/$_pointer.txt does not name a version" ;; esac echo "Installing surreal $_launcher for $PLATFORM..." # Named for the executable, as every product is: `surreal-v`. _asset="$DOWNLOAD_ROOT/cli/launcher/$_launcher/surreal-$_launcher.$PLATFORM" fetch_verified "$_asset" "$EXT" "$WORK/download.$EXT" if [ "$EXT" = tgz ]; then tar -zxf "$WORK/download.$EXT" -C "$WORK" || err "could not unpack the downloaded archive" [ -f "$WORK/surreal" ] || err "the archive held no 'surreal'" mv "$WORK/surreal" "$WORK/$BINARY.new" else mv "$WORK/download.$EXT" "$WORK/$BINARY.new" fi chmod 755 "$WORK/$BINARY.new" _said=$(bounded 30 "$WORK/$BINARY.new" --version) || err "the downloaded launcher does not run on this machine, or did not answer within 30 seconds" # The checksum proves the archive arrived as published; only the binary's # own version proves it is the release the pointer names. Build metadata # (`+...`) names the same release. _reported=$(printf '%s\n' "$_said" | awk '{ for (i = 1; i <= NF; i++) if ($i ~ /^v?[0-9]+\.[0-9]+\.[0-9]+/) { print $i; exit } }') _reported=${_reported#v} _reported=${_reported%%+*} # The pointer's build metadata names the same release, so it is dropped from # the pointer's version as it is from the reported one. The download URL # keeps it. _named=${_launcher#v} _named=${_named%%+*} [ "$_reported" = "$_named" ] || err "the downloaded launcher reports ${_reported:-no version}, but cli/launcher/$_pointer.txt names $_named, so nothing was installed" # The database, through the launcher still in the temporary directory. # An existing `surreal` — perhaps the database itself, from the previous # installer — is replaced only once this has worked, so a failed download # leaves the machine as it was. if [ "$INSTALL_DB" = 1 ]; then _db="db@${VERSION:-${CHANNEL:-latest}}" # A `db@spec` in --with names the release to pin. It is installed here, # pinned, rather than latest followed by the --with loop, which installs # without pinning and leaves latest as the default. if [ -z "$VERSION" ]; then for _component in $(echo "$WITH" | tr ',' ' '); do case "$_component" in db@*) _db=$_component ;; esac done fi echo "Installing the database ($_db)..." "$WORK/$BINARY.new" install "$_db" --pin --no-input || err "could not install the database" # The exit criterion of a fresh install: the database runs with the # network forbidden. bounded 30 env SURREAL_CLI_OFFLINE=1 "$WORK/$BINARY.new" db --version >/dev/null || err "the database was installed but does not run offline" fi # Where it goes _loc=$(expand "$INSTALL_DIR") mkdir -p "$_loc" 2>/dev/null if [ ! -d "$_loc" ] || [ ! -w "$_loc" ]; then if [ "$INTERACTIVE" = true ]; then echo "" printf "Where would you like to install 'surreal' [~/.surrealdb]? " read -r _loc &2 if [ "$INSTALL_DB" = 1 ]; then echo "Warning: not installed:$_failed. 'surreal' and the database are ready." >&2 else echo "Warning: not installed:$_failed. 'surreal' is ready." >&2 fi fi fi echo "" echo "SurrealDB successfully installed in:" echo " $SURREAL" echo "" if [ "$_loc" != "$INSTALL_DIR" ]; then echo "To ensure that surreal is in your \$PATH run:" echo " PATH=$_loc:\$PATH" echo "Or to move it to $INSTALL_DIR run:" echo " sudo mv $SURREAL $INSTALL_DIR" echo "" fi if [ "$INSTALL_DB" = 1 ]; then echo "The database is installed and pinned. To install 'surreal' without it" echo "next time, pass --no-db or set SURREAL_CLI_INSTALL_DB=0." else echo "No database was installed (--no-db, or SURREAL_CLI_INSTALL_DB=0). It" echo "installs on first use, or now with:" echo " surreal install db" fi echo "" echo "To see what is installed run:" echo " surreal list" echo "To start an in-memory database server run:" echo " surreal db start --log debug --user root --pass root memory" echo "For help with getting started visit:" echo " https://surrealdb.com/docs" echo "" } main "$@"