#!/usr/bin/env bash # mountOS installer, https://mountos.sh/install # Usage: curl -fsSL https://mountos.sh/install | bash [-- OPTIONS] # --pkg NAME package to install (default: the full client bundle, mountos-macos on # macOS / mountos-windows on Windows; mountos-cli elsewhere. The bundles # carry the mountos CLI plus that platform's mount backend) # --version X.Y.Z install specific version (default: latest) # --major N install latest of major series N # --platform NAME override platform: linux, darwin, windows # --arch NAME override arch: amd64, arm64 # --prefix PATH install prefix (default: /usr/local/bin) # --list list available versions for --pkg and exit # --auto-install-mcp register the MCP connector with local AI assistants after install set -euo pipefail DIST_BASE="https://mountos.sh/install" PKG="" VERSION="" MAJOR="" PREFIX="/usr/local/bin" PLATFORM_OVERRIDE="" ARCH_OVERRIDE="" LIST=0 MCP_INSTALL=0 # Out-of-band trust root: verifies the sha256 checksum was signed by mountOS's # release key, not just fetched from the same bucket the artifact lives in (a # compromised bucket/CDN could otherwise swap the binary and its checksum together). # Public key, not a secret; Ed25519, matching internal/identity.ReleaseSigningKey. RELEASE_SIGNING_PUBKEY="MCowBQYDK2VwAyEAeMcv01w22DLxoreGVjrnkWbyl0/VnOLuSzm5QkoX354=" _usage() { echo "Usage: bash mountos.sh [--pkg NAME] [--version X.Y.Z] [--major N]" >&2 echo " [--platform NAME] [--arch NAME] [--prefix PATH] [--list]" >&2 echo " [--auto-install-mcp]" >&2 exit 1 } _safe_arg() { local val="$1" name="$2" [[ "$val" =~ ^[A-Za-z0-9._-]+$ ]] || { echo "Invalid value for ${name}: '${val}'" >&2; exit 1 } echo "$val" } while [[ $# -gt 0 ]]; do case "$1" in --pkg) [[ $# -ge 2 ]] || _usage; PKG=$(_safe_arg "$2" "--pkg"); shift 2 ;; --version) [[ $# -ge 2 ]] || _usage; VERSION=$(_safe_arg "$2" "--version"); shift 2 ;; --major) [[ $# -ge 2 ]] || _usage [[ "$2" =~ ^[0-9]+$ ]] || { echo "--major must be a number" >&2; exit 1; } MAJOR="$2"; shift 2 ;; --platform) [[ $# -ge 2 ]] || _usage; PLATFORM_OVERRIDE=$(_safe_arg "$2" "--platform"); shift 2 ;; --arch) [[ $# -ge 2 ]] || _usage; ARCH_OVERRIDE=$(_safe_arg "$2" "--arch"); shift 2 ;; --prefix) [[ $# -ge 2 ]] || _usage; PREFIX="$2"; shift 2 ;; --list) LIST=1; shift ;; --auto-install-mcp) MCP_INSTALL=1; shift ;; --help|-h) _usage ;; *) echo "Unknown option: $1" >&2; _usage ;; esac done # Detect platform and arch, apply overrides (needed before the default --pkg # choice below, and before --list, so both see the real target platform). _os=$(uname -s | tr '[:upper:]' '[:lower:]') _arch_raw=$(uname -m) case "$_arch_raw" in x86_64) _arch="amd64" ;; aarch64|arm64) _arch="arm64" ;; *) _arch="$_arch_raw" ;; esac [[ -n "$PLATFORM_OVERRIDE" ]] && _os="$PLATFORM_OVERRIDE" [[ -n "$ARCH_OVERRIDE" ]] && _arch="$ARCH_OVERRIDE" case "$_os" in darwin|linux|windows) PLATFORM_ARCH="${_os}-${_arch}" ;; *) echo "Unsupported platform: $_os" >&2; exit 1 ;; esac # Default package is the full client bundle where one exists (macOS: signed .pkg # with FSKit/Desktop/CLI; Windows: signed .exe with the mountosio driver + CLI). # Linux has no bundled GUI suite, so it defaults to the bare CLI archive. if [[ -z "$PKG" ]]; then case "$_os" in darwin) PKG="mountos-macos" ;; windows) PKG="mountos-windows" ;; *) PKG="mountos-cli-${_os}" ;; esac fi # mountos-macos ships one universal .pkg (FSKit/Desktop build universal, and the # CLI inside the bundle is lipo'd into a universal binary too), unlike every other # package here, which is per-arch. [[ "$PKG" == "mountos-macos" ]] && PLATFORM_ARCH="darwin-universal" # The CLI is published per platform (it versions per platform, and one shared package's # latest.json cannot carry three different versions). Accept the old bare name so existing # instructions and scripts keep working. if [[ "$PKG" == "mountos-cli" ]]; then PKG="mountos-cli-${_os}" fi # --list: fetch versions.json for PKG, print, and exit if [[ "$LIST" == 1 ]]; then VERSIONS_URL="${DIST_BASE}/dist/${PKG}/versions.json" echo "Fetching versions for ${PKG}..." _raw=$(curl -fsSL --connect-timeout 10 --max-time 30 "$VERSIONS_URL") || { echo "Failed to fetch versions for ${PKG}" >&2; exit 1 } _latest="" _versions=() if command -v jq &>/dev/null; then _latest=$(jq -r '.latest // empty' <<< "$_raw") while IFS= read -r v; do [[ -n "$v" ]] && _versions+=("$v") done < <(jq -r ' [.versions[] | split(".") | map(tonumber)] | sort | reverse | .[] | join(".") ' <<< "$_raw" 2>/dev/null || jq -r '.versions[] // empty' <<< "$_raw") else _latest=$(printf '%s' "$_raw" \ | grep -o '"latest"[[:space:]]*:[[:space:]]*"[^"]*"' \ | sed 's/.*"latest"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/' | head -1) while IFS= read -r v; do [[ -n "$v" ]] && _versions+=("$v") done < <(printf '%s' "$_raw" \ | grep -o '"[0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*[^"]*"' \ | tr -d '"') fi echo "Available versions for ${PKG}:" for v in "${_versions[@]}"; do if [[ -n "$MAJOR" && "${v%%.*}" != "$MAJOR" ]]; then continue fi if [[ "$v" == "$_latest" ]]; then printf ' %s (latest)\n' "$v" else printf ' %s\n' "$v" fi done exit 0 fi # Detect sha256 tool if command -v sha256sum &>/dev/null; then _sha256() { sha256sum "$1" | awk '{print $1}'; } elif command -v shasum &>/dev/null; then _sha256() { shasum -a 256 "$1" | awk '{print $1}'; } else echo "Neither sha256sum nor shasum found, cannot verify download integrity" >&2; exit 1 fi # Pick something that can check an Ed25519 signature. macOS ships LibreSSL as # /usr/bin/openssl, and LibreSSL supports neither Ed25519 nor `pkeyutl -rawin`, so a # full OpenSSL is looked for first (Homebrew keeps openssl@3 keg-only, off PATH, hence # the explicit paths), then Node, whose crypto.verify handles Ed25519 natively. # When neither exists the install continues with a loud warning, because refusing # outright would block every stock Mac from installing at all. # # Each candidate must round-trip a signature it generates itself before it is chosen. # A version number would not do: it says nothing about how the binary was compiled, and # the point is to separate "this tool cannot check Ed25519" from "this signature is # bad". Once a backend passes its self-test, a later failure means tampering. VERIFIER="" VERIFIER_KIND="" _openssl_can_ed25519() { local bin="$1" dir dir=$(mktemp -d) || return 1 local rc=1 if "$bin" genpkey -algorithm ed25519 -out "$dir/k" 2>/dev/null && "$bin" pkey -in "$dir/k" -pubout -out "$dir/p" 2>/dev/null && printf 'probe' >| "$dir/d" && "$bin" pkeyutl -sign -inkey "$dir/k" -rawin -in "$dir/d" -out "$dir/s" 2>/dev/null && "$bin" pkeyutl -verify -pubin -inkey "$dir/p" -rawin -in "$dir/d" \ -sigfile "$dir/s" &>/dev/null; then rc=0 fi rm -rf "$dir" return $rc } _node_can_ed25519() { node -e ' const c = require("crypto"); const { publicKey, privateKey } = c.generateKeyPairSync("ed25519"); const d = Buffer.from("probe"); process.exit(c.verify(null, d, publicKey, c.sign(null, d, privateKey)) ? 0 : 1); ' >/dev/null 2>&1 } _pick_verifier() { local c for c in "${MOUNTOS_OPENSSL:-}" openssl3 /opt/homebrew/opt/openssl@3/bin/openssl \ /usr/local/opt/openssl@3/bin/openssl openssl; do [[ -n "$c" ]] || continue command -v "$c" &>/dev/null || continue if _openssl_can_ed25519 "$c"; then VERIFIER="$c" VERIFIER_KIND="openssl"; return 0 fi done if command -v node &>/dev/null && _node_can_ed25519; then VERIFIER="node" VERIFIER_KIND="node"; return 0 fi return 1 } _pick_verifier || true # Full client bundles are opaque platform installers (.pkg/.exe), handed directly # to the OS installer below rather than extracted; every other package is a plain # tar.gz/zip of binaries. $1 = pkg name. _ext_for_pkg() { case "$1" in mountos-macos) echo "pkg" ;; mountos-windows) echo "exe" ;; *) [[ "$_os" == "windows" ]] && echo "zip" || echo "tar.gz" ;; esac } ARTIFACT_URL="" EXPECTED_SHA256="" EXT="" if [[ -n "$VERSION" ]]; then EXT="$(_ext_for_pkg "$PKG")" ARTIFACT_URL="${DIST_BASE}/dist/${PKG}/${VERSION}/${PLATFORM_ARCH}.${EXT}" SHA256_URL="${DIST_BASE}/dist/${PKG}/${VERSION}/${PLATFORM_ARCH}.sha256" else if [[ -n "$MAJOR" ]]; then MANIFEST_URL="${DIST_BASE}/dist/${PKG}/v${MAJOR}/latest.json" else MANIFEST_URL="${DIST_BASE}/dist/${PKG}/latest.json" fi echo "Fetching manifest: ${MANIFEST_URL}" MANIFEST=$(curl -fsSL --connect-timeout 10 --max-time 30 "$MANIFEST_URL") || { echo "Failed to fetch manifest for ${PKG}" >&2; exit 1 } if command -v jq &>/dev/null; then VERSION=$(jq -r '.version // empty' <<< "$MANIFEST") ARTIFACT_URL=$(jq -r --arg k "$PLATFORM_ARCH" '.artifacts[$k].url // empty' <<< "$MANIFEST") EXPECTED_SHA256=$(jq -r --arg k "$PLATFORM_ARCH" '.artifacts[$k].sha256 // empty' <<< "$MANIFEST") else VERSION=$(echo "$MANIFEST" | grep -o '"version"[[:space:]]*:[[:space:]]*"[^"]*"' \ | head -1 | sed 's/.*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/') _block=$(echo "$MANIFEST" | grep -F -A10 "\"${PLATFORM_ARCH}\"") ARTIFACT_URL=$(echo "$_block" | grep '"url"' | head -1 \ | sed 's/.*"url"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/') EXPECTED_SHA256=$(echo "$_block" | grep '"sha256"' | head -1 \ | sed 's/.*"sha256"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/') fi SHA256_URL="" [[ -z "$VERSION" ]] && { echo "Could not parse version from manifest" >&2; exit 1; } [[ -z "$ARTIFACT_URL" ]] && { echo "No artifact for ${PLATFORM_ARCH} in ${PKG} ${VERSION}" >&2; exit 1 } _path="${ARTIFACT_URL%%\?*}" _basename="${_path##*/}" EXT="${_basename#*.}" fi echo "Installing ${PKG} ${VERSION} (${PLATFORM_ARCH})" # /usr/local/bin is root-owned by default on a fresh Linux host; escalate just the # final install step via sudo instead of failing outright. $0 is literally "bash" (not # a runnable path) when this script runs via `curl | bash`, so telling the user to # "sudo bash $0" is a dead end -- there is no script file on disk to point sudo at. _writable_ancestor() { [[ -d "$1" ]] || { _writable_ancestor "$(dirname "$1")"; return; } [[ -w "$1" ]] } SUDO="" if ! _writable_ancestor "$PREFIX"; then if command -v sudo &>/dev/null; then echo "Prefix ${PREFIX} needs root to write to; installing with sudo (you may be prompted for your password)." >&2 SUDO="sudo" else echo "Prefix ${PREFIX} is not writable and sudo is not available." >&2 echo "Re-run with a user-local prefix instead:" >&2 echo " curl -fsSL ${DIST_BASE} | bash -s -- --prefix \$HOME/.local/bin" >&2 exit 1 fi fi TMPDIR_PKG=$(mktemp -d) trap 'rm -rf "${TMPDIR_PKG:?}"' EXIT OUTFILE="${TMPDIR_PKG}/artifact.${EXT}" echo "Downloading ${ARTIFACT_URL}" curl -fSL --progress-bar --connect-timeout 30 --max-time 600 "$ARTIFACT_URL" -o "$OUTFILE" # Printed when nothing on this system can check the release signature. It states the # fact and the one action that fixes it. It deliberately does not explain checksums or # speculate about what could go wrong: the reader is installing software, not auditing # the distribution channel. _warn_unverified() { echo "" >&2 echo " WARNING: could not verify this download." >&2 echo "" >&2 echo " No tool on this system can check the mountOS release signature." >&2 echo " Install one of these, then run the same command again:" >&2 echo "" >&2 echo " brew install openssl@3" >&2 echo " brew install node" >&2 echo "" >&2 } # Verifies $1 (a sha256 hex digest) was signed by RELEASE_SIGNING_PUBKEY, fetching the # detached signature from the dist///.sha256.sig path # upload.sh writes it to. upload.sh refuses to publish an artifact without one, so on a # system that CAN verify, a missing signature is treated exactly like a bad one: it # means the checksum did not come from a mountOS release. Without a verifier there is # nothing to check, and the install continues with the warning above. _verify_release_signature() { local digest="$1" if [[ -z "$VERIFIER" ]]; then _warn_unverified return 0 fi local sig_url="${DIST_BASE}/dist/${PKG}/${VERSION}/${PLATFORM_ARCH}.sha256.sig" local sig_b64 sig_b64=$(curl -fsSL --connect-timeout 10 --max-time 30 "$sig_url" 2>/dev/null) || { echo "No release signature at ${sig_url}, refusing to install (possible tampering)" >&2 exit 1 } local datafile sigfile datafile=$(mktemp); sigfile=$(mktemp) printf '%s' "$digest" >| "$datafile" printf '%s' "$sig_b64" | openssl base64 -d -A -out "$sigfile" 2>/dev/null \ || printf '%s' "$sig_b64" | base64 -d >| "$sigfile" 2>/dev/null local ok=1 if [[ "$VERIFIER_KIND" == "openssl" ]]; then local pubfile pubfile=$(mktemp) { echo "-----BEGIN PUBLIC KEY-----" echo "$RELEASE_SIGNING_PUBKEY" echo "-----END PUBLIC KEY-----" } >| "$pubfile" "$VERIFIER" pkeyutl -verify -pubin -inkey "$pubfile" -rawin -in "$datafile" \ -sigfile "$sigfile" &>/dev/null || ok=0 rm -f "$pubfile" else local js rc js=$(mktemp) cat > "$js" <<'NODEJS' const crypto = require('crypto'); const fs = require('fs'); const [pubB64, dataPath, sigPath] = process.argv.slice(2); try { const pem = '-----BEGIN PUBLIC KEY-----\n' + pubB64 + '\n-----END PUBLIC KEY-----\n'; // The null algorithm is required for Ed25519: the scheme hashes internally. const ok = crypto.verify(null, fs.readFileSync(dataPath), crypto.createPublicKey(pem), fs.readFileSync(sigPath)); process.exit(ok ? 0 : 1); // 1 = the signature is wrong } catch (e) { console.error(e.message); process.exit(2); // 2 = this runtime could not perform the check } NODEJS rc=0 "$VERIFIER" "$js" "$RELEASE_SIGNING_PUBKEY" "$datafile" "$sigfile" 2>/dev/null || rc=$? rm -f "$js" # Exit 2 means the check could not run at all. Selection self-tested this runtime, # so it should not happen; treat it as unverified rather than as tampering, which # would turn a broken local toolchain into a refused install. if [[ "$rc" == 2 ]]; then rm -f "$datafile" "$sigfile" _warn_unverified return 0 fi [[ "$rc" == 0 ]] || ok=0 fi rm -f "$datafile" "$sigfile" if [[ "$ok" == 1 ]]; then echo "Release signature OK (${VERIFIER_KIND})" else echo "Release signature verification FAILED, refusing to install (possible tampering)" >&2 exit 1 fi } if [[ -n "$EXPECTED_SHA256" ]]; then _got=$(_sha256 "$OUTFILE") if [[ "$_got" != "$EXPECTED_SHA256" ]]; then echo "sha256 mismatch: expected ${EXPECTED_SHA256}, got ${_got}" >&2; exit 1 fi echo "sha256 OK" _verify_release_signature "$_got" elif [[ -n "${SHA256_URL:-}" ]]; then _expected=$(curl -fsSL --connect-timeout 10 --max-time 30 "$SHA256_URL" | awk '{print $1}') || { echo "No sha256 published at ${SHA256_URL}, refusing to install" >&2; exit 1 } [[ -n "$_expected" ]] || { echo "Empty sha256 at ${SHA256_URL}, refusing to install" >&2; exit 1 } _got=$(_sha256 "$OUTFILE") if [[ "$_got" != "$_expected" ]]; then echo "sha256 mismatch: expected ${_expected}, got ${_got}" >&2; exit 1 fi echo "sha256 OK" _verify_release_signature "$_got" else echo "No sha256 published for ${PKG} ${VERSION} (${PLATFORM_ARCH}), refusing to install" >&2 exit 1 fi # Full client bundles (.pkg/.exe) are whole platform installers, not archives to # unpack: hand them to the OS installer instead of tar/unzip. Copy out of # TMPDIR_PKG first since it's deleted on EXIT the moment this script returns, but # `open` on macOS launches Installer.app asynchronously and returns immediately, # well before the user finishes the GUI flow. if [[ "$EXT" == "pkg" || "$EXT" == "exe" ]]; then _dest_dir="${HOME}/Downloads" [[ -d "$_dest_dir" && -w "$_dest_dir" ]] || _dest_dir="$PWD" _dest="${_dest_dir}/mountOS-${VERSION}-${PLATFORM_ARCH}.${EXT}" cp "$OUTFILE" "$_dest" echo "Saved installer to ${_dest}" if [[ "$EXT" == "pkg" && "$(uname -s)" == "Darwin" ]]; then echo "Opening the mountOS installer..." open "$_dest" echo "Finish the installation in the window that just opened (component selection, license, admin password)." elif [[ "$EXT" == "exe" && "$_os" == "windows" ]]; then echo "Launching the mountOS installer..." chmod +x "$_dest" 2>/dev/null || true "$_dest" else echo "This installer targets ${PLATFORM_ARCH} and can't run on this host; run it manually on that platform." fi exit 0 fi EXTRACT_DIR="${TMPDIR_PKG}/pkg" mkdir -p "$EXTRACT_DIR" case "$EXT" in tar.gz|gz|tgz) tar -xzf "$OUTFILE" -C "$EXTRACT_DIR" ;; zip) command -v unzip &>/dev/null || { echo "unzip not found, cannot extract .zip" >&2; exit 1; } unzip -q "$OUTFILE" -d "$EXTRACT_DIR" ;; *) echo "Unknown archive type: .${EXT}" >&2; exit 1 ;; esac if [[ -f "${EXTRACT_DIR}/install.sh" ]]; then $SUDO bash "${EXTRACT_DIR}/install.sh" --prefix "$PREFIX" else $SUDO mkdir -p "$PREFIX" installed=0 while IFS= read -r -d '' bin; do $SUDO install -m755 "$bin" "$PREFIX/" installed=$(( installed + 1 )) done < <(find "$EXTRACT_DIR" -maxdepth 2 -type f -perm -100 -print0) (( installed > 0 )) || { echo "No executable files found in archive" >&2; exit 1; } echo "Installed ${installed} file(s) to ${PREFIX}" fi echo "mountOS ${PKG} ${VERSION} installed successfully." if [[ -x "${PREFIX}/mountos" ]]; then if [[ "$MCP_INSTALL" == 1 ]]; then echo echo "Registering MCP connector with local AI assistants..." "${PREFIX}/mountos" mcp install || \ echo " (mcp install reported an issue; re-run later: ${PREFIX}/mountos mcp install)" else echo echo "AI assistant access (optional): register the read-only MCP connector with" echo "Claude Desktop, Claude Code, Codex and Gemini by running:" echo " ${PREFIX}/mountos mcp install" fi fi