#!/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 "$@"