#!/usr/bin/env bash
# Vex installer for macOS and Linux.
#
# curl -fsSL https://www.projectvex.ai/install.sh | bash
#
# What this script does, and nothing else:
# 1. Reads the release manifest (latest-mac.yml or latest-linux.yml) from
# GitHub Releases of Vex-Foundation/Vex, the only place Vex builds live.
# 2. Downloads the build for this machine and checks its size and SHA-512
# against that manifest.
# 3. macOS: checks the Apple Developer ID signature is Vex's (team
# K75L5DDLK8) and that Gatekeeper accepts it as notarized, then puts
# Vex.app in /Applications (or ~/Applications).
# Linux: installs the .deb with apt on Debian and Ubuntu, or the
# AppImage into ~/.local everywhere else. Linux builds are not
# code-signed, so the checksum is the only check available there.
#
# It never asks for a seed phrase or a master password, never touches your
# wallet data, and never strips the macOS quarantine flag. sudo is used only
# for apt on Linux.
#
# Options (environment variables):
# VEX_VERSION=0.3.1 install a specific release instead of the latest
# VEX_INSTALL_DIR=
macOS: where Vex.app goes (default /Applications)
# VEX_LINUX_PACKAGE=appimage force the AppImage (or "deb") on Linux
#
# Everything lives inside main(), which runs on the last line, so a download
# cut off halfway through executes nothing.
if [ -z "${BASH_VERSION:-}" ]; then
echo "error: run this installer with bash: curl -fsSL https://www.projectvex.ai/install.sh | bash" >&2
exit 1
fi
set -euo pipefail
REPO="Vex-Foundation/Vex"
MAC_TEAM_ID="K75L5DDLK8"
BUNDLE_ID="ai.projectvex.app"
DOCS_URL="https://www.projectvex.ai/docs/install"
if [ -t 1 ]; then
BOLD=$'\033[1m' DIM=$'\033[2m' BLUE=$'\033[38;5;63m' RED=$'\033[31m' YELLOW=$'\033[33m' RESET=$'\033[0m'
else
BOLD='' DIM='' BLUE='' RED='' YELLOW='' RESET=''
fi
say() { printf '%s\n' "$*"; }
step() { printf '%s==>%s %s\n' "$BLUE" "$RESET" "$*"; }
warn() { printf '%swarning:%s %s\n' "$YELLOW" "$RESET" "$*" >&2; }
die() {
printf '%serror:%s %s\n' "$RED" "$RESET" "$*" >&2
exit 1
}
need() { command -v "$1" >/dev/null 2>&1 || die "this installer needs '$1', which is not on your PATH."; }
# Base64 sha512 from the manifest -> lowercase hex, to compare with shasum.
b64_to_hex() { printf '%s' "$1" | base64 -d | od -An -v -tx1 | tr -d ' \n'; }
sha512_hex() {
if command -v sha512sum >/dev/null 2>&1; then
sha512sum "$1" | cut -d' ' -f1
else
shasum -a 512 "$1" | cut -d' ' -f1
fi
}
file_size() { wc -c <"$1" | tr -d ' '; }
# Prints " " for every entry in an electron-builder
# latest*.yml manifest.
manifest_files() {
awk '
$1 == "-" && $2 == "url:" { url = $3; sha = ""; next }
$1 == "sha512:" && url != "" { sha = $2; next }
$1 == "size:" && url != "" { print url, sha, $2; url = "" }
' "$1"
}
# The version goes into a download URL, so it has to look like one.
manifest_version() {
local v
v=$(awk '$1 == "version:" { print $2; exit }' "$1")
[[ "$v" =~ ^[0-9]+\.[0-9]+\.[0-9]+([-+][0-9A-Za-z.-]+)?$ ]] || die "unexpected version in the release manifest: '$v'"
printf '%s\n' "$v"
}
fetch_manifest() { #
local url
if [ -n "${VEX_VERSION:-}" ]; then
url="https://github.com/$REPO/releases/download/v${VEX_VERSION#v}/$1"
else
url="https://github.com/$REPO/releases/latest/download/$1"
fi
curl -fsSL --retry 2 -o "$2" "$url" ||
die "could not read $1 from GitHub Releases. The build for your OS may still be uploading; try again in a few minutes, or see https://github.com/$REPO/releases"
}
# Picks the manifest entry whose file name ends in and downloads it
# into the temp dir, failing unless its size and SHA-512 match the manifest.
# Sets ASSET_NAME and ASSET_PATH.
download_verified() { #
local name='' sha='' size='' version
version=$(manifest_version "$1")
read -r name sha size < <(manifest_files "$1" | awk -v s="$2" 'substr($1, length($1) - length(s) + 1) == s { print; exit }') || true
[ -n "$name" ] || die "release $version has no '*$2' build."
# The name becomes a URL path and a local file name: allow nothing exotic.
[[ "$name" =~ ^Vex-[0-9A-Za-z.+-]+"$2"$ ]] || die "unexpected file name in the release manifest: $name"
step "Downloading $name"
ASSET_NAME=$name
ASSET_PATH="$TMP/$name"
curl -fL --retry 2 --progress-bar -o "$ASSET_PATH" \
"https://github.com/$REPO/releases/download/v$version/$name" ||
die "download failed: $name"
step "Checking SHA-512 against the release manifest"
[ "$(file_size "$ASSET_PATH")" = "$size" ] ||
die "size mismatch for $name (expected $size bytes). Not installing."
[ "$(sha512_hex "$ASSET_PATH")" = "$(b64_to_hex "$sha")" ] ||
die "SHA-512 mismatch for $name. The file is not the published build. Not installing."
}
print_next_steps() {
say ""
say "${BOLD}Before first run${RESET}"
if command -v docker >/dev/null 2>&1 || [ -d /Applications/Docker.app ]; then
say " ${DIM}ok${RESET} Docker found"
else
say " ${YELLOW}todo${RESET} Install Docker Desktop or Docker Engine with Compose. Vex runs its local stack in it."
fi
say " ${YELLOW}todo${RESET} Have an OpenRouter API key ready. Inference runs on your own account."
say ""
say "${DIM}Vex will never DM you first or ask for your seed phrase. Official sources:${RESET}"
say "${DIM}projectvex.ai, x.com/ProjectVEXai, github.com/$REPO${RESET}"
}
install_macos() {
need ditto
need codesign
need spctl
local major
major=$(sw_vers -productVersion | cut -d. -f1)
[ "$major" -ge 13 ] || die "Vex needs macOS 13 or newer (this Mac runs $(sw_vers -productVersion))."
# A Terminal running under Rosetta reports x86_64 on Apple silicon; take the
# native build anyway.
local arch
case "$(uname -m)" in
arm64) arch=arm64 ;;
x86_64)
if [ "$(sysctl -in sysctl.proc_translated 2>/dev/null || true)" = "1" ]; then arch=arm64; else arch=x64; fi
;;
*) die "unsupported Mac architecture: $(uname -m)" ;;
esac
local dest="${VEX_INSTALL_DIR:-/Applications}"
if [ -z "${VEX_INSTALL_DIR:-}" ] && [ ! -w /Applications ]; then
dest="$HOME/Applications"
fi
mkdir -p "$dest"
local target="$dest/Vex.app"
step "Reading the latest Vex release"
fetch_manifest latest-mac.yml "$TMP/latest-mac.yml"
local version
version=$(manifest_version "$TMP/latest-mac.yml")
if [ -d "$target" ]; then
local current
current=$(defaults read "$target/Contents/Info" CFBundleShortVersionString 2>/dev/null || true)
if [ "$current" = "$version" ]; then
say "Vex $version is already installed at $target."
print_next_steps
return
fi
fi
download_verified "$TMP/latest-mac.yml" "-mac-$arch.zip"
step "Unpacking"
mkdir "$TMP/unpacked"
ditto -x -k "$ASSET_PATH" "$TMP/unpacked"
local app="$TMP/unpacked/Vex.app"
[ -d "$app" ] || die "$ASSET_NAME does not contain Vex.app. Not installing."
# Downloads made by curl carry no quarantine flag, so Gatekeeper would never
# look at this app on first launch. Do here what it would have done, and pin
# the signer to Vex's own Developer ID team.
step "Verifying the Apple signature (team $MAC_TEAM_ID)"
local requirement="anchor apple generic and identifier \"$BUNDLE_ID\" and certificate leaf[subject.OU] = \"$MAC_TEAM_ID\" and certificate 1[field.1.2.840.113635.100.6.2.6] exists and certificate leaf[field.1.2.840.113635.100.6.1.13] exists"
codesign --verify --deep --strict -R="$requirement" "$app" 2>/dev/null ||
die "Vex.app is not signed by Vex's Apple Developer ID. Not installing."
if [ "$(spctl --status 2>/dev/null || true)" = "assessments enabled" ]; then
local verdict
verdict=$(spctl --assess --type execute -vv "$app" 2>&1) || true
[[ "$verdict" == *$'\nsource=Notarized Developer ID'* ]] ||
die "Gatekeeper does not accept Vex.app as notarized. Not installing."
else
warn "Gatekeeper is turned off on this Mac, so notarization was not checked. The Developer ID signature was."
fi
if pgrep -qx Vex; then
die "Vex is running. Quit it (Cmd+Q) and run this installer again."
fi
step "Installing to $target"
if [ -e "$target" ]; then
mv "$target" "$TMP/Vex.app.previous"
fi
if ! mv "$app" "$target"; then
[ -e "$TMP/Vex.app.previous" ] && mv "$TMP/Vex.app.previous" "$target"
die "could not write $target."
fi
say ""
say "${BOLD}Vex $version is installed.${RESET} Open it from Launchpad, or run: open -a Vex"
print_next_steps
}
install_linux_deb() {
local sudo=''
if [ "$(id -u)" -ne 0 ]; then
command -v sudo >/dev/null 2>&1 ||
die "installing the .deb needs root or sudo. Re-run with VEX_LINUX_PACKAGE=appimage to install without it."
sudo='sudo'
fi
download_verified "$TMP/latest-linux.yml" "-linux-amd64.deb"
step "Installing with apt (you may be asked for your sudo password)"
# apt reads local packages as the unprivileged _apt user.
chmod 755 "$TMP"
chmod 644 "$ASSET_PATH"
$sudo apt-get install -y "$ASSET_PATH" || die "apt could not install $ASSET_NAME."
say ""
say "${BOLD}Vex $(manifest_version "$TMP/latest-linux.yml") is installed.${RESET} Launch it from your applications menu."
}
install_linux_appimage() {
download_verified "$TMP/latest-linux.yml" "-linux-x86_64.AppImage"
local data="${XDG_DATA_HOME:-$HOME/.local/share}"
local appimage="$data/vex/Vex.AppImage"
step "Installing to $appimage"
mkdir -p "$data/vex" "$data/applications" "$HOME/.local/bin"
chmod 755 "$ASSET_PATH"
mv -f "$ASSET_PATH" "$appimage"
ln -sf "$appimage" "$HOME/.local/bin/vex"
# Menu entry, built from the one inside the AppImage so it launches with the
# same arguments the build ships with. --appimage-extract needs no FUSE.
# Best effort: without it the entry still works, just plainly.
local exec_args="%U"
if (cd "$TMP" && "$appimage" --appimage-extract '*.desktop' >/dev/null 2>&1 &&
"$appimage" --appimage-extract 'usr/share/icons/*' >/dev/null 2>&1); then
local shipped png
shipped=$(find "$TMP/squashfs-root" -maxdepth 1 -name '*.desktop' 2>/dev/null | head -n1)
if [ -n "$shipped" ]; then
exec_args=$(sed -n 's/^Exec=AppRun *//p' "$shipped" | head -n1)
[ -n "$exec_args" ] || exec_args="%U"
fi
png=$(find "$TMP/squashfs-root/usr/share/icons" -path '*512x512*' -name '*.png' 2>/dev/null | head -n1)
if [ -n "$png" ]; then
mkdir -p "$data/icons/hicolor/512x512/apps"
cp "$png" "$data/icons/hicolor/512x512/apps/vex.png"
fi
fi
cat >"$data/applications/vex.desktop" </dev/null 2>&1; then
update-desktop-database "$data/applications" >/dev/null 2>&1 || true
fi
say ""
say "${BOLD}Vex $(manifest_version "$TMP/latest-linux.yml") is installed.${RESET} Launch it from your applications menu, or run: vex"
case ":$PATH:" in
*":$HOME/.local/bin:"*) ;;
*) say "${DIM}(~/.local/bin is not on your PATH, so run $appimage directly, or add it.)${RESET}" ;;
esac
if ! { ldconfig -p 2>/dev/null || true; } | grep 'libfuse\.so\.2' >/dev/null; then
warn "AppImages need FUSE 2 (libfuse2) to start. Install it with your package manager if Vex does not open."
fi
}
install_linux() {
[ "$(uname -m)" = "x86_64" ] ||
die "Vex ships x64 Linux builds only for now (this machine is $(uname -m)). See $DOCS_URL"
step "Reading the latest Vex release"
fetch_manifest latest-linux.yml "$TMP/latest-linux.yml"
local pkg="${VEX_LINUX_PACKAGE:-}"
if [ -z "$pkg" ]; then
if command -v apt-get >/dev/null 2>&1 && command -v dpkg >/dev/null 2>&1; then pkg=deb; else pkg=appimage; fi
fi
case "$pkg" in
deb) install_linux_deb ;;
appimage) install_linux_appimage ;;
*) die "VEX_LINUX_PACKAGE must be 'deb' or 'appimage', not '$pkg'." ;;
esac
print_next_steps
}
main() {
need curl
need awk
need base64
need od
say ""
say "${BOLD}Vex${RESET} ${DIM}Your keys. Your machine. Your agent.${RESET}"
say ""
TMP=$(mktemp -d "${TMPDIR:-/tmp}/vex-install.XXXXXX")
trap 'rm -rf "$TMP"' EXIT
case "$(uname -s)" in
Darwin) install_macos ;;
Linux) install_linux ;;
MINGW* | MSYS* | CYGWIN*)
die "on Windows, run this in PowerShell instead: irm https://www.projectvex.ai/install.ps1 | iex"
;;
*) die "unsupported OS: $(uname -s). See $DOCS_URL" ;;
esac
}
main "$@"