#!/bin/bash set -e # Colors GREEN='\033[1;32m' BLUE='\033[1;34m' YELLOW='\033[1;33m' RED='\033[1;31m' BOLD='\033[1m' DIM='\033[2m' NC='\033[0m' # No Color # Banner: the Holm wordmark (ember palette) WORDMARK='\033[1;38;5;' echo -e " ${WORDMARK}215m█ █ ${WORDMARK}209m█▀█ █ ${WORDMARK}203m█▄ ▄█${NC}" echo -e " ${WORDMARK}166m█▀█ █▄█ ${WORDMARK}130m█▄▄ █ ▀ █${NC}" echo -e " ${DIM}Sovereign compute${NC}" echo "" # Detect OS and Arch OS="$(uname -s)" ARCH="$(uname -m)" case "$OS" in Linux) OS="linux" ;; Darwin) OS="darwin" ;; *) echo "Unsupported OS: $OS"; exit 1 ;; esac case "$ARCH" in x86_64) ARCH="amd64" ;; aarch64) ARCH="arm64" ;; arm64) ARCH="arm64" ;; *) echo "Unsupported Architecture: $ARCH"; exit 1 ;; esac # Paths SYSTEM_BINARY="/usr/local/bin/holm" USER_BINARY="$HOME/.local/bin/holm" SYSTEM_SERVICE="/etc/systemd/system/holm.service" USER_SERVICE="$HOME/.config/systemd/user/holm-local.service" USER_DB="$HOME/.holm/data.db" TRUSTED_RELEASE_BASE_URL="https://github.com/holmhq/releases/releases" PINNED_RELEASE_TAG="v0.272.0" # The pinned binary refuses first use until the license it embeds is accepted. # Refresh these with the pin; LICENSE_CONSENT is the binary's exact ID@digest. LICENSE_ID="holm-usage-license-v1" LICENSE_DIGEST="sha256:898163f1d2592ae95c7b722b8b89c9de371bbfe22f96259b030049cd6b250d3e" LICENSE_URL="https://license.holmhq.com/v1/" LICENSE_CONSENT="${LICENSE_ID}@${LICENSE_DIGEST}" # Options. With --mode the installer asks nothing, for coding agents and # scripts; without options it asks on the terminal. MODE_ARG="" ACCEPT_ARG="" DOMAIN_ARG="" EMAIL_ARG="" IP_ARG="" PORT_ARG="" usage() { cat <&2; exit 1; fi value="$2"; shift 2 ;; -h|--help) usage; exit 0 ;; *) echo "Unknown option: $opt" >&2; usage >&2; exit 1 ;; esac case "$opt" in --mode) MODE_ARG="$value" ;; --accept-license) ACCEPT_ARG="$value" ;; --domain) DOMAIN_ARG="$value" ;; --email) EMAIL_ARG="$value" ;; --ip) IP_ARG="$value" ;; --port) PORT_ARG="$value" ;; esac done case "$MODE_ARG" in ""|cli|local|server) ;; *) echo "Unknown --mode '$MODE_ARG': use cli, local or server" >&2; exit 1 ;; esac NONINTERACTIVE="" if [ -n "$MODE_ARG" ]; then NONINTERACTIVE=1 fi # Values reach a systemd unit and a root command line, so only plain ports, # IPv4 addresses, domain names and email addresses pass. LABEL_RE='[A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?' DOMAIN_RE="^${LABEL_RE}(\.${LABEL_RE})*$" EMAIL_RE="^[A-Za-z0-9._%+][A-Za-z0-9._%+-]*@${LABEL_RE}(\.${LABEL_RE})+$" valid_port() { [[ "$1" =~ ^[1-9][0-9]{0,4}$ ]] && [ "$1" -le 65535 ] } valid_ipv4() { local part [[ "$1" =~ ^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}$ ]] || return 1 for part in ${1//./ }; do [ "$((10#$part))" -le 255 ] || return 1 done } valid_domain() { # A bare IP cannot carry the admin. and app. names. [ "${#1}" -le 253 ] && [[ "$1" =~ $DOMAIN_RE ]] && ! valid_ipv4 "$1" } valid_email() { [ "${#1}" -le 254 ] && [[ "$1" =~ $EMAIL_RE ]] } IP_HELP="The IP must be an IPv4 address such as 192.168.1.20 (--ip)." PORT_HELP="The port must be a number from 1 to 65535 (--port)." DOMAIN_HELP="The domain must be a plain name such as example.com (--domain); with only an IP such as 203.0.113.5, use 203-0-113-5.sslip.io." EMAIL_HELP="The email must be a plain address such as ops@example.com (--email)." refuse_value() { echo -e "${RED}$1${NC}" >&2 echo "Nothing was changed." >&2 exit 1 } check_local_values() { valid_ipv4 "$1" || refuse_value "$IP_HELP" valid_port "$2" || refuse_value "$PORT_HELP" } check_server_values() { valid_domain "$1" || refuse_value "$DOMAIN_HELP" [ -z "$2" ] || valid_email "$2" || refuse_value "$EMAIL_HELP" } # Check the options before anything is downloaded. [ -z "$IP_ARG" ] || valid_ipv4 "$IP_ARG" || refuse_value "$IP_HELP" [ -z "$PORT_ARG" ] || valid_port "$PORT_ARG" || refuse_value "$PORT_HELP" [ -z "$DOMAIN_ARG" ] || valid_domain "$DOMAIN_ARG" || refuse_value "$DOMAIN_HELP" [ -z "$EMAIL_ARG" ] || valid_email "$EMAIL_ARG" || refuse_value "$EMAIL_HELP" # need_tty stops with advice instead of hanging when there is no terminal to # ask on, as in a coding agent's shell. need_tty() { if ! (: < /dev/tty) 2>/dev/null; then echo "There is no terminal to ask on. Run with --mode cli, local or server (see --help)." >&2 exit 1 fi } # Detect local IP (prefer 192.168.x.x or 10.x.x.x) detect_local_ip() { local ip # Try to get primary local IP ip=$(ip route get 1 2>/dev/null | awk '{print $7; exit}') if [ -z "$ip" ]; then # Fallback: get first non-loopback IP ip=$(hostname -I 2>/dev/null | awk '{print $1}') fi if [ -z "$ip" ]; then ip="127.0.0.1" fi echo "$ip" } # Check for existing installations check_existing() { SYSTEM_EXISTS=false USER_EXISTS=false SYSTEM_RUNNING=false USER_RUNNING=false CURRENT_VERSION="none" # Check system installation if [ -f "$SYSTEM_BINARY" ]; then SYSTEM_EXISTS=true CURRENT_VERSION=$($SYSTEM_BINARY --version 2>/dev/null | head -1 | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' || echo "unknown") if command -v systemctl >/dev/null 2>&1; then if systemctl is-active --quiet holm 2>/dev/null; then SYSTEM_RUNNING=true fi fi fi # Check user installation if [ -f "$USER_BINARY" ]; then USER_EXISTS=true if [ "$CURRENT_VERSION" = "none" ]; then CURRENT_VERSION=$($USER_BINARY --version 2>/dev/null | head -1 | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' || echo "unknown") fi if command -v systemctl >/dev/null 2>&1; then if systemctl --user is-active --quiet holm-local 2>/dev/null; then USER_RUNNING=true fi fi fi } # Resolve the independently trusted installer pin. The release workflow advances # this table only after verifying published bytes; first install never trusts latest. get_pinned_release() { TAG="$PINNED_RELEASE_TAG" NEW_VERSION="${TAG#v}" if ! EXPECTED_SHA256="$(trusted_release_sha256 "$TAG" "$OS" "$ARCH")"; then echo -e "${RED}No trusted checksum pin for ${TAG} ${OS}/${ARCH}.${NC}" exit 1 fi } trusted_release_sha256() { local tag="$1" local os="$2" local arch="$3" case "${tag}:${os}:${arch}" in v0.272.0:linux:amd64) echo "c6c0b54aefd2370f7ccce35e92418d54dbca73d46a505f2ab5143bc54e38606c" ;; v0.272.0:linux:arm64) echo "e87bc7ebf82a4c3d614d636d7002824da2061c426f648bec489092b8c87741dd" ;; v0.272.0:darwin:amd64) echo "5e3456b148410285cec3fc7ff9bab5e5e60ce21e00ffe7d1e9a19bdcdf130c2d" ;; v0.272.0:darwin:arm64) echo "276efe02116369a0d9f0349af863dd981d34c803160be41238c5444e56af882b" ;; *) return 1 ;; esac } verify_downloaded_tarball_sha256() { local archive="$1" local expected="$2" local actual if [ -z "$expected" ]; then echo -e "${RED}Trusted checksum is required before extracting release assets.${NC}" exit 1 fi if command -v sha256sum >/dev/null 2>&1; then actual="$(sha256sum "$archive" | awk '{print tolower($1)}')" elif command -v shasum >/dev/null 2>&1; then actual="$(shasum -a 256 "$archive" | awk '{print tolower($1)}')" else echo -e "${RED}sha256sum or shasum is required to verify the release asset.${NC}" exit 1 fi expected="$(printf '%s' "$expected" | awk '{print tolower($1)}')" if [ "$actual" != "$expected" ]; then echo -e "${RED}Release asset checksum mismatch.${NC}" echo -e "${DIM}Expected: ${expected}${NC}" echo -e "${DIM}Actual: ${actual}${NC}" exit 1 fi } # Show the license and ask once, before anything changes. confirm_license() { echo "" echo -e "${BOLD}License${NC}" echo "Holm ${TAG} is licensed under the Holm Usage License Agreement (${LICENSE_ID})." echo -e "Read it at ${BOLD}${LICENSE_URL}${NC}" if [ -n "$NONINTERACTIVE" ]; then if [ "$ACCEPT_ARG" = "$LICENSE_CONSENT" ]; then echo "Accepted with --accept-license." return 0 fi echo -e "${YELLOW}License not accepted. Nothing was changed.${NC}" echo "Once the person has read it and agrees, pass: --accept-license ${LICENSE_CONSENT}" return 1 fi read -p "Do you accept these terms? [y/N]: " ACCEPT < /dev/tty if [[ ! "$ACCEPT" =~ ^([Yy]|[Yy][Ee][Ss])$ ]]; then echo -e "${YELLOW}License not accepted. Nothing was changed.${NC}" return 1 fi } # Without a terminal sudo could wait forever at a password prompt; -n makes it # fail instead. as_root() { if [ "$EUID" -eq 0 ]; then "$@" elif [ -n "$NONINTERACTIVE" ]; then sudo -n "$@" else sudo "$@" fi } run_as() { local user="$1" shift if [ "$user" = "$(id -un)" ]; then "$@" elif [ "$EUID" -eq 0 ] && command -v runuser >/dev/null 2>&1; then runuser -u "$user" -- "$@" elif [ -n "$NONINTERACTIVE" ]; then sudo -n -u "$user" "$@" else sudo -u "$user" "$@" fi } # Before an upgrade touches the service, make sure the instance it opens has # accepted the new binary's license: the unit's --db, else the service user's # default ~/.holm/data.db. An instance that already accepted is not asked again. ensure_service_license() { local is_user_service="$1" local unit user home db if [ "$is_user_service" = "true" ]; then unit="$(systemctl --user cat holm-local 2>/dev/null)" || return 0 user="$(id -un)" home="$HOME" else unit="$(systemctl cat holm 2>/dev/null)" || return 0 user="$(printf '%s\n' "$unit" | sed -n 's/^User=//p' | tail -n 1)" user="${user:-root}" # The service user runs the downloaded binary from the private temp dir. chmod 755 "$TMP_DIR" home="$(getent passwd "$user" | cut -d: -f6)" fi db="$(printf '%s\n' "$unit" | sed -n 's/^ExecStart=.*--db[ =]\([^ ]*\).*/\1/p' | tail -n 1)" db="${db:-$home/.holm/data.db}" if run_as "$user" "$DOWNLOADED_BINARY" license status --require-accepted --db "$db" >/dev/null 2>&1; then return 0 fi confirm_license || return 1 run_as "$user" "$DOWNLOADED_BINARY" license accept --id "$LICENSE_ID" --digest "$LICENSE_DIGEST" --db "$db" } # Download binary download_binary() { FILE_NAME="holm-${TAG}-${OS}-${ARCH}.tar.gz" DOWNLOAD_URL="${TRUSTED_RELEASE_BASE_URL}/download/${TAG}/${FILE_NAME}" echo -e "${BLUE}Downloading ${BOLD}${TAG}${NC}${BLUE} for ${BOLD}${OS}/${ARCH}${NC}..." TMP_DIR="$(mktemp -d)" trap 'rm -rf "$TMP_DIR"' EXIT if ! curl -sL --fail "$DOWNLOAD_URL" -o "$TMP_DIR/$FILE_NAME"; then echo -e "${RED}Download failed!${NC}" exit 1 fi verify_downloaded_tarball_sha256 "$TMP_DIR/$FILE_NAME" "$EXPECTED_SHA256" tar -xzf "$TMP_DIR/$FILE_NAME" -C "$TMP_DIR" # Find the binary in extracted files if [ -f "$TMP_DIR/holm" ]; then DOWNLOADED_BINARY="$TMP_DIR/holm" elif [ -f "$TMP_DIR/holm-${OS}-${ARCH}" ]; then DOWNLOADED_BINARY="$TMP_DIR/holm-${OS}-${ARCH}" else DOWNLOADED_BINARY="$(find "$TMP_DIR" -type f -perm -u+x | head -n 1)" fi if [ -z "$DOWNLOADED_BINARY" ] || [ ! -f "$DOWNLOADED_BINARY" ]; then echo -e "${RED}Could not find binary in archive.${NC}" exit 1 fi chmod +x "$DOWNLOADED_BINARY" echo -e "${GREEN}Downloaded ${TAG}${NC}" } # Install for local development install_local_dev() { echo "" echo -e "${BOLD}Local Development Setup${NC}" confirm_license || exit 1 echo "" if ! command -v systemctl >/dev/null 2>&1; then echo -e "${RED}Local mode runs Holm as a systemd user service, and this machine has no systemctl.${NC}" echo "Install the CLI instead (--mode cli), then start a node with: holm start --runtime-mode manual" exit 1 fi # Detect local IP if [ -n "$NONINTERACTIVE" ]; then LOCAL_IP="${IP_ARG:-$(detect_local_ip)}" PORT="${PORT_ARG:-8080}" echo -e "${BLUE}Using ${BOLD}${LOCAL_IP}:${PORT}${NC}" else LOCAL_IP=$(detect_local_ip) echo -e "${BLUE}Detected local IP: ${BOLD}${LOCAL_IP}${NC}" read -p "Use this IP? [Y/n]: " CONFIRM < /dev/tty if [[ "$CONFIRM" =~ ^[Nn]$ ]]; then read -p "Enter IP address: " LOCAL_IP < /dev/tty fi # Port selection read -p "Port [8080]: " PORT < /dev/tty PORT=${PORT:-8080} fi check_local_values "$LOCAL_IP" "$PORT" # Create directories mkdir -p "$HOME/.local/bin" mkdir -p "$(dirname "$USER_DB")" mkdir -p "$HOME/.config/systemd/user" # Install binary echo -e "${BLUE}Installing binary to ${USER_BINARY}...${NC}" cp "$DOWNLOADED_BINARY" "$USER_BINARY" chmod +x "$USER_BINARY" # Ensure ~/.local/bin is in PATH if [[ ":$PATH:" != *":$HOME/.local/bin:"* ]]; then echo -e "${YELLOW}Note: Add ~/.local/bin to your PATH:${NC}" echo ' export PATH="$HOME/.local/bin:$PATH"' echo "" fi # Initialize database if not exists if [ ! -f "$USER_DB" ]; then echo -e "${BLUE}Initializing database...${NC}" "$USER_BINARY" server init \ --username dev \ --password dev \ --domain "$LOCAL_IP" \ --db "$USER_DB" \ --accept-license "$LICENSE_CONSENT" else echo -e "${BLUE}Database already exists at ${USER_DB}${NC}" "$USER_BINARY" license accept --id "$LICENSE_ID" --digest "$LICENSE_DIGEST" --db "$USER_DB" fi # Create user systemd service echo -e "${BLUE}Creating systemd user service...${NC}" cat > "$USER_SERVICE" << EOF [Unit] Description=Holm Local Development Server After=network.target [Service] Type=simple ExecStart=${USER_BINARY} server start --port ${PORT} --domain ${LOCAL_IP} --db ${USER_DB} Restart=always RestartSec=5 WorkingDirectory=$(dirname "$USER_DB") Environment=HOLM_ENV=development [Install] WantedBy=default.target EOF # Enable and start service echo -e "${BLUE}Enabling service...${NC}" systemctl --user daemon-reload systemctl --user enable holm-local systemctl --user start holm-local # Enable linger for persistence across reboots if command -v loginctl >/dev/null 2>&1; then loginctl enable-linger "$(whoami)" 2>/dev/null || true fi # Wait for service to start sleep 2 # Verify if systemctl --user is-active --quiet holm-local; then echo "" echo -e "${GREEN}Local development server installed and running!${NC}" echo "" echo -e " ${BOLD}Dashboard:${NC} http://admin.${LOCAL_IP}.nip.io:${PORT}" echo -e " ${BOLD}Apps:${NC} http://.${LOCAL_IP}.nip.io:${PORT}" echo -e " ${BOLD}Database:${NC} ${USER_DB}" echo "" echo -e " ${DIM}Credentials: dev / dev${NC}" echo "" echo -e "${BOLD}Commands:${NC}" echo " systemctl --user status holm-local # Check status" echo " systemctl --user restart holm-local # Restart" echo " journalctl --user -u holm-local -f # View logs" else echo -e "${RED}Service failed to start. Check logs:${NC}" echo " journalctl --user -u holm-local -n 50" exit 1 fi } # Install for production server install_production() { echo "" echo -e "${BOLD}Production Server Setup${NC}" echo -e "${DIM}(Requires sudo)${NC}" confirm_license || exit 1 echo "" if [ -n "$NONINTERACTIVE" ]; then DOMAIN="$DOMAIN_ARG" EMAIL="$EMAIL_ARG" if [ -z "$DOMAIN" ]; then echo -e "${RED}--mode server needs --domain.${NC}" exit 1 fi else read -p "Domain (e.g. my-paas.com): " DOMAIN < /dev/tty read -p "Email (Enter to skip HTTPS): " EMAIL < /dev/tty fi check_server_values "$DOMAIN" "$EMAIL" echo "" if [ "$EUID" -ne 0 ]; then if ! command -v sudo >/dev/null 2>&1; then refuse_value "Server mode needs root: run the installer as root, or install sudo." fi if [ -n "$NONINTERACTIVE" ] && ! sudo -n true 2>/dev/null; then echo -e "${RED}Server mode needs root, and sudo here asks for a password that cannot be typed without a terminal.${NC}" >&2 echo "Run the installer as root (for example over ssh as root), or allow this user passwordless sudo." >&2 echo "Nothing was changed." >&2 exit 1 fi fi # Install binary to system path echo -e "${BLUE}Installing binary to ${SYSTEM_BINARY}...${NC}" as_root cp "$DOWNLOADED_BINARY" "$SYSTEM_BINARY" as_root chmod +x "$SYSTEM_BINARY" if [ "$OS" = "linux" ]; then as_root setcap CAP_NET_BIND_SERVICE=+eip "$SYSTEM_BINARY" 2>/dev/null || true fi # Run service install command; one array element per argument, so a value # can never split into extra flags. local cmd=("$SYSTEM_BINARY" service install --domain "$DOMAIN" --accept-license "$LICENSE_CONSENT") if [ -n "$EMAIL" ]; then cmd+=(--email "$EMAIL" --https) fi as_root "${cmd[@]}" } # Install CLI only install_cli_only() { echo "" echo -e "${BOLD}CLI Tool Installation${NC}" echo "" # Prefer user directory, fall back to system mkdir -p "$HOME/.local/bin" echo -e "${BLUE}Installing binary to ${USER_BINARY}...${NC}" cp "$DOWNLOADED_BINARY" "$USER_BINARY" chmod +x "$USER_BINARY" if [[ ":$PATH:" != *":$HOME/.local/bin:"* ]]; then echo -e "${YELLOW}Note: Add ~/.local/bin to your PATH:${NC}" echo ' export PATH="$HOME/.local/bin:$PATH"' echo "" fi echo -e "${GREEN}Binary installed!${NC}" echo "" echo "Holm asks you to accept its license before first use." echo -e "Read it at ${BOLD}${LICENSE_URL}${NC}, then create a local instance:" echo "" echo " holm server init --username --password --domain holm.localhost \\" echo " --accept-license ${LICENSE_CONSENT}" echo "" echo "Run 'holm --help' to get started." } # Upgrade existing installation upgrade_existing() { local target_binary="$1" local service_name="$2" local is_user_service="$3" ensure_service_license "$is_user_service" || exit 1 echo -e "${YELLOW}Upgrading from v${CURRENT_VERSION} to v${NEW_VERSION}${NC}" echo "" # Stop service if running if [ "$is_user_service" = "true" ] && [ "$USER_RUNNING" = "true" ]; then echo -e "${BLUE}Stopping user service...${NC}" systemctl --user stop holm-local elif [ "$SYSTEM_RUNNING" = "true" ]; then echo -e "${BLUE}Stopping system service...${NC}" as_root systemctl stop holm fi # Backup and replace binary if [ -f "$target_binary" ]; then if [ "$is_user_service" = "true" ]; then cp "$target_binary" "${target_binary}.old" cp "$DOWNLOADED_BINARY" "$target_binary" chmod +x "$target_binary" else as_root cp "$target_binary" "${target_binary}.old" as_root cp "$DOWNLOADED_BINARY" "$target_binary" as_root chmod +x "$target_binary" fi fi echo -e "${GREEN}Binary updated${NC}" # Restart service if [ "$is_user_service" = "true" ] && [ "$USER_RUNNING" = "true" ]; then echo -e "${BLUE}Restarting user service...${NC}" systemctl --user start holm-local sleep 2 if systemctl --user is-active --quiet holm-local; then echo -e "${GREEN}Service restarted successfully${NC}" else echo -e "${RED}Service failed to start${NC}" fi elif [ "$SYSTEM_RUNNING" = "true" ]; then echo -e "${BLUE}Restarting system service...${NC}" as_root systemctl start holm sleep 2 if systemctl is-active --quiet holm; then echo -e "${GREEN}Service restarted successfully${NC}" else echo -e "${RED}Service failed to start${NC}" fi fi echo "" echo -e "${GREEN}Upgrade complete! v${CURRENT_VERSION} → v${NEW_VERSION}${NC}" } # Main flow check_existing get_pinned_release # Handle existing installation if [ "$SYSTEM_EXISTS" = "true" ] || [ "$USER_EXISTS" = "true" ]; then echo -e "${BLUE}Existing installation detected (v${CURRENT_VERSION})${NC}" if [ -n "$NONINTERACTIVE" ]; then echo "Holm is already installed. Nothing was changed; upgrade it with: holm upgrade" exit 0 fi if [ "$CURRENT_VERSION" = "$NEW_VERSION" ]; then echo -e "${GREEN}Already running latest version (v${NEW_VERSION})${NC}" echo "" echo -e "${BOLD}What would you like to do?${NC}" echo "1. Reinstall/reconfigure" echo "2. Exit" need_tty read -p "> Select [1/2]: " CHOICE < /dev/tty if [ "$CHOICE" != "1" ]; then echo "Nothing to do." exit 0 fi else echo -e "${YELLOW}New version available: v${NEW_VERSION}${NC}" echo "" echo -e "${BOLD}What would you like to do?${NC}" echo "1. Upgrade (keep configuration)" echo "2. Fresh install (reconfigure)" echo "3. Exit" need_tty read -p "> Select [1/2/3]: " CHOICE < /dev/tty if [ "$CHOICE" = "3" ]; then exit 0 fi if [ "$CHOICE" = "1" ]; then download_binary if [ "$USER_EXISTS" = "true" ]; then upgrade_existing "$USER_BINARY" "holm-local" "true" else upgrade_existing "$SYSTEM_BINARY" "holm" "false" fi exit 0 fi fi fi # Fresh installation download_binary if [ -n "$NONINTERACTIVE" ]; then case "$MODE_ARG" in server) install_production ;; local) install_local_dev ;; cli) install_cli_only ;; esac exit 0 fi echo "" echo -e "${BOLD}Select Installation Type:${NC}" echo "" echo -e "1. ${BOLD}Production Server${NC}" echo " For VPS/cloud. System service, real domain, HTTPS." echo "" echo -e "2. ${BOLD}Local Development${NC}" echo " For dev machines. User service, auto-start, IP-based." echo "" echo -e "3. ${BOLD}CLI Only${NC}" echo " Just the binary. Connect to remote servers." echo "" need_tty read -p "> Select [1/2/3]: " MODE < /dev/tty case "$MODE" in 1) install_production ;; 2) install_local_dev ;; 3) install_cli_only ;; *) echo "Invalid selection."; exit 1 ;; esac