#!/bin/sh # Spacefast CLI installer - https://spacefast.com/install.sh # # What this script does, in order: # 1. Reads flags and environment overrides. Nothing is executed while parsing. # 2. Looks for an existing `sf` and works out how it was installed. # 3. Picks a channel: npm, then bun, then a standalone binary from the # GitHub release manifest. An existing install keeps its own channel. # 4. Installs or updates through that channel. The binary channel verifies # the SHA-256 checksum published in the release manifest. # 5. Optionally runs `sf setup agent` to wire up agent clients. # # It never runs sudo, never evals downloaded text, and only ever writes to a # package manager's own global prefix or to the install directory it prints. # Run it with --dry-run to see every step it would take without doing any of it. set -eu # --- Constants --------------------------------------------------------------- DEFAULT_MANIFEST_URL="https://github.com/spacefast/cli/releases/latest/download/distribution-v1.json" NPM_PACKAGE="spacefast" NPM_DIST_TAGS_URL="https://registry.npmjs.org/-/package/spacefast/dist-tags" WINDOWS_HINT="irm https://spacefast.com/install.ps1 | iex" # The published `spacefast` package declares engines.node >= 20.3. MIN_NODE_MAJOR=20 MIN_NODE_MINOR=3 # --- Settings from the environment ------------------------------------------- if [ -n "${SPACEFAST_CLI_MANIFEST_URL:-}" ]; then MANIFEST_URL="$SPACEFAST_CLI_MANIFEST_URL" elif [ -n "${SPACEFAST_DOWNLOAD_BASE_URL:-}" ]; then MANIFEST_URL="${SPACEFAST_DOWNLOAD_BASE_URL%/}/cli/distribution-v1.json" else MANIFEST_URL="$DEFAULT_MANIFEST_URL" fi INSTALL_DIR="${SPACEFAST_INSTALL_DIR:-${STATTIC_INSTALL_DIR:-${SPACEFAST_BIN_DIR:-}}}" METHOD="${SPACEFAST_INSTALL_METHOD:-auto}" # An explicit install directory means "put a binary there", so it pins the # channel unless --method says otherwise. if [ -n "$INSTALL_DIR" ] && [ "$METHOD" = "auto" ]; then METHOD="binary" fi AGENTS=0 AGENT="auto" MODE="remote" YES=0 DRY_RUN=0 # Filled in as the script decides what to do. CHANNEL="" EXISTING_PATH="" EXISTING_CHANNEL="" EXISTING_VERSION="" found_dir="" TARGET_VERSION="" RESULT_ACTION="" RESULT_PATH="" STAGE_DIR="" # --- Output helpers ---------------------------------------------------------- say() { printf '==> %s\n' "$*" >&2 } fail() { printf 'error: %s\n' "$*" >&2 exit 1 } # Runs a command, or prints it when --dry-run is set. Arguments are passed # through as a list: nothing here is re-parsed by a shell. run() { if [ "$DRY_RUN" = "1" ]; then printf 'dry-run: %s\n' "$*" >&2 return 0 fi "$@" } have() { command -v "$1" >/dev/null 2>&1 } usage() { cat <<'EOF' Install or update the Spacefast CLI. Usage: curl -fsSL https://spacefast.com/install.sh | sh curl -fsSL https://spacefast.com/install.sh | sh -s -- --agents -y curl -fsSL https://spacefast.com/install.sh | sh -s -- --dry-run Already installed? This updates the existing install through the same channel it came from, and does nothing if it is already current. Options: -y, --yes Do not prompt during agent setup (implied when stdin is not a terminal, so piped and agent runs never block) --agents Configure detected agent clients after install --agent NAME Agent/client for setup agent (default: auto) --remote Use hosted MCP through the CLI login (default) --oauth Use hosted MCP with editor-managed OAuth --local Use local stdio MCP --method NAME Install channel: auto, npm, bun, or binary (default auto) --bin-dir DIR Install a standalone binary into DIR --install-dir DIR Install a standalone binary into DIR --dry-run Print every step without installing anything -h, --help Print help Channel selection (--method auto): An existing install is updated in place through its own channel. Otherwise npm is preferred when node meets the CLI's floor, then bun, then a standalone binary from the GitHub release manifest. Passing an install directory selects the binary channel. Environment: SPACEFAST_INSTALL_METHOD Same values as --method SPACEFAST_INSTALL_DIR Same as --install-dir SPACEFAST_CLI_MANIFEST_URL Release manifest URL for the binary channel SPACEFAST_DOWNLOAD_BASE_URL Base URL; the manifest is read from /cli/distribution-v1.json EOF } # --- Argument parsing -------------------------------------------------------- parse_args() { while [ "$#" -gt 0 ]; do case "$1" in -y|--yes) YES=1 ;; --agents) AGENTS=1 ;; --agent) shift AGENT="${1:-}" [ -n "$AGENT" ] || fail "Missing value for --agent." ;; --hosted|--oauth) MODE="oauth" ;; --remote) MODE="remote" ;; --local) MODE="local" ;; --method) shift METHOD="${1:-}" [ -n "$METHOD" ] || fail "Missing value for --method." ;; --bin-dir|--install-dir) option="$1" shift INSTALL_DIR="${1:-}" [ -n "$INSTALL_DIR" ] || fail "Missing value for $option." if [ "$METHOD" = "auto" ]; then METHOD="binary" fi ;; --dry-run) DRY_RUN=1 ;; -h|--help) usage exit 0 ;; *) usage >&2 fail "Unknown option: $1" ;; esac shift done case "$METHOD" in auto|npm|bun|binary) ;; *) fail "Unknown install method: $METHOD (expected auto, npm, bun, or binary)" ;; esac # Agents and humans piping this script have no terminal to answer prompts on. if [ ! -t 0 ]; then YES=1 fi } # --- Downloading and hashing ------------------------------------------------- # Returns non-zero instead of exiting, so callers can treat a failed fetch as # "unknown" rather than fatal. download_to() { if have curl; then curl -fsSL -H 'Cache-Control: no-cache' "$1" -o "$2" elif have wget; then wget -q -O "$2" "$1" else return 127 fi } fetch() { if ! have curl && ! have wget; then fail "curl or wget is required to download the Spacefast CLI." fi download_to "$1" "$2" || fail "Failed to download $1" } sha256_file() { if have sha256sum; then sha256sum "$1" | awk '{print $1}' elif have shasum; then shasum -a 256 "$1" | awk '{print $1}' elif have openssl; then openssl dgst -sha256 "$1" | awk '{print $NF}' else fail "sha256sum, shasum, or openssl is required to verify the Spacefast CLI download." fi } # --- Reading the release manifest -------------------------------------------- # Pulls "": "" from the block that follows "". json_field_for_target() { awk -v target="\"$1\"" -v field="\"$2\"" ' $0 ~ target { inside = 1; next } inside && $0 ~ field { line = $0 sub(".*" field "[[:space:]]*:[[:space:]]*\"", "", line) sub("\".*", "", line) print line exit } ' "$3" } # Top-level fields precede the per-target files. manifest_field() { awk -v field="\"$2\"" ' /"files"[[:space:]]*:/ { exit } $0 ~ field { line = $0 sub(".*" field "[[:space:]]*:[[:space:]]*\"", "", line) sub("\".*", "", line) print line exit } ' "$1" } npm_latest_version() { tags="$TMP_DIR/dist-tags.json" if download_to "$NPM_DIST_TAGS_URL" "$tags" 2>/dev/null; then sed -n 's/.*"latest"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$tags" | head -n 1 fi } # --- Platform detection (binary channel only) -------------------------------- detect_target() { case "$(uname -s)" in Darwin) platform="darwin" ;; Linux) platform="linux" ;; MINGW*|MSYS*|CYGWIN*) fail "Use the Windows installer from PowerShell instead: $WINDOWS_HINT" ;; *) fail "Unsupported operating system: $(uname -s)" ;; esac case "$(uname -m)" in arm64|aarch64) arch="arm64" ;; x86_64|amd64) arch="x64" ;; *) fail "Unsupported CPU architecture: $(uname -m)" ;; esac printf '%s-%s\n' "$platform" "$arch" } # --- Toolchain probes -------------------------------------------------------- node_meets_floor() { raw="${1#v}" major="${raw%%.*}" rest="${raw#*.}" minor="${rest%%.*}" case "$major" in ''|*[!0-9]*) return 1 ;; esac case "$minor" in ''|*[!0-9]*) minor=0 ;; esac if [ "$major" -gt "$MIN_NODE_MAJOR" ]; then return 0; fi if [ "$major" -lt "$MIN_NODE_MAJOR" ]; then return 1; fi [ "$minor" -ge "$MIN_NODE_MINOR" ] } npm_global_bin() { prefix="$(npm prefix -g 2>/dev/null || true)" [ -n "$prefix" ] || return 1 printf '%s/bin\n' "$prefix" } bun_global_bin() { bun_bin_dir="$(bun pm bin -g 2>/dev/null || true)" case "$bun_bin_dir" in /*) printf '%s\n' "$bun_bin_dir" ;; *) printf '%s/bin\n' "${BUN_INSTALL:-$HOME/.bun}" ;; esac } # True when the deepest existing ancestor of the directory is writable, which is # what decides whether a global install can proceed without sudo. dir_is_writable() { probe="$1" while [ ! -d "$probe" ] && [ "$probe" != "/" ] && [ -n "$probe" ]; do probe="$(dirname "$probe")" done [ -w "$probe" ] } npm_usable() { have npm || return 1 have node || return 1 node_meets_floor "$(node --version 2>/dev/null || echo 0)" || return 1 bin="$(npm_global_bin || true)" [ -n "$bin" ] || return 1 dir_is_writable "$bin" } bun_usable() { have bun || return 1 dir_is_writable "$(bun_global_bin)" } # --- Existing install detection ---------------------------------------------- # `sf` is a common command name (the Salesforce CLI ships one too), so a # candidate only counts when its own --version identifies it as Spacefast. spacefast_version_at() { output="$("$1" --version 2>/dev/null || true)" case "$output" in *spacefast*|*Spacefast*) ;; *) return 1 ;; esac # Split on anything that is not a digit or a dot, then take the first # version-shaped token out of e.g. "spacefast/0.0.27 darwin-arm64 node-v22". printf '%s\n' "$output" | tr -c '0-9.' '\n' \ | grep -E '^[0-9]+\.[0-9]+\.[0-9]+' | head -n 1 } detect_existing() { for name in sf spacefast; do candidate="$(command -v "$name" 2>/dev/null || true)" [ -n "$candidate" ] || continue version="$(spacefast_version_at "$candidate" || true)" if [ -n "$version" ]; then EXISTING_PATH="$candidate" EXISTING_VERSION="$version" break fi done if [ -z "$EXISTING_PATH" ]; then return 0 fi found_dir="$(dirname "$EXISTING_PATH")" # /usr/local/bin is both npm's default global bin and this script's default # binary target, so npm only wins when it also reports owning the package. if have npm && [ "$found_dir" = "$(npm_global_bin || true)" ] \ && npm ls -g --depth 0 "$NPM_PACKAGE" >/dev/null 2>&1; then EXISTING_CHANNEL="npm" return 0 fi if have bun && [ "$found_dir" = "$(bun_global_bin)" ]; then EXISTING_CHANNEL="bun" return 0 fi EXISTING_CHANNEL="binary" } # --- Channel selection ------------------------------------------------------- choose_channel() { if [ "$METHOD" != "auto" ]; then CHANNEL="$METHOD" return 0 fi # Keep an existing install on its own channel, unless the tool that put it # there is gone. A permissions problem still fails loudly rather than # quietly leaving a second copy behind on another channel. case "$EXISTING_CHANNEL" in npm) if have npm; then CHANNEL="npm"; return 0; fi say "npm is no longer installed; choosing a new channel." ;; bun) if have bun; then CHANNEL="bun"; return 0; fi say "bun is no longer installed; choosing a new channel." ;; binary) CHANNEL="binary" return 0 ;; esac if npm_usable; then CHANNEL="npm" elif bun_usable; then CHANNEL="bun" else CHANNEL="binary" fi } warn_if_not_on_path() { case ":$PATH:" in *":$1:"*) ;; *) say "Add $1 to PATH to run sf from any directory." ;; esac } up_to_date() { [ -n "$EXISTING_VERSION" ] && [ -n "$1" ] && [ "$EXISTING_VERSION" = "$1" ] } install_target_is_current() { [ "$EXISTING_CHANNEL" = "$1" ] \ && [ "$found_dir" = "$2" ] \ && up_to_date "$3" } # "updated" only when the existing install is the one being replaced; putting a # binary somewhere new while npm owns another copy is a fresh install. action_word() { if [ "$EXISTING_CHANNEL" = "$CHANNEL" ]; then printf 'updated\n' else printf 'installed\n' fi } # --- Channel: npm ------------------------------------------------------------ install_with_npm() { have npm || fail "npm was requested but is not installed." if have node; then node_version="$(node --version 2>/dev/null || echo unknown)" node_meets_floor "$node_version" \ || fail "node $node_version is older than the ${MIN_NODE_MAJOR}.${MIN_NODE_MINOR} the Spacefast CLI needs. Upgrade node, or re-run with --method binary." fi bin_dir="$(npm_global_bin || true)" [ -n "$bin_dir" ] || fail "Could not resolve npm's global prefix." dir_is_writable "$bin_dir" \ || fail "$bin_dir is not writable. Point npm at a writable prefix (npm config set prefix ~/.npm-global) or re-run with --method binary. This installer never uses sudo." TARGET_VERSION="$(npm_latest_version)" if install_target_is_current "npm" "$bin_dir" "$TARGET_VERSION"; then RESULT_ACTION="already current" RESULT_PATH="$EXISTING_PATH" return 0 fi RESULT_ACTION="$(action_word)" if [ "$RESULT_ACTION" = "updated" ]; then say "Updating the Spacefast CLI with npm" else say "Installing the Spacefast CLI with npm" fi run npm install -g "$NPM_PACKAGE@latest" RESULT_PATH="$bin_dir/sf" warn_if_not_on_path "$bin_dir" } # --- Channel: bun ------------------------------------------------------------ install_with_bun() { have bun || fail "bun was requested but is not installed." bin_dir="$(bun_global_bin)" dir_is_writable "$bin_dir" \ || fail "$bin_dir is not writable. Set BUN_INSTALL to a writable directory or re-run with --method binary. This installer never uses sudo." TARGET_VERSION="$(npm_latest_version)" if install_target_is_current "bun" "$bin_dir" "$TARGET_VERSION"; then RESULT_ACTION="already current" RESULT_PATH="$EXISTING_PATH" return 0 fi RESULT_ACTION="$(action_word)" if [ "$RESULT_ACTION" = "updated" ]; then say "Updating the Spacefast CLI with bun" else say "Installing the Spacefast CLI with bun" fi run bun install -g "$NPM_PACKAGE@latest" RESULT_PATH="$bin_dir/sf" warn_if_not_on_path "$bin_dir" } # --- Channel: standalone binary ---------------------------------------------- resolve_install_dir() { if [ -n "$INSTALL_DIR" ]; then printf '%s\n' "$INSTALL_DIR" return 0 fi # Update an existing binary where it already lives. if [ "$EXISTING_CHANNEL" = "binary" ] && [ -n "$EXISTING_PATH" ]; then dirname "$EXISTING_PATH" return 0 fi if [ -w /usr/local/bin ]; then printf '/usr/local/bin\n' else printf '%s/.local/bin\n' "$HOME" fi } install_binary() { target="$(detect_target)" dir="$(resolve_install_dir)" manifest="$TMP_DIR/distribution-v1.json" say "Fetching Spacefast CLI release manifest" fetch "$MANIFEST_URL" "$manifest" url="$(json_field_for_target "$target" url "$manifest")" sha256="$(json_field_for_target "$target" sha256 "$manifest")" name="$(json_field_for_target "$target" name "$manifest")" TARGET_VERSION="$(manifest_field "$manifest" version)" [ "$(manifest_field "$manifest" format)" = "spacefast.cli.distribution.v1" ] \ || fail "The release is not a Spacefast CLI distribution archive." compression="$(json_field_for_target "$target" compression "$manifest")" entrypoint="$(json_field_for_target "$target" entrypoint "$manifest")" payload_id="$(json_field_for_target "$target" payloadId "$manifest")" entrypoint_sha256="$(json_field_for_target "$target" entrypointSha256 "$manifest")" if [ -z "$url" ] || [ -z "$sha256" ] || [ -z "$name" ]; then fail "No Spacefast CLI binary found for $target in $MANIFEST_URL." fi case "$name" in */*|*..*) fail "Invalid Spacefast CLI asset name in release manifest." ;; esac [ "$compression" = "tar.gz" ] && [ "$entrypoint" = "spacefast" ] \ || fail "The release is not a Spacefast CLI distribution archive." install_path="$dir/spacefast" alias_path="$dir/sf" dir_is_writable "$dir" \ || fail "$dir is not writable. Re-run with SPACEFAST_INSTALL_DIR set to a writable directory. This installer never uses sudo." printf '%s\n' "$payload_id" | grep -Eq '^[a-f0-9]{64}$' || fail "Invalid compiler payload identity." printf '%s\n' "$entrypoint_sha256" | grep -Eq '^[a-f0-9]{64}$' || fail "Invalid CLI executable checksum." if [ -d "$dir/lib/$payload_id" ] && [ -x "$alias_path" ] && [ -f "$install_path" ] \ && [ "$(sha256_file "$install_path")" = "$entrypoint_sha256" ] \ && [ "$(sha256_file "$alias_path")" = "$entrypoint_sha256" ] \ && install_target_is_current "binary" "$dir" "$TARGET_VERSION"; then RESULT_ACTION="already current" RESULT_PATH="$EXISTING_PATH" return 0 fi if [ "$EXISTING_CHANNEL" = "binary" ] && [ "$found_dir" = "$dir" ]; then RESULT_ACTION="updated" else RESULT_ACTION="installed" fi if [ "$DRY_RUN" = "1" ]; then say "Would download $url" say "Would verify sha256 $sha256" say "Would install $install_path and alias $alias_path" RESULT_PATH="$alias_path" return 0 fi download="$TMP_DIR/$name" say "Downloading Spacefast CLI $TARGET_VERSION for $target" fetch "$url" "$download" actual_sha256="$(sha256_file "$download")" if [ "$actual_sha256" != "$sha256" ]; then fail "Checksum mismatch for $url." fi have tar || fail "tar is required to unpack the Spacefast CLI download." mkdir -p "$dir/lib" STAGE_DIR="$(mktemp -d "$dir/.spacefast-install.XXXXXX")" tar -xzf "$download" -C "$STAGE_DIR" set -- "$STAGE_DIR/lib"/* [ "$#" -eq 1 ] && [ "$1" = "$STAGE_DIR/lib/$payload_id" ] && [ -d "$1" ] \ || fail "The archive does not contain its declared compiler payload." [ "$(sha256_file "$STAGE_DIR/spacefast")" = "$entrypoint_sha256" ] \ || fail "The archive does not contain its declared CLI executable." candidate_version="$("$STAGE_DIR/spacefast" --version)" case "$candidate_version" in "spacefast/$TARGET_VERSION "*) ;; *) fail "The downloaded CLI does not match release $TARGET_VERSION." ;; esac if ! ln -s "spacefast" "$STAGE_DIR/sf" 2>/dev/null; then cp "$STAGE_DIR/spacefast" "$STAGE_DIR/sf" fi if [ -e "$dir/lib/$payload_id" ]; then have diff || fail "diff is required to verify an existing CLI payload." diff -r "$STAGE_DIR/lib/$payload_id" "$dir/lib/$payload_id" >/dev/null 2>&1 \ || fail "The installed payload $dir/lib/$payload_id differs from the release. Close running CLI processes and move that directory aside before retrying." else mv "$STAGE_DIR/lib/$payload_id" "$dir/lib/" \ || fail "Could not activate the CLI payload. Another installer may be running; retry when it finishes." fi # Each launcher embeds its own immutable payload identity. Existing processes # keep their old compiler while the same-filesystem rename updates new starts. mv -f "$STAGE_DIR/spacefast" "$install_path" mv -f "$STAGE_DIR/sf" "$alias_path" rm -rf "$STAGE_DIR" STAGE_DIR="" RESULT_PATH="$alias_path" warn_if_not_on_path "$dir" } # --- Agent setup ------------------------------------------------------------- setup_agents() { [ "$AGENTS" = "1" ] || return 0 sf_bin="$RESULT_PATH" if [ ! -x "$sf_bin" ] && [ "$DRY_RUN" != "1" ]; then sf_bin="$(command -v sf 2>/dev/null || true)" [ -n "$sf_bin" ] || fail "Installed the Spacefast CLI but could not find sf to configure agents." fi say "Configuring Spacefast for agents" if [ "$YES" = "1" ]; then set -- setup agent -y --agent "$AGENT" else set -- setup agent --agent "$AGENT" fi case "$MODE" in local) set -- "$@" --local ;; remote) set -- "$@" --remote ;; oauth) set -- "$@" --remote --oauth ;; esac run "$sf_bin" "$@" } # --- Main -------------------------------------------------------------------- main() { parse_args "$@" TMP_DIR="$(mktemp -d)" trap 'rm -rf "$TMP_DIR"; if [ -n "$STAGE_DIR" ]; then rm -rf "$STAGE_DIR"; fi' EXIT if [ "$DRY_RUN" = "1" ]; then say "Dry run: nothing will be installed or modified." fi detect_existing if [ -n "$EXISTING_PATH" ]; then say "Found the Spacefast CLI ${EXISTING_VERSION:-(unknown version)} at $EXISTING_PATH (installed via $EXISTING_CHANNEL)" fi choose_channel say "Using the $CHANNEL channel" case "$CHANNEL" in npm) install_with_npm ;; bun) install_with_bun ;; binary) install_binary ;; esac version="${TARGET_VERSION:-${EXISTING_VERSION:-unknown}}" if [ "$RESULT_ACTION" = "already current" ]; then say "Spacefast CLI $version is already current at $RESULT_PATH (via $CHANNEL) - nothing to do." setup_agents return 0 fi setup_agents if [ "$DRY_RUN" = "1" ]; then say "Would have $RESULT_ACTION Spacefast CLI $version at $RESULT_PATH via $CHANNEL." else say "Spacefast CLI $version $RESULT_ACTION at $RESULT_PATH via $CHANNEL." fi } main "$@"