#!/usr/bin/env bash # Metis AI macOS installer. Run as a file, not via `curl | bash`. # Prefer: /bin/bash -c "$(curl -fsSL https://github.com/f1shyondrugs/metis-ai/releases/latest/download/metis-install.sh)" set -Eeuo pipefail # LaunchAgents and other non-login environments can omit HOME. if [[ -z "${HOME:-}" ]]; then HOME="$(dscl . -read "/Users/$(id -un)" NFSHomeDirectory 2>/dev/null | awk '{print $2}' || true)" if [[ -z "$HOME" ]]; then HOME="$(eval echo "~$(id -un)" 2>/dev/null || true)" fi if [[ -z "$HOME" ]]; then if [[ "$(id -u)" -eq 0 ]]; then HOME="/var/root" else HOME="/tmp" fi fi export HOME fi REPO_URL="${METIS_AI_REPO_URL:-https://github.com/f1shyondrugs/metis-ai.git}" DEFAULT_DIR="${METIS_AI_INSTALL_DIR:-$HOME/metis-ai}" die() { printf 'Error: %s\n' "$*" >&2; exit 1; } confirm_install() { local name="$1" answer (( non_interactive )) && return 0 if [[ -t 0 ]]; then IFS= read -r -p "$name is missing or too old. Install/update it automatically? [Y/n] " answer else IFS= read -r -p "$name is missing or too old. Install/update it automatically? [Y/n] " answer < /dev/tty fi [[ -z "$answer" || "$answer" =~ ^([Yy][Ee][Ss]|[Yy])$ ]] } version_at_least_22() { command -v "$1" >/dev/null 2>&1 && [[ "$("$1" -p 'process.versions.node.split(".")[0]')" -ge 22 ]] } write_env_line() { local key="$1" value="$2" value="${value//\\/\\\\}" value="${value//\"/\\\"}" value="${value//\$/\\$}" printf '%s="%s"\n' "$key" "$value" } xml_escape() { local value="$1" value="${value//&/&}" value="${value///>}" value="${value//\"/"}" printf '%s' "$value" } json_str() { local value="$1" value="${value//\\/\\\\}" value="${value//\"/\\\"}" printf '"%s"' "$value" } wait_for_health() { local url="$1" max="${2:-30}" attempt body for attempt in $(seq 1 "$max"); do body="$(curl --fail --silent --max-time 2 "$url" 2>/dev/null || true)" if [[ "$url" == */api/status ]]; then if [[ "$body" == *'"authenticated":'* && "$body" == *'"worker":'* && "$body" == *'"mcp":'* ]]; then return 0; fi elif [[ "$body" == *'Universal MCP Gateway'* && "$body" == *'"endpoint":"/mcp"'* && "$body" == *'"ok":true'* ]]; then return 0 fi sleep 1 done return 1 } wait_for_frontend_assets() { local base_url="$1" html asset attempt for attempt in $(seq 1 30); do html="$(curl --fail --silent --max-time 2 "${base_url%/}/" 2>/dev/null || true)" asset="$(printf '%s' "$html" | grep -oE '/_next/static/[^[:space:]"<>]+\.js' | head -n 1 || true)" if [[ -n "$asset" ]] && curl --fail --silent --max-time 2 "${base_url%/}${asset}" >/dev/null 2>&1; then return 0 fi sleep 1 done return 1 } port_in_use() { local p="$1" if command -v lsof >/dev/null 2>&1; then lsof -nP -iTCP:"$p" -sTCP:LISTEN >/dev/null 2>&1 else (echo >/dev/tcp/127.0.0.1/"$p") >/dev/null 2>&1 fi } pick_free_port() { local p="$1" if ! port_in_use "$p"; then printf '%s' "$p"; return; fi local candidate for candidate in 8798 8799 8800 8801 8802 8788 8789; do if ! port_in_use "$candidate"; then printf '%s' "$candidate"; return; fi done printf '%s' "$p" } assert_available_ports() { local web="$1" gateway="$2" p owners pid command process_name owned_ports [[ "$web" != "$gateway" ]] || die "Web and MCP ports must be different." for p in "$web" "$gateway"; do [[ "$p" =~ ^[0-9]+$ && "$p" -ge 1 && "$p" -le 65535 ]] || die "Port must be a number between 1 and 65535: $p" if command -v docker >/dev/null 2>&1; then owned_ports="$(docker ps --filter "label=com.docker.compose.project.working_dir=$install_dir" --format '{{.Ports}}' 2>/dev/null || true)" if [[ "$owned_ports" == *":$p->"* ]]; then if (( ${force_native:-0} )); then die "This installation is running in Docker. Stop its Compose stack before migrating with --native."; fi continue fi fi if port_in_use "$p"; then owners="$(lsof -nP -iTCP:"$p" -sTCP:LISTEN -t 2>/dev/null || true)" [[ -n "$owners" ]] || die "Port $p is occupied. Choose a free --port / --mcp-port." for pid in $owners; do command="$(ps -p "$pid" -o command=)" if [[ "$command" != *"$install_dir/"* || ( "$command" != *server.mjs* && "$command" != *gateway-core.mjs* ) ]]; then process_name="$(ps -p "$pid" -o comm=)" die "Port $p is already occupied by PID $pid: $process_name. Choose a free --port / --mcp-port or close the conflicting application (for example OpenWebUI)." fi done fi done } usage() { cat <<'EOF' Usage: macos.sh Install Metis AI without account prompts macos.sh --non-interactive Install without prompts (default) macos.sh uninstall [options] Uninstall Metis AI (same flags as uninstall-macos.sh) Options: --install-dir DIR Application checkout (default: ~/metis-ai) --data-dir DIR Runtime data directory (default: INSTALL_DIR/data) --agent-cwd DIR Agent workspace (default: $HOME) --port PORT Web port (default: 3100) --host HOST Bind address (default: 127.0.0.1) --mcp-port PORT MCP gateway port (default: 8787) --service-name NAME launchd service prefix (default: metis-ai) --public-url URL URL shown to users --version TAG Checkout a release tag such as v1.0.0 after fetch --commit SHA Checkout a master commit SHA after fetch --native Node.js + launchd background host (default) --replace-existing Uninstall a detected existing install (keeps data), then continue --non-interactive Never read prompts; all values come from arguments/defaults --allow-root-agents Permit root agents (default: true) --deny-root-agents Disable root agents, including on an existing install --dry-run Collect configuration and print the plan, then exit -h, --help Show this help Security: use a dedicated unprivileged service user. For multiuser deployments, use separate OS users for agent isolation; application accounts alone do not separate operating-system permissions. Root agents are enabled by default for Host-Admins; use --deny-root-agents to disable them. EOF } non_interactive=0 dry_run=0 allow_root_agents=true root_agents_set=0 install_dir="$DEFAULT_DIR" data_dir="" data_dir_set=0 agent_cwd="$HOME" port="3100" ai_chat_host="" mcp_port="8787" service_name="metis-ai" public_url="" force_native=1 replace_existing=0 REPLACE_DATA_STASH="" release_version="" commit_sha="" if [[ "${1:-}" == "uninstall" ]]; then shift uninstall_dir="" uninstall_svc="$service_name" args=("$@") i=0 uninstall_help=0 while [[ $i -lt ${#args[@]} ]]; do case "${args[$i]}" in --install-dir) uninstall_dir="${args[$((i+1))]:-}" i=$((i+2)) ;; --service-name) uninstall_svc="${args[$((i+1))]:-$uninstall_svc}" i=$((i+2)) ;; -h|--help) uninstall_help=1 i=$((i+1)) ;; *) i=$((i+1)) ;; esac done if (( uninstall_help == 0 )); then if [[ -z "$uninstall_dir" ]]; then if [[ ! -f "$HOME/Library/LaunchAgents/${uninstall_svc}-app.plist" ]]; then die "Metis AI is not installed as ${uninstall_svc}-app. Nothing to uninstall." fi elif [[ ! -e "$uninstall_dir/.metis-ai-install.json" && ! -e "$uninstall_dir/.env" && ! -e "$uninstall_dir/server.mjs" ]]; then die "No Metis AI install found at $uninstall_dir. Nothing to uninstall." fi fi self_dir="$(cd "$(dirname "$0")" && pwd)" uninstall_script="" if [[ -f "$self_dir/uninstall-macos.sh" ]]; then uninstall_script="$self_dir/uninstall-macos.sh" elif [[ -f "$self_dir/install/uninstall-macos.sh" ]]; then uninstall_script="$self_dir/install/uninstall-macos.sh" else base="${METIS_AI_INSTALL_BASE:-https://raw.githubusercontent.com/f1shyondrugs/metis-ai/master}" base="${base%/}" uninstall_script="$(mktemp "${TMPDIR:-/tmp}/metis-ai-uninstall.XXXXXX")" curl -fsSL "$base/install/uninstall-macos.sh" -o "$uninstall_script" || die "failed to download the Metis AI uninstaller." chmod u+x "$uninstall_script" fi exec /bin/bash "$uninstall_script" "$@" fi while [[ $# -gt 0 ]]; do case "$1" in --install-dir) [[ $# -ge 2 ]] || die "--install-dir requires a value"; install_dir="$2"; shift 2 ;; --data-dir) [[ $# -ge 2 ]] || die "--data-dir requires a value"; data_dir="$2"; data_dir_set=1; shift 2 ;; --agent-cwd) [[ $# -ge 2 ]] || die "--agent-cwd requires a value"; agent_cwd="$2"; shift 2 ;; --port) [[ $# -ge 2 ]] || die "--port requires a value"; port="$2"; shift 2 ;; --host) [[ $# -ge 2 ]] || die "--host requires a value"; ai_chat_host="$2"; shift 2 ;; --mcp-port) [[ $# -ge 2 ]] || die "--mcp-port requires a value"; mcp_port="$2"; shift 2 ;; --service-name) [[ $# -ge 2 ]] || die "--service-name requires a value"; service_name="$2"; shift 2 ;; --public-url) [[ $# -ge 2 ]] || die "--public-url requires a value"; public_url="$2"; shift 2 ;; --version) [[ $# -ge 2 ]] || die "--version requires a value"; release_version="$2"; shift 2 ;; --commit) [[ $# -ge 2 ]] || die "--commit requires a value"; commit_sha="$2"; shift 2 ;; --native) force_native=1; shift ;; --docker) die "Docker installation is no longer available. Use the native installer." ;; --replace-existing) replace_existing=1; shift ;; --non-interactive) non_interactive=1; shift ;; --allow-root-agents) allow_root_agents=true; root_agents_set=1; shift ;; --deny-root-agents) allow_root_agents=false; root_agents_set=1; shift ;; --dry-run) dry_run=1; shift ;; -h|--help) usage; exit 0 ;; *) die "Unknown option: $1 (use --help for usage)" ;; esac done read_tty_line() { local prompt="$1" if [[ -t 0 ]]; then IFS= read -r -p "$prompt" REPLY elif [[ -r /dev/tty ]] && { : 2>/dev/null < /dev/tty; }; then IFS= read -r -p "$prompt" REPLY < /dev/tty else REPLY="" fi } if (( non_interactive == 0 )); then read_tty_line "Installation directory [$install_dir]: " if [[ -n "$REPLY" ]]; then install_dir="$REPLY" fi fi install_dir="${install_dir/#\~/$HOME}" if [[ -f "$install_dir/.metis-ai-install.json" ]] && grep -Eq '"installMethod"[[:space:]]*:[[:space:]]*"docker"' "$install_dir/.metis-ai-install.json"; then die "This directory contains a legacy Docker installation. Choose a separate directory for native installation; migrate data separately." fi agent_cwd="${agent_cwd/#\~/$HOME}" data_dir="${data_dir/#\~/$HOME}" configure_root_agents() { local previous="" (( root_agents_set )) && return 0 if [[ -f "$install_dir/.env" ]]; then previous="$(awk -F= '/^AI_CHAT_ALLOW_ROOT_AGENTS=/{sub(/^[^=]*=/, ""); gsub(/^"|"$/, ""); print; exit}' "$install_dir/.env")" case "$previous" in true|1|yes) allow_root_agents=true ;; false|0|no) allow_root_agents=false ;; esac fi } configure_root_agents default_public_host() { local host host="$(ipconfig getifaddr en0 2>/dev/null || ipconfig getifaddr en1 2>/dev/null || true)" printf '%s' "${host:-127.0.0.1}" } ai_chat_host="${ai_chat_host:-127.0.0.1}" data_dir="${data_dir:-$install_dir/data}" data_dir="${data_dir:-$install_dir/data}" if [[ "$ai_chat_host" == "0.0.0.0" ]]; then public_host="$(default_public_host)" else public_host="127.0.0.1" fi public_url="${public_url:-http://${public_host}:${port}}" [[ "$port" =~ ^[0-9]+$ && "$port" -ge 1 && "$port" -le 65535 ]] || die "Web port must be a number between 1 and 65535." [[ "$mcp_port" =~ ^[0-9]+$ && "$mcp_port" -ge 1 && "$mcp_port" -le 65535 ]] || die "MCP port must be a number between 1 and 65535." [[ "$port" != "$mcp_port" ]] || die "Web and MCP ports must be different." [[ "$service_name" =~ ^[A-Za-z0-9][A-Za-z0-9_-]*$ ]] || die "Service name may contain letters, numbers, underscores and hyphens." if [[ -n "$commit_sha" && -n "$release_version" ]]; then die "Use either --version or --commit, not both." fi if [[ -n "$commit_sha" ]]; then [[ "$commit_sha" =~ ^[0-9a-fA-F]{7,40}$ ]] || die "Commit must be a git SHA." else if [[ -z "$release_version" || "$release_version" == "latest" ]]; then command -v curl >/dev/null 2>&1 || die "curl is required to resolve the latest stable Metis AI release." release_api="$(curl -fsSL -H 'Accept: application/vnd.github+json' 'https://api.github.com/repos/f1shyondrugs/metis-ai/releases/latest')" || die "Could not resolve the latest stable Metis AI release." release_version="$(printf '%s' "$release_api" | sed -n 's/.*"tag_name"[[:space:]]*:[[:space:]]*"\(v[0-9][0-9A-Za-z.-]*\)".*/\1/p' | head -n 1)" fi [[ "$release_version" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]] || die "The selected stable release tag is invalid or unavailable: ${release_version:-empty}." fi # Validate releases before changing an existing installation; Git need not be installed yet. if [[ "$REPO_URL" == https://github.com/* ]]; then repo_slug="${REPO_URL#https://github.com/}"; repo_slug="${repo_slug%.git}" command -v curl >/dev/null 2>&1 || die "curl is required to validate the selected release." if [[ -n "$commit_sha" ]]; then commit_json="$(curl -fsSL -H 'Accept: application/vnd.github+json' "https://api.github.com/repos/$repo_slug/commits/$commit_sha")" || die "The selected commit is unavailable: $commit_sha" commit_sha="$(printf '%s' "$commit_json" | sed -n 's/^[[:space:]]*"sha"[[:space:]]*:[[:space:]]*"\([0-9a-f]\{40\}\)".*/\1/p' | head -n 1)" [[ "$commit_sha" =~ ^[0-9a-f]{40}$ ]] || die "GitHub returned no valid commit." else selected_release="${release_api:-}" if [[ -z "$selected_release" ]]; then selected_release="$(curl -fsSL -H 'Accept: application/vnd.github+json' "https://api.github.com/repos/$repo_slug/releases/tags/$release_version")" || die "The selected published release is unavailable: $release_version" fi printf '%s' "$selected_release" | grep -q '"name"[[:space:]]*:[[:space:]]*"SHA256SUMS"' || die "The selected release has no installer checksum asset." fi else # Private/local Git mirrors are an explicit developer transport override. command -v git >/dev/null 2>&1 || die "git is required for a custom repository." if [[ -n "$commit_sha" ]]; then ref_check_dir="$(mktemp -d "${TMPDIR:-/tmp}/metis-ref-check.XXXXXX")" git -C "$ref_check_dir" init -q if ! git -C "$ref_check_dir" fetch --depth=1 "$REPO_URL" "$commit_sha" >/dev/null 2>&1; then rm -rf "$ref_check_dir"; die "The selected commit is unavailable: $commit_sha" fi commit_sha="$(git -C "$ref_check_dir" rev-parse FETCH_HEAD)" rm -rf "$ref_check_dir" else git ls-remote --exit-code --tags "$REPO_URL" "refs/tags/$release_version" >/dev/null 2>&1 || die "The selected release tag is unavailable: $release_version" fi fi export METIS_AI_INSTALL_BASE="${METIS_AI_INSTALL_BASE:-https://raw.githubusercontent.com/f1shyondrugs/metis-ai/${commit_sha:-$release_version}}" existing_service_state="" existing_service_dir="" existing_plist="$HOME/Library/LaunchAgents/${service_name}-app.plist" if [[ -f "$existing_plist" ]]; then existing_service_state="launch-agent" existing_service_dir="$(sed -n '/WorkingDirectory<\/key>/{n;s/.*\(.*\)<\/string>.*/\1/p;}' "$existing_plist")" fi if (( dry_run )); then cat </dev/null 2>&1; then value="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1])).get("dataDir",""))' "$dir/.metis-ai-install.json" 2>/dev/null || true)" fi if [[ -z "$value" && -d "$dir/data" ]]; then value="$dir/data" fi printf '%s' "$value" } path_is_inside() { local inner outer inner="$(abspath "$1")" outer="$(abspath "$2")" [[ "$inner" == "$outer" || "$inner" == "$outer"/* ]] } stop_macos_units() { local name="$1" suffix for suffix in app worker mcp; do launchctl bootout "gui/$(id -u)" "$HOME/Library/LaunchAgents/${name}-${suffix}.plist" >/dev/null 2>&1 || true rm -f "$HOME/Library/LaunchAgents/${name}-${suffix}.plist" done } stash_nested_data() { local dir="$1" data="$2" REPLACE_DATA_STASH="" [[ -n "$data" && -e "$data" ]] || return 0 if path_is_inside "$data" "$dir"; then REPLACE_DATA_STASH="$(dirname "$(abspath "$dir")")/.$(basename "$dir").metis-keep-data" rm -rf -- "$REPLACE_DATA_STASH" mv -- "$data" "$REPLACE_DATA_STASH" printf 'Kept nested data at %s\n' "$REPLACE_DATA_STASH" fi } restore_stashed_data() { local dest="$1" [[ -n "${REPLACE_DATA_STASH:-}" && -e "$REPLACE_DATA_STASH" ]] || return 0 mkdir -p "$(dirname "$dest")" if [[ -e "$dest" ]]; then rm -rf -- "$dest" fi mv -- "$REPLACE_DATA_STASH" "$dest" REPLACE_DATA_STASH="" printf 'Restored kept data to %s\n' "$dest" } REPLACE_ENV_STASH="" env_stash_path() { local dir="$1" resolved resolved="$(abspath "$dir")" printf '%s/.%s.metis-keep-env' "$(dirname "$resolved")" "$(basename "$resolved")" } preserve_existing_env() { local dir="$1" src="$dir/.env" [[ -f "$src" ]] || return 0 REPLACE_ENV_STASH="$(env_stash_path "$dir")" cp -a -- "$src" "$REPLACE_ENV_STASH" chmod 600 "$REPLACE_ENV_STASH" printf 'Kept previous .env at %s\n' "$REPLACE_ENV_STASH" } adopt_env_stash() { local dir="$1" candidate [[ -n "${REPLACE_ENV_STASH:-}" && -f "$REPLACE_ENV_STASH" ]] && return 0 candidate="$(env_stash_path "$dir")" if [[ -f "$candidate" ]]; then REPLACE_ENV_STASH="$candidate" return 0 fi preserve_existing_env "$dir" } merge_preserved_env() { local dest="$1" preserved="${REPLACE_ENV_STASH:-}" tmp [[ -n "$preserved" && -f "$preserved" && -f "$dest" ]] || return 0 tmp="$dest.tmp" # METIS_ENV_MERGE_BEGIN awk -v preserved="$preserved" -v root_agents_set="${root_agents_set:-0}" ' function key_of(line, k) { if (line ~ /^[ \t]*#/ || line ~ /^[ \t]*$/) return "" k = line sub(/\r$/, "", k) if (index(k, "=") == 0) return "" sub(/=.*/, "", k) return k } function is_structural(k) { if (k == "AI_CHAT_ALLOW_ROOT_AGENTS" && root_agents_set == 1) return 1 return (k == "AI_CHAT_ROOT" || k == "AI_CHAT_INSTALL_DIR" || k == "METIS_NODE_BIN" || k == "METIS_NODE_HOME" || k == "METIS_PNPM_HOME" || k == "CHAT_DATA_DIR" || k == "METIS_DATA_DIR" || k == "AGENT_CWD" || k == "METIS_WORKSPACE" || k == "AI_CHAT_MCP_STATE_DIR" || k == "METIS_DOCKER" || k == "AI_CHAT_SERVICE_NAME") } BEGIN { while ((getline line < preserved) > 0) { k = key_of(line) if (k != "") { old[k] = line if (!(k in seen_old)) { seen_old[k] = 1 old_order[++n] = k } } } close(preserved) } { k = key_of($0) if (k != "" && !is_structural(k) && (k in old)) { print old[k] used[k] = 1 } else { print $0 if (k != "") used[k] = 1 } } END { for (i = 1; i <= n; i++) { k = old_order[i] if (!(k in used) && !is_structural(k)) print old[k] } } ' "$dest" > "$tmp" # METIS_ENV_MERGE_END mv "$tmp" "$dest" chmod 600 "$dest" rm -f -- "$preserved" REPLACE_ENV_STASH="" printf 'Merged previous .env into %s (old values kept, new keys added).\n' "$dest" } read_env_key() { local file="$1" key="$2" awk -v key="$key" ' index($0, key "=") == 1 { val = substr($0, length(key) + 2) gsub(/\r/, "", val) gsub(/^"/, "", val) gsub(/"$/, "", val) print val exit } ' "$file" } upsert_env_key() { local dest="$1" key="$2" value="$3" tmp line line="$(write_env_line "$key" "$value" | tr -d '\n')" tmp="$dest.tmp" awk -v key="$key" -v line="$line" ' BEGIN { replaced = 0 } $0 ~ "^" key "=" { print line; replaced = 1; next } { print } END { if (!replaced) print line } ' "$dest" > "$tmp" mv "$tmp" "$dest" chmod 600 "$dest" } apply_merged_runtime_ports() { local dest="$1" value next [[ -f "$dest" ]] || return 0 value="$(read_env_key "$dest" PORT)" if [[ "$value" =~ ^[0-9]+$ ]]; then port="$value" fi value="$(read_env_key "$dest" MCP_PORT)" if [[ "$value" =~ ^[0-9]+$ ]]; then mcp_port="$value" fi assert_available_ports "$port" "$mcp_port" public_url="$(read_env_key "$dest" AI_CHAT_PUBLIC_URL)" } uninstall_detected_install() { local dir="$1" data="" [[ -n "$dir" && "$dir" != "/" && "$dir" != "$HOME" ]] || die "Refusing to uninstall an unsafe install directory: ${dir:-unknown}" printf 'Uninstalling existing Metis AI at %s (data kept).\n' "$dir" data="$(read_existing_data_dir "$dir")" stop_macos_units "$service_name" preserve_existing_env "$dir" stash_nested_data "$dir" "$data" rm -rf -- "$dir" } if [[ -n "$existing_service_state" ]]; then same=0 [[ -n "$existing_service_dir" && "$existing_service_dir" == "$install_dir" ]] && same=1 choice="" if (( replace_existing )); then choice=r elif (( non_interactive )); then if (( same )); then choice=u; fi else printf 'Existing Metis AI detected.\n' printf ' service: %s-app (%s)\n' "$service_name" "$existing_service_state" printf ' directory: %s\n' "${existing_service_dir:-unknown}" printf '[u] Upgrade that install\n[r] Replace it (uninstall, keep data, then continue)\n[n] Uninstall and exit (keeps data)\n[a] Abort\n' read_tty_line "Choice [u/r/n/a]: " choice="$REPLY" fi case "$choice" in r|R) install_dir="${existing_service_dir:-$install_dir}" if (( data_dir_set == 0 )); then data_dir="$install_dir/data" fi uninstall_detected_install "$install_dir" existing_service_state="" existing_service_dir="" ;; u|U) if [[ -n "$existing_service_dir" ]]; then install_dir="$existing_service_dir" fi printf 'Existing Metis AI install detected: %s-app is registered in %s. Upgrading in place.\n' \ "$service_name" "$install_dir" ;; a|A) printf 'Aborted.\n' exit 0 ;; n|N) install_dir="${existing_service_dir:-$install_dir}" [[ -n "$install_dir" && "$install_dir" != "/" && "$install_dir" != "$HOME" ]] || die "Could not resolve the install directory to uninstall." if (( dry_run )); then printf 'Would uninstall Metis AI at %s (data kept) and exit.\n' "$install_dir" exit 0 fi exec /bin/bash "$0" uninstall --install-dir "$install_dir" --service-name "$service_name" --yes --keep-data ;; *) die "Metis AI is already installed as ${service_name}-app in ${existing_service_dir:-an unknown directory}. Re-run and choose upgrade/replace/uninstall, or pass --replace-existing." ;; esac fi # Read existing ports before dependency installation, then validate the actual listeners. if [[ -f "$install_dir/.env" ]]; then saved_port="$(read_env_key "$install_dir/.env" PORT)" saved_mcp_port="$(read_env_key "$install_dir/.env" MCP_PORT)" [[ -z "$saved_port" ]] || port="$saved_port" [[ -z "$saved_mcp_port" ]] || mcp_port="$saved_mcp_port" fi assert_available_ports "$port" "$mcp_port" [[ -n "$public_url" ]] || public_url="http://${public_host}:$port" install_homebrew() { command -v brew >/dev/null 2>&1 && return 0 confirm_install "Homebrew" || die "Homebrew is required." command -v curl >/dev/null 2>&1 || die "curl is required to install Homebrew automatically." NONINTERACTIVE=1 /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" if [[ -x /opt/homebrew/bin/brew ]]; then eval "$(/opt/homebrew/bin/brew shellenv)" elif [[ -x /usr/local/bin/brew ]]; then eval "$(/usr/local/bin/brew shellenv)" fi command -v brew >/dev/null 2>&1 || die "Homebrew installation completed but brew is not available." } install_homebrew # A LaunchAgent does not load the owner's shell profile. Recover paths saved # by the previous native installation before checking tools for an update. if [[ -f "$install_dir/.env" ]]; then saved_node_bin="$(read_env_key "$install_dir/.env" METIS_NODE_BIN)" saved_pnpm_home="$(read_env_key "$install_dir/.env" METIS_PNPM_HOME)" if [[ -n "$saved_node_bin" && -x "$saved_node_bin" ]] && version_at_least_22 "$saved_node_bin"; then export PATH="$(dirname "$saved_node_bin"):$PATH" fi if [[ -n "$saved_pnpm_home" && -x "$saved_pnpm_home/pnpm" ]]; then export PATH="$saved_pnpm_home:$PATH" fi fi command -v git >/dev/null 2>&1 || { confirm_install "git" && brew install git || die "git is required."; } if [[ -e "$install_dir/.git" ]]; then git -C "$install_dir" fetch --force origin git -C "$install_dir" fetch --tags --force elif [[ -e "$install_dir" && -n "$(ls -A "$install_dir" 2>/dev/null)" ]]; then die "Installation directory exists and is not an existing Metis AI checkout." else mkdir -p "$(dirname "$install_dir")" git clone "$REPO_URL" "$install_dir" fi if [[ -n "$commit_sha" ]]; then update_ref="$commit_sha" git -C "$install_dir" fetch origin "$commit_sha" git -C "$install_dir" checkout --force --detach "$update_ref" else update_ref="$release_version" git -C "$install_dir" checkout --force "$update_ref" fi # Installer updates replace tracked local edits and divergent commits with the # selected upstream ref. Ignored install state (including .env and data) stays. git -C "$install_dir" reset --hard "$update_ref" restore_stashed_data "$data_dir" { if ! version_at_least_22 node; then confirm_install "Node.js 22 or newer" || die "Node.js 22 or newer is required." if command -v node >/dev/null 2>&1; then brew upgrade node || true; else brew install node; fi fi version_at_least_22 node || die "Node.js 22 or newer is required after installation." command -v pnpm >/dev/null 2>&1 || { confirm_install "pnpm" && brew install pnpm || die "pnpm is required."; } } node_bin="$(command -v node || true)" node_home="" pnpm_home="" if [[ -n "$node_bin" ]]; then node_home="$(dirname "$(dirname "$node_bin")")" fi pnpm_home="$(dirname "$(command -v pnpm)")" secrets_key="$(openssl rand -hex 32)" mcp_token="$(openssl rand -hex 32)" mkdir -p "$data_dir" "$agent_cwd" adopt_env_stash "$install_dir" { write_env_line APP_NAME "Metis AI" write_env_line PORT "$port" write_env_line AI_CHAT_HOST "$ai_chat_host" write_env_line CHAT_DATA_DIR "$data_dir" write_env_line AGENT_CWD "$agent_cwd" write_env_line AI_CHAT_ROOT "$install_dir" write_env_line AI_CHAT_INSTALL_DIR "$install_dir" write_env_line AI_CHAT_PUBLIC_URL "$public_url" write_env_line AI_CHAT_SERVICE_NAME "$service_name" write_env_line AI_CHAT_WORKER_CONCURRENCY "25" write_env_line AI_CHAT_MCP_STATE_DIR "$data_dir/mcp-state" write_env_line AI_CHAT_SECRETS_KEY "$secrets_key" write_env_line MCP_PORT "$mcp_port" write_env_line MCP_PUBLIC_URL "http://127.0.0.1:$mcp_port" write_env_line AI_CHAT_INTERNAL_URL "http://127.0.0.1:$port/api/internal/mcp-question" write_env_line MCP_BEARER_TOKEN "$mcp_token" printf 'MCP_ALLOW_REMOTE_ADMIN=false\n' printf 'MCP_ENABLE_REMOTE_SERVERS=false\n' printf 'MCP_ENABLE_OPTIONAL_SERVERS=false\n' write_env_line METIS_WORKSPACE "$agent_cwd" write_env_line METIS_DATA_DIR "$data_dir" write_env_line AI_CHAT_BIND "$ai_chat_host" write_env_line AI_CHAT_ALLOW_ROOT_AGENTS "$allow_root_agents" write_env_line METIS_NODE_BIN "$node_bin" write_env_line METIS_NODE_HOME "$node_home" write_env_line METIS_PNPM_HOME "$pnpm_home" } > "$install_dir/.env" chmod 600 "$install_dir/.env" merge_preserved_env "$install_dir/.env" upsert_env_key "$install_dir/.env" NODE_ENV "production" apply_merged_runtime_ports "$install_dir/.env" cat > "$install_dir/run-service.sh" <<'EOF' #!/bin/bash set -euo pipefail ROOT="$(cd "$(dirname "$0")" && pwd)" set -a # shellcheck disable=SC1091 . "$ROOT/.env" set +a export PATH="${METIS_PNPM_HOME:+$METIS_PNPM_HOME:}${METIS_NODE_HOME:+$METIS_NODE_HOME/bin:}$ROOT/node_modules/.bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin${PATH:+:$PATH}" cd "$ROOT" exec "${METIS_NODE_BIN:?METIS_NODE_BIN is missing from .env}" "$@" EOF chmod 700 "$install_dir/run-service.sh" ( unset NODE_ENV set -a # shellcheck disable=SC1091 . "$install_dir/.env" set +a export PATH="${METIS_PNPM_HOME:+$METIS_PNPM_HOME:}${METIS_NODE_HOME:+$METIS_NODE_HOME/bin:}$PATH" cd "$install_dir" "$METIS_PNPM_HOME/pnpm" install --frozen-lockfile node scripts/sync-provider-clis.mjs pnpm exec playwright install chromium current_build_slot="${NEXT_DIST_DIR:-}" if [[ "$current_build_slot" == ".next-a" ]]; then next_build_slot=".next-b" else next_build_slot=".next-a" fi bash scripts/build-production-slot.sh "$next_build_slot" upsert_env_key "$install_dir/.env" NEXT_DIST_DIR "$next_build_slot" ) launch_dir="$HOME/Library/LaunchAgents" mkdir -p "$launch_dir" write_plist() { local suffix label suffix="$1" shift label="${service_name}-${suffix}" { printf '%s\n' '' printf '%s\n' '' printf '%s\n' '' printf ' Label%s\n' "$(xml_escape "$label")" printf ' ProgramArguments\n' printf ' %s\n' "$(xml_escape "$install_dir/run-service.sh")" local arg for arg in "$@"; do printf ' %s\n' "$(xml_escape "$arg")" done printf ' \n' printf ' WorkingDirectory%s\n' "$(xml_escape "$install_dir")" printf ' EnvironmentVariables\n' printf ' HOME%s\n' "$(xml_escape "$HOME")" printf ' PATH%s\n' "$(xml_escape "$node_home/bin:$install_dir/node_modules/.bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin")" printf ' \n' printf ' RunAtLoad\n' printf ' KeepAlive\n' printf ' StandardOutPath%s\n' "$(xml_escape "$data_dir/$label.log")" printf ' StandardErrorPath%s\n' "$(xml_escape "$data_dir/$label.error.log")" printf '%s\n' '' } > "$launch_dir/$label.plist" launchctl bootout "gui/$(id -u)" "$launch_dir/$label.plist" >/dev/null 2>&1 || true launchctl bootstrap "gui/$(id -u)" "$launch_dir/$label.plist" } write_plist app "$install_dir/node_modules/tsx/dist/cli.mjs" "$install_dir/server.mjs" write_plist worker "$install_dir/node_modules/tsx/dist/cli.mjs" "$install_dir/worker.ts" write_plist mcp "$install_dir/lib/mcp-core/gateway-core.mjs" # Finder/Spotlight application entry. The application hosts no web view. app_bundle="$HOME/Applications/Metis AI ($service_name).app" mkdir -p "$app_bundle/Contents/MacOS" { printf '%s\n' '' '' printf 'CFBundleNameMetis AI\n' printf 'CFBundleIdentifierai.metis.host.%s\n' "$(xml_escape "$service_name")" printf 'CFBundleExecutableMetisHost\n' printf 'CFBundlePackageTypeAPPL\n' printf 'LSUIElement\n' printf '%s\n' '' } > "$app_bundle/Contents/Info.plist" { printf '#!/bin/bash\nset -euo pipefail\n' printf 'ROOT=%q\nSERVICE=%q\n' "$install_dir" "$service_name" cat <<'APP' set -a . "$ROOT/.env" set +a exec >>"$CHAT_DATA_DIR/host.log" 2>&1 for suffix in app worker mcp; do label="$SERVICE-$suffix" if ! launchctl print "gui/$(id -u)/$label" >/dev/null 2>&1; then launchctl bootstrap "gui/$(id -u)" "$HOME/Library/LaunchAgents/$label.plist" fi launchctl kickstart "gui/$(id -u)/$label" done url="http://127.0.0.1:$PORT" for attempt in $(seq 1 60); do body="$(curl --fail --silent --max-time 2 "$url/api/status" || true)" if [[ "$body" == *'"authenticated":'* && "$body" == *'"worker":'* && "$body" == *'"mcp":'* ]]; then open "$url" exit 0 fi sleep 1 done osascript -e 'display alert "Metis AI could not start" message "Check the Metis service logs for startup errors or an occupied web/MCP port." as critical' exit 1 APP } > "$app_bundle/Contents/MacOS/MetisHost" chmod 700 "$app_bundle/Contents/MacOS/MetisHost" if [[ -x /System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister ]]; then /System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister -f "$app_bundle" fi health_tries=30 wait_for_health "http://127.0.0.1:$port/api/status" "$health_tries" || die "Metis did not become healthy on web port $port. Check $data_dir/${service_name}-app.error.log for startup errors and port conflicts." wait_for_frontend_assets "http://127.0.0.1:$port" || die "The application started, but its browser assets are not available. Check launchctl or the service logs." wait_for_health "http://127.0.0.1:$mcp_port/health" "$health_tries" || die "Metis MCP gateway did not become healthy on port $mcp_port. Check $data_dir/${service_name}-mcp.error.log for startup errors and port conflicts." install_method="native" { printf '{' printf '"installDir":%s,' "$(json_str "$install_dir")" printf '"dataDir":%s,' "$(json_str "$data_dir")" printf '"agentCwd":%s,' "$(json_str "$agent_cwd")" printf '"serviceName":%s,' "$(json_str "$service_name")" printf '"host":%s,' "$(json_str "$ai_chat_host")" printf '"os":"macos",' printf '"installMethod":%s,' "$(json_str "$install_method")" printf '"createdAt":%s' "$(json_str "$(date -u +%Y-%m-%dT%H:%M:%SZ)")" printf '}\n' } > "$install_dir/.metis-ai-install.json" chmod 600 "$install_dir/.metis-ai-install.json" cp "$install_dir/install/uninstall-macos.sh" "$install_dir/uninstall-macos.sh" chmod 700 "$install_dir/uninstall-macos.sh" if [[ "$ai_chat_host" == "0.0.0.0" ]]; then printf 'Warning: the web application is reachable on the local network. Use strong credentials and a firewall or trusted TLS reverse proxy.\n' fi printf '\nMetis AI installed successfully.\nOpen: %s\nYou can change this. Add: %s\n' \ "$public_url" "$install_dir/.env" printf 'Security: prefer a dedicated unprivileged service user and separate OS users for multiuser agent isolation.\n' printf 'First setup requires the setup token from the local file: %s\nRead it locally; do not publish it or copy it into logs.\n' "$data_dir/setup-token" printf 'Application: %s (opens Metis in your browser)\n' "$app_bundle" printf 'Uninstall: %s --install-dir %q --keep-data\n' "$install_dir/uninstall-macos.sh" "$install_dir"