#!/usr/bin/env bash # ============================================================================================= # Harness Maxxing - one-line installer (Ubuntu / Debian with systemd, x86_64 or arm64, as root) # # Install with a licence key (it is asked, or LICENSE=HM-XXXX-XXXX-XXXX-XXXX): # curl -fsSL https://get.harness-maxxing.com | bash # Development install from the git repository instead (read token): # GH_TOKEN=$T bash install.sh # Options (pass them after `bash -s --` when piping): # --update update an existing install (= maxxing update) # --uninstall remove service + integrations (= maxxing uninstall), keeps the workspace # --purge ... also remove the code and the state directory # --purge-workspace ... also remove the workspace (irreversible) # -h, --help # # Unattended: answer with variables and ASSUME_YES=1, e.g. # curl -fsSL | ACCESS=domain DOMAIN=ui.example.com ASSUME_YES=1 bash # Variables (default): # LICENSE (licence key) LICENSE_API (https://api.harness-maxxing.com) # REPO (rorogeno/claude-maxxing-v2) BRANCH (main) GH_TOKEN (git install) INSTALL_DIR (/opt/claude-maxxing) # WORKSPACE (/root/maxxing) STATE_DIR (/var/lib/claude-maxxing) PORT (8787) # ACCESS (domain|tailscale|local) DOMAIN ASSUME_YES (0) # TIMEZONE (system zone - also chosen later in the web onboarding, which switches the system clock) # RESTORE_FROM (owner/name of a memory backup repo) RESTORE_TOKEN # CLAUDE_TOKEN (output of `claude setup-token`, optional) ANTHROPIC_API_KEY (optional) # VOICE (local - default: Whisper on this server, model downloaded in the background | groq | openai | off) # # Re-runnable: every step keeps what exists. The server never listens on a public interface. # ============================================================================================= set -euo pipefail REPO="${REPO:-rorogeno/claude-maxxing-v2}" BRANCH="${BRANCH:-main}" GH_TOKEN="${GH_TOKEN:-}" LICENSE="${LICENSE:-}" LICENSE_API="${LICENSE_API:-https://api.harness-maxxing.com}" LICENSED=0 # 1 = code comes from a signed release (licence), not from git RELEASE_JSON="" # public key that signs the releases (message "harness-maxxing||") RELEASE_PUBKEY='-----BEGIN PUBLIC KEY----- MCowBQYDK2VwAyEAfg3vG94nEzS93VB0zGtE2RH+1VF317mLumbHgMMkOPY= -----END PUBLIC KEY-----' INSTALL_DIR="${INSTALL_DIR:-/opt/claude-maxxing}" WORKSPACE="${WORKSPACE:-/root/maxxing}" STATE_DIR="${STATE_DIR:-/var/lib/claude-maxxing}" PORT="${PORT:-8787}" ACCESS="${ACCESS:-}" DOMAIN="${DOMAIN:-}" TIMEZONE="${TIMEZONE:-}" ASSUME_YES="${ASSUME_YES:-0}" RESTORE_FROM="${RESTORE_FROM:-}" RESTORE_TOKEN="${RESTORE_TOKEN:-}" CLAUDE_TOKEN="${CLAUDE_TOKEN:-}" ANTHROPIC_API_KEY="${ANTHROPIC_API_KEY:-}" VOICE="${VOICE:-local}" FIREWALL="${FIREWALL:-1}" SERVICE=claude-maxxing UNIT=/etc/systemd/system/$SERVICE.service CADDY_SITE=/etc/caddy/claude-maxxing.caddy CADDYFILE=/etc/caddy/Caddyfile BIN=/usr/local/bin/maxxing DEFAULT_STATE=/var/lib/claude-maxxing NODE_MAJOR=22 TOTAL=11 STEP=0 IN_STEP=0 SPIN_PID="" STEP_LOG="" T_START=$SECONDS BIND=127.0.0.1 PUBLIC_URL="" AFTER="" TOKEN_USED=0 # ---- output --------------------------------------------------------------------------------- FANCY=0 if [ -t 1 ] && [ -z "${NO_COLOR:-}" ] && [ "${TERM:-dumb}" != dumb ]; then FANCY=1 C_B=$'\033[1m'; C_D=$'\033[2m'; C_G=$'\033[38;5;42m'; C_Y=$'\033[38;5;214m'; C_R=$'\033[38;5;203m' C_C=$'\033[38;5;75m'; C_A=$'\033[38;5;33m'; C_W=$'\033[97m'; C_0=$'\033[0m' BLUES=(27 33 39 45 51 87) else C_B=""; C_D=""; C_G=""; C_Y=""; C_R=""; C_C=""; C_A=""; C_W=""; C_0="" BLUES=() fi ok() { printf ' %s✔%s %s\n' "$C_G" "$C_0" "$*"; } warn() { printf ' %s!%s %s\n' "$C_Y" "$C_0" "$*"; } info() { printf ' %s%s%s\n' "$C_D" "$*" "$C_0"; } die() { printf ' %s✘ %s%s\n' "$C_R" "$*" "$C_0" >&2; exit 1; } section() { printf '\n %s◆%s %s%s%s\n' "$C_A" "$C_0" "$C_B" "$*" "$C_0"; } fmt_secs() { local s=$1; if [ "$s" -ge 60 ]; then printf '%d min %02d s' $((s / 60)) $((s % 60)); else printf '%d s' "$s"; fi; } logo() { local lines=( '███╗ ███╗ █████╗ ██╗ ██╗██╗ ██╗██╗███╗ ██╗ ██████╗ ' '████╗ ████║██╔══██╗╚██╗██╔╝╚██╗██╔╝██║████╗ ██║██╔════╝ ' '██╔████╔██║███████║ ╚███╔╝ ╚███╔╝ ██║██╔██╗ ██║██║ ███╗' '██║╚██╔╝██║██╔══██║ ██╔██╗ ██╔██╗ ██║██║╚██╗██║██║ ██║' '██║ ╚═╝ ██║██║ ██║██╔╝ ██╗██╔╝ ██╗██║██║ ╚████║╚██████╔╝' '╚═╝ ╚═╝╚═╝ ╚═╝╚═╝ ╚═╝╚═╝ ╚═╝╚═╝╚═╝ ╚═══╝ ╚═════╝ ') echo if [ "$FANCY" = 1 ] && [ "$(tput cols 2>/dev/null || echo 80)" -ge 64 ]; then printf ' %s%sH A R N E S S%s\n' "$C_B" "$C_C" "$C_0" local i for i in "${!lines[@]}"; do printf ' \033[38;5;%sm%s%s\n' "${BLUES[$i]}" "${lines[$i]}" "$C_0"; done else printf ' %sHARNESS MAXXING%s\n' "$C_B" "$C_0" fi printf ' %sThe control room for Claude Code on your own server%s\n' "$C_D" "$C_0" } # step_begin "Title" - numbered step; its output is captured and shown once it ends, a spinner runs meanwhile step_begin() { STEP=$((STEP + 1)); STEP_TITLE=$1; STEP_T0=$SECONDS; IN_STEP=1 if [ "$FANCY" = 1 ]; then STEP_LOG=$(mktemp) exec 3>&1 4>&2 ( trap 'exit 0' TERM f='⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏'; i=0; t0=$SECONDS while :; do printf '\r %s%s%s %s%d/%d%s %s %s%s%s\033[K' "$C_A" "${f:i%10:1}" "$C_0" "$C_D" "$STEP" "$TOTAL" "$C_0" \ "$STEP_TITLE" "$C_D" "$(fmt_secs $((SECONDS - t0)))" "$C_0" >&3 i=$((i + 1)); sleep 0.1 done ) & SPIN_PID=$! exec >"$STEP_LOG" 2>&1 else printf '\n[%d/%d] %s\n' "$STEP" "$TOTAL" "$STEP_TITLE" fi } step_end() { # step_end ok|fail local status=${1:-ok} took=$((SECONDS - STEP_T0)) mark IN_STEP=0 [ "$FANCY" = 1 ] || return 0 if [ -n "$SPIN_PID" ]; then kill "$SPIN_PID" 2>/dev/null; wait "$SPIN_PID" 2>/dev/null || true; SPIN_PID=""; fi exec 1>&3 2>&4 3>&- 4>&- if [ "$status" = ok ]; then mark="$C_G✔$C_0"; else mark="$C_R✘$C_0"; fi printf '\r\033[K %s %s%d/%d%s %s%s%s %s%s%s\n' "$mark" "$C_D" "$STEP" "$TOTAL" "$C_0" "$C_B" "$STEP_TITLE" "$C_0" \ "$C_D" "$(fmt_secs "$took")" "$C_0" if [ -s "$STEP_LOG" ]; then if [ "$status" = ok ]; then cat "$STEP_LOG"; else tail -n 25 "$STEP_LOG"; fi fi rm -f "$STEP_LOG" } run_step() { # run_step "Title" function - runs in this shell (variables it sets are kept) step_begin "$1" "$2" step_end ok } on_exit() { local rc=$? if [ "$IN_STEP" = 1 ]; then step_end fail printf '\n %sThe installation stopped.%s Fix the problem above, then run the same command again:\n' "$C_R$C_B" "$C_0" printf ' %severy step keeps what is already done.%s\n\n' "$C_D" "$C_0" fi exit "$rc" } trap on_exit EXIT # ---- questions (answers come from the terminal even when the script is piped) --------------- has_tty() { [ "$ASSUME_YES" != 1 ] && (: /dev/null; } # ask VAR "question" [default] - keeps a VAR that is already set ask() { local __v=$1 __q=$2 __d=${3:-} __a="" [ -n "${!__v:-}" ] && return 0 if ! has_tty; then printf -v "$__v" '%s' "$__d"; return 0; fi read -rp " $C_A?$C_0 $__q${__d:+ $C_D[$__d]$C_0}: " __a 0 = yes ask_yn() { local __a="" __d=${2:-n} if ! has_tty; then [ "$__d" = y ]; return; fi read -rp " $C_A?$C_0 $1 $C_D[$( [ "$__d" = y ] && echo Y/n || echo y/N )]$C_0: " __a /dev/null || echo "" import json, sys v = json.load(open(sys.argv[1])).get(sys.argv[2], "") print("" if v is None else v) PY } git_auth_args() { # prints nothing; fills GIT_AUTH array GIT_AUTH=() if [ -n "$GH_TOKEN" ]; then GIT_AUTH=(-c "http.extraHeader=Authorization: Basic $(printf 'x-access-token:%s' "$GH_TOKEN" | base64 | tr -d '\n')") fi } mx() { MAXXING_STATE="$STATE_DIR" "$BIN" "$@"; } usage() { cat <<'USAGE' Harness Maxxing installer curl -fsSL https://get.harness-maxxing.com | bash install or repair with a licence key (re-runnable) GH_TOKEN= bash install.sh development install from the git repository bash /opt/claude-maxxing/install.sh --update update (= maxxing update) bash /opt/claude-maxxing/install.sh --uninstall [--purge] [--purge-workspace] bash /opt/claude-maxxing/install.sh --tools (re)install zip, jq, Node.js, Remotion, Scrapling bash /opt/claude-maxxing/install.sh --secure security updates, fail2ban, firewall (FIREWALL=0: no firewall) Variables: LICENSE LICENSE_API REPO BRANCH GH_TOKEN INSTALL_DIR WORKSPACE STATE_DIR PORT ACCESS(domain|tailscale|local) FIREWALL(1|0) DOMAIN TIMEZONE ASSUME_YES=1 RESTORE_FROM RESTORE_TOKEN CLAUDE_TOKEN ANTHROPIC_API_KEY USAGE } # ---- checks --------------------------------------------------------------------------------- check_system() { section "Server" [ "$(id -u)" = 0 ] || die "Run as root (sudo -i, then the command again)." command -v apt-get >/dev/null 2>&1 || die "Ubuntu or Debian required (apt-get not found)." if [ -r /etc/os-release ]; then local ids pretty # shellcheck disable=SC1091 ids=$(. /etc/os-release && echo " ${ID:-} ${ID_LIKE:-} ") # shellcheck disable=SC1091 pretty=$(. /etc/os-release && echo "${PRETTY_NAME:-${ID:-unknown}}") case "$ids" in *" debian "*|*" ubuntu "*) ok "$pretty" ;; *) warn "untested distribution ($pretty) - continuing because apt is present" ;; esac fi [ -d /run/systemd/system ] || die "systemd must be running (sessions and the service are systemd units)." case "$(uname -m)" in x86_64|amd64|aarch64|arm64) ok "architecture $(uname -m), systemd running" ;; *) die "unsupported architecture $(uname -m) (x86_64 or arm64 required)" ;; esac HOME=$(getent passwd root | cut -d: -f6) export HOME export PATH="$HOME/.local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" case "$PORT" in ''|*[!0-9]*) die "PORT must be a number" ;; esac } # ---- choices (asked up front, then everything runs unattended) ------------------------------ choose_access() { section "Setup" local cur_url cur_bind def=local cur_url=$(cfg_get public_url) cur_bind=$(cfg_get bind) if [ -z "$ACCESS" ]; then case "$cur_url" in https://*) def=domain; [ -n "$DOMAIN" ] || DOMAIN=$(printf '%s' "$cur_url" | sed -E 's#^https://([^/:]+).*#\1#') ;; *) case "$cur_bind" in 100.*) def=tailscale ;; *) if [ -z "$cur_url$cur_bind" ] && has_tty; then def=domain; fi ;; esac ;; esac if has_tty; then echo " How will you reach the web UI?" printf ' %s1%s %sdomain%s https://ui.example.com - automatic HTTPS %s(recommended)%s\n' "$C_A$C_B" "$C_0" "$C_B" "$C_0" "$C_G" "$C_0" printf ' %s2%s %stailscale%s http://:%s - your Tailscale devices only\n' "$C_A$C_B" "$C_0" "$C_B" "$C_0" "$PORT" printf ' %s3%s %slocal%s 127.0.0.1 only - through an SSH tunnel\n' "$C_A$C_B" "$C_0" "$C_B" "$C_0" local n case "$def" in domain) n=1 ;; tailscale) n=2 ;; *) n=3 ;; esac ask ACC "Choice (1/2/3)" "$n" case "${ACC:-$n}" in 1|domain) ACCESS=domain ;; 2|tailscale) ACCESS=tailscale ;; *) ACCESS=local ;; esac else ACCESS=$def fi fi case "$ACCESS" in domain) ask DOMAIN "Domain name (its DNS A/AAAA record must point to this server)" "" DOMAIN=$(printf '%s' "$DOMAIN" | sed -E 's#^https?://##; s#/.*$##') [ -n "$DOMAIN" ] || die "ACCESS=domain needs DOMAIN=ui.example.com" PUBLIC_URL="https://$DOMAIN"; BIND=127.0.0.1 ;; tailscale) command -v tailscale >/dev/null 2>&1 || die "Tailscale is not installed: curl -fsSL https://tailscale.com/install.sh | sh && tailscale up" BIND=$(tailscale ip -4 2>/dev/null | head -n1 || true) [ -n "$BIND" ] || die "Tailscale is not connected (run: tailscale up)." PUBLIC_URL="http://$BIND:$PORT"; AFTER=" tailscaled.service" ;; local) PUBLIC_URL=""; BIND=127.0.0.1 ;; *) die "ACCESS must be domain, tailscale or local" ;; esac ok "access: $ACCESS${PUBLIC_URL:+ ($PUBLIC_URL)}, server bound to $BIND:$PORT" if [ -z "$RESTORE_FROM" ] && [ ! -f "$WORKSPACE/memory.md" ] && has_tty; then if ask_yn "Restore your memory from an existing Harness Maxxing GitHub backup?" n; then ask RESTORE_FROM "Backup repository (owner/name)" "" fi fi if [ -n "$RESTORE_FROM" ]; then ask_secret RESTORE_TOKEN "GitHub token with read access to $RESTORE_FROM" [ -n "$RESTORE_TOKEN" ] || die "RESTORE_FROM needs RESTORE_TOKEN" ok "restore from $RESTORE_FROM after the installation" fi case "$VOICE" in ""|off|local|groq|openai) ;; *) die "VOICE must be local, groq, openai or off" ;; esac [ "$VOICE" = off ] || ok "voice messages: $VOICE (change it later in Settings -> Voice)" if [ -d "$INSTALL_DIR/.git" ]; then if [ -f "$STATE_DIR/secrets.json" ] && grep -q '"license_token"' "$STATE_DIR/secrets.json" 2>/dev/null; then LICENSED=1; fi elif [ -z "$GH_TOKEN" ]; then # new install: a licence key LICENSED=1 if [ -z "$LICENSE" ]; then has_tty || die "set LICENSE=HM-XXXX-XXXX-XXXX-XXXX (the key from your licence email)" ask LICENSE "Licence key (from your licence email, HM-XXXX-XXXX-XXXX-XXXX)" "" fi LICENSE=$(printf '%s' "$LICENSE" | tr -d '[:space:]' | tr '[:lower:]' '[:upper:]') [ -n "$LICENSE" ] || die "a licence key is needed - it is in your licence email (lost it? https://harness-maxxing.com/licence)" license_activate fi [ "$ACCESS" = domain ] && dns_check SYS_TZ=$(system_tz) [ -n "$TIMEZONE" ] || TIMEZONE=$(cfg_get timezone) [ -n "$TIMEZONE" ] || TIMEZONE=$SYS_TZ # chosen in the web onboarding if [ ! -d /usr/share/zoneinfo ] || [ ! -e /usr/share/zoneinfo/UTC ]; then # minimal images ship without tzdata export DEBIAN_FRONTEND=noninteractive apt-get update -qq >/dev/null 2>&1; apt-get install -y -qq tzdata >/dev/null 2>&1 || true fi case "$TIMEZONE" in UTC|Etc/UTC) ;; *) [ -f "/usr/share/zoneinfo/$TIMEZONE" ] || die "unknown time zone '$TIMEZONE' (see: timedatectl list-timezones)";; esac if [ "$TIMEZONE" != "$SYS_TZ" ]; then ok "time zone: $TIMEZONE (the server clock is switched from $SYS_TZ so jobs fire at the times you see)" else ok "time zone: $TIMEZONE for now - you pick yours in the web onboarding" fi } # system_tz - the zone the cron daemon uses system_tz() { local z="" z=$(timedatectl show -p Timezone --value 2>/dev/null || true) [ -n "$z" ] || z=$(cat /etc/timezone 2>/dev/null || true) [ -n "$z" ] || z=$(readlink /etc/localtime 2>/dev/null | sed 's#.*/zoneinfo/##' || true) echo "${z:-UTC}" } # align_tz - cron fires in the system zone: make it equal to the configured one align_tz() { local cur cur=$(system_tz) [ "$TIMEZONE" = "$cur" ] && return 0 if timedatectl set-timezone "$TIMEZONE" 2>/dev/null; then systemctl restart cron >/dev/null 2>&1 || true ok "system time zone set to $TIMEZONE (was $cur), cron restarted" else warn "could not set the system time zone - run: timedatectl set-timezone $TIMEZONE && systemctl restart cron" fi } # ---- packages ------------------------------------------------------------------------------- install_packages() { export DEBIAN_FRONTEND=noninteractive apt-get update -qq >/dev/null 2>&1 || warn "apt-get update reported errors" apt-get install -y -qq git curl ca-certificates cron openssl >/tmp/maxxing-apt.log 2>&1 \ || { tail -n 15 /tmp/maxxing-apt.log; die "apt-get install failed"; } apt-get install -y -qq ffmpeg >/dev/null 2>&1 || warn "ffmpeg not installed: no video thumbnails in Files, no voice messages" systemctl enable --now cron >/dev/null 2>&1 || warn "could not start the cron service" ok "git, curl, cron$(command -v ffmpeg >/dev/null 2>&1 && echo ', ffmpeg')" } # Small universal tools + Node.js + Remotion (motion design videos), shared by every video project: # /tools/remotion holds node_modules and the headless browser once; `maxxing video new DIR` # links new projects to it. Never fatal: Harness Maxxing works without it. `install.sh --tools` re-runs it. install_tools() { export DEBIAN_FRONTEND=noninteractive apt-get install -y -qq zip unzip jq ripgrep imagemagick poppler-utils xz-utils >>/tmp/maxxing-apt.log 2>&1 \ || warn "some tools could not be installed (zip, unzip, jq, ripgrep, imagemagick, poppler-utils)" local p n libs=() for p in libnss3 libdbus-1-3 libatk1.0-0t64:libatk1.0-0 libatk-bridge2.0-0t64:libatk-bridge2.0-0 libgbm1 \ libasound2t64:libasound2 libxrandr2 libxkbcommon0 libxfixes3 libxcomposite1 libxdamage1 \ libpango-1.0-0 libcairo2 libcups2t64:libcups2 fonts-liberation fonts-noto-color-emoji; do for n in ${p//:/ }; do if apt-cache show "$n" >/dev/null 2>&1; then libs+=("$n"); break; fi done done apt-get install -y -qq "${libs[@]}" >>/tmp/maxxing-apt.log 2>&1 || warn "some browser libraries are missing (video rendering)" local scr="" if install_scrapling; then scr=" · Scrapling $("$STATE_DIR/tools/uv/scrapling/bin/python" -c 'import importlib.metadata as m; print(m.version("scrapling"))' 2>/dev/null)" else tail -n 5 /tmp/maxxing-scrapling.log 2>/dev/null warn "Scrapling could not be installed (re-run: install.sh --tools)" fi install_node || { warn "Node.js not installed: no video rendering (re-run: install.sh --tools)"; return 0; } local dir="$STATE_DIR/tools/remotion" mkdir -p "$dir" cp -r "$INSTALL_DIR/templates/remotion/." "$dir/" if (cd "$dir" && npm install --no-audit --no-fund --loglevel=error >/tmp/maxxing-npm.log 2>&1 \ && npx --yes remotion browser ensure >>/tmp/maxxing-npm.log 2>&1); then ok "zip, jq, ripgrep, imagemagick · Node.js $(node --version) · Remotion $(cd "$dir" && node -p "require('remotion/package.json').version" 2>/dev/null)$scr" else tail -n 5 /tmp/maxxing-npm.log warn "Remotion could not be prepared (re-run: install.sh --tools)" fi } # Scrapling (web scraping: static pages, JavaScript pages, protected pages) in its own uv tool env, # with Playwright's Chromium in /tools/browsers; /usr/local/bin/scrapling and scrapling-python # are wrappers that point it at that browser. No MCP server is registered (it would add tools to every # session's context): Claude uses the CLI or Python through the web-scraping skill. install_scrapling() { local base="$STATE_DIR/tools" py mkdir -p "$base" UV_TOOL_DIR="$base/uv" UV_TOOL_BIN_DIR="$base/bin" uv tool install -q --force --python 3.12 \ "scrapling[fetchers,rag]" >>/tmp/maxxing-scrapling.log 2>&1 || return 1 py="$base/uv/scrapling/bin/python" [ -x "$py" ] || return 1 PLAYWRIGHT_BROWSERS_PATH="$base/browsers" "$py" -m playwright install --with-deps --no-shell chromium \ >>/tmp/maxxing-scrapling.log 2>&1 || return 1 printf '#!/bin/sh\n# Harness Maxxing wrapper: Scrapling with its browser (%s)\nexport PLAYWRIGHT_BROWSERS_PATH=%s\nexec %s "$@"\n' \ "$base/browsers" "$base/browsers" "$base/bin/scrapling" > /usr/local/bin/scrapling printf '#!/bin/sh\n# Harness Maxxing wrapper: Python with Scrapling installed (for scraping scripts)\nexport PLAYWRIGHT_BROWSERS_PATH=%s\nexec %s "$@"\n' \ "$base/browsers" "$py" > /usr/local/bin/scrapling-python chmod 755 /usr/local/bin/scrapling /usr/local/bin/scrapling-python } install_node() { local cur cur=$(node --version 2>/dev/null | sed 's/^v//; s/\..*//') if [ -n "$cur" ] && [ "$cur" -ge 20 ]; then return 0; fi local arch base file case "$(uname -m)" in x86_64) arch=x64 ;; aarch64|arm64) arch=arm64 ;; *) return 1 ;; esac base="https://nodejs.org/dist/latest-v$NODE_MAJOR.x" file=$(curl -fsSL "$base/SHASUMS256.txt" | awk -v a="linux-$arch.tar.xz" '$2 ~ a"$" {print $2; exit}') || return 1 [ -n "$file" ] || return 1 curl -fsSL "$base/$file" -o "/tmp/$file" || return 1 (cd /tmp && curl -fsSL "$base/SHASUMS256.txt" | grep " $file\$" | sha256sum -c --status) || { rm -f "/tmp/$file"; return 1; } tar -xJf "/tmp/$file" -C /usr/local --strip-components=1 --exclude='*/CHANGELOG.md' --exclude='*/README.md' --exclude='*/LICENSE' \ && rm -f "/tmp/$file" command -v node >/dev/null 2>&1 } install_uv() { if command -v uv >/dev/null 2>&1; then ok "uv already installed ($(uv --version 2>/dev/null))" return fi curl -LsSf https://astral.sh/uv/install.sh | env UV_INSTALL_DIR=/usr/local/bin UV_NO_MODIFY_PATH=1 sh >/dev/null 2>&1 \ || die "uv installation failed (https://docs.astral.sh/uv/)" command -v uv >/dev/null 2>&1 || die "uv not found after installation" ok "uv $(uv --version 2>/dev/null | awk '{print $2}') in /usr/local/bin" } # The official installer refuses to run under sudo (SUDO_USER set, e.g. after `sudo -i` from an ubuntu # account): we install for root on purpose, so allow it. Its output is kept in a log and shown on failure. install_claude() { local log=/tmp/maxxing-claude-install.log try if ! command -v claude >/dev/null 2>&1 && [ ! -x /root/.local/bin/claude ]; then : >"$log" for try in 1 2; do if curl -fsSL https://claude.ai/install.sh -o /tmp/claude-install.sh >>"$log" 2>&1 \ && HOME=/root CLAUDE_INSTALL_ALLOW_SUDO=1 bash /tmp/claude-install.sh >>"$log" 2>&1; then break fi [ "$try" = 2 ] && claude_install_failed "$log" sleep 5 done rm -f /tmp/claude-install.sh fi HOME=/root # the rest of the install is for root too (skills in /root/.claude) if [ ! -e /usr/local/bin/claude ] && [ -x /root/.local/bin/claude ]; then ln -s /root/.local/bin/claude /usr/local/bin/claude fi command -v claude >/dev/null 2>&1 || die "claude not found after installation" ok "Claude Code $(claude --version 2>/dev/null | head -n1)" if [ -f "$HOME/.claude/.credentials.json" ]; then info "the Claude CLI is already signed in on this server" fi } claude_install_failed() { # explain the most common causes, then stop local log=$1 printf '\n'; tail -n 12 "$log" | sed 's/^/ | /'; printf '\n' if ! curl -fsS -o /dev/null --max-time 15 https://downloads.claude.ai/claude-code-releases/stable 2>/dev/null; then warn "downloads.claude.ai is not reachable from this server: check DNS / the provider's outgoing" warn "firewall, or the country (https://www.anthropic.com/supported-countries)." elif grep -qi "not available in your region\|unexpected content" "$log"; then warn "Claude Code is not available from this server's country (https://www.anthropic.com/supported-countries)." elif grep -qi "checksum" "$log"; then warn "the download was corrupted - re-run the installer." elif grep -qi "No space left" "$log"; then warn "the disk is full - free some space (df -h) and re-run the installer." fi die "Claude Code installation failed (full log: $log) - re-run the installer once fixed" } # ---- server security ------------------------------------------------------------------------ # Automatic security updates, fail2ban for SSH, and a firewall that only lets SSH (+ 80/443 for a # domain, + the Tailscale interface) in - so a dev server Claude starts on 0.0.0.0 is not exposed. # The SSH ports are detected first (never lock the user out); FIREWALL=0 skips the firewall. ssh_ports() { local p p=$( { sshd -T 2>/dev/null | awk '$1=="port"{print $2}'; echo "${SSH_CONNECTION:-}" | awk 'NF==4{print $4}'; } | sort -un) [ -n "$p" ] && echo "$p" || echo 22 } secure_server() { export DEBIAN_FRONTEND=noninteractive local done=() if apt-get install -y -qq unattended-upgrades >>/tmp/maxxing-apt.log 2>&1; then printf 'APT::Periodic::Update-Package-Lists "1";\nAPT::Periodic::Unattended-Upgrade "1";\n' \ > /etc/apt/apt.conf.d/20auto-upgrades systemctl enable --now unattended-upgrades >/dev/null 2>&1 || true done+=("security updates") else warn "unattended-upgrades not installed" fi if apt-get install -y -qq fail2ban >>/tmp/maxxing-apt.log 2>&1; then rm -f /etc/fail2ban/jail.d/agent-maxxing.local # name used by early 2.0.0a5 builds [ -f /etc/fail2ban/jail.d/harness-maxxing.local ] || cat > /etc/fail2ban/jail.d/harness-maxxing.local <<'JAIL' # Harness Maxxing: ban an IP for 1 h after 5 failed SSH logins in 10 min (edit freely) [sshd] enabled = true backend = systemd maxretry = 5 findtime = 10m bantime = 1h JAIL systemctl enable fail2ban >/dev/null 2>&1 || true systemctl restart fail2ban >/dev/null 2>&1 && done+=("fail2ban (SSH)") || warn "fail2ban did not start" else warn "fail2ban not installed" fi if [ "$FIREWALL" = 0 ]; then info "firewall skipped (FIREWALL=0)" elif systemd-detect-virt -cq 2>/dev/null; then info "container: the firewall is left to the host" elif apt-get install -y -qq ufw >>/tmp/maxxing-apt.log 2>&1; then local p ports ports=$(ssh_ports) for p in $ports; do ufw allow "$p/tcp" comment 'SSH' >/dev/null; done if [ "$ACCESS" = domain ]; then ufw allow 80/tcp comment 'HTTP (Caddy)' >/dev/null ufw allow 443/tcp comment 'HTTPS (Caddy)' >/dev/null fi [ "$ACCESS" = tailscale ] && ufw allow in on tailscale0 comment 'Tailscale' >/dev/null ufw default deny incoming >/dev/null ufw default allow outgoing >/dev/null local open="SSH $(echo $ports | tr ' ' '/')" [ "$ACCESS" = domain ] && open="$open 80 443" [ "$ACCESS" = tailscale ] && open="$open tailscale0" if ufw --force enable >/dev/null; then done+=("firewall ($open)"); else warn "ufw could not be enabled"; fi else warn "ufw not installed" fi if [ ${#done[@]} -gt 0 ]; then local out="" d; for d in "${done[@]}"; do out="${out:+$out · }$d"; done; ok "$out"; fi } # ---- licence -------------------------------------------------------------------------------- machine_hash() { local raw="" raw=$(tr -d '[:space:]' /dev/null || true) [ -n "$raw" ] || raw=$(tr -d '[:space:]' /dev/null || true) [ -n "$raw" ] || die "/etc/machine-id is missing (run: systemd-machine-id-setup)" printf 'harness-maxxing:%s' "$raw" | sha256sum | cut -d' ' -f1 } # json_get FILE KEY.SUBKEY - prints a value of a JSON file ("" when absent) json_get() { python3 - "$1" "$2" <<'PY' 2>/dev/null || true import json, sys v = json.load(open(sys.argv[1])) for k in sys.argv[2].split("."): v = v.get(k) if isinstance(v, dict) else None print("" if v is None else v) PY } license_activate() { command -v curl >/dev/null 2>&1 || { apt-get update -qq >/dev/null 2>&1; apt-get install -y -qq curl ca-certificates >/dev/null 2>&1; } command -v python3 >/dev/null 2>&1 || { apt-get update -qq >/dev/null 2>&1; apt-get install -y -qq python3 >/dev/null 2>&1; } RELEASE_JSON=$(mktemp /tmp/maxxing-activation.XXXXXX) local body code msg body=$(python3 -c 'import json,sys; print(json.dumps({"key": sys.argv[1], "machine_id": sys.argv[2], "hostname": sys.argv[3], "os": sys.argv[4]}))' \ "$LICENSE" "$(machine_hash)" "$(hostname)" "$(. /etc/os-release 2>/dev/null; echo "${PRETTY_NAME:-linux}")") code=$(curl -sS -o "$RELEASE_JSON" -w '%{http_code}' -X POST -H 'content-type: application/json' --data "$body" "$LICENSE_API/v1/activate" 2>/dev/null || echo 000) if [ "$code" != 200 ]; then msg=$(json_get "$RELEASE_JSON" message) [ "$code" = 000 ] && msg="cannot reach $LICENSE_API (network?)" die "licence refused: ${msg:-HTTP $code}" fi [ -n "$(json_get "$RELEASE_JSON" release.url)" ] || die "licence OK but $(json_get "$RELEASE_JSON" message || echo 'no version is available yet')" ok "licence ${LICENSE:0:7}…${LICENSE: -4} ($(json_get "$RELEASE_JSON" license.kind), $(json_get "$RELEASE_JSON" license.email)) - version $(json_get "$RELEASE_JSON" release.version)" } # the signed release of the activation -> $INSTALL_DIR as a git repository (origin/main = this version) fetch_release() { local tmp ver sha sig url tmp=$(mktemp -d /tmp/maxxing-release.XXXXXX) ver=$(json_get "$RELEASE_JSON" release.version); sha=$(json_get "$RELEASE_JSON" release.sha256) sig=$(json_get "$RELEASE_JSON" release.signature); url=$(json_get "$RELEASE_JSON" release.url) curl -fsSL --retry 3 -o "$tmp/release.tar.gz" "$url" || die "download of version $ver failed" [ "$(sha256sum "$tmp/release.tar.gz" | cut -d' ' -f1)" = "$sha" ] || die "the downloaded archive is corrupted (sha256 mismatch) - run the command again" command -v openssl >/dev/null 2>&1 || apt-get install -y -qq openssl >/dev/null 2>&1 printf '%s' "$RELEASE_PUBKEY" > "$tmp/pub.pem" printf 'harness-maxxing|%s|%s' "$ver" "$sha" > "$tmp/msg" printf '%s' "$sig" | base64 -d > "$tmp/sig" openssl pkeyutl -verify -pubin -inkey "$tmp/pub.pem" -rawin -in "$tmp/msg" -sigfile "$tmp/sig" >/dev/null 2>&1 \ || die "the downloaded archive is not signed by Harness Maxxing - refused" mkdir -p "$INSTALL_DIR" tar -xzf "$tmp/release.tar.gz" -C "$INSTALL_DIR" --no-same-owner || die "cannot extract version $ver" local id=(-c user.name="Harness Maxxing" -c user.email="releases@harness-maxxing.com") git -C "$INSTALL_DIR" init -q -b "$BRANCH" git -C "$INSTALL_DIR" add -A -f git "${id[@]}" -C "$INSTALL_DIR" commit -q -m "Harness Maxxing $ver" || die "git commit of version $ver failed" git -C "$INSTALL_DIR" update-ref "refs/remotes/origin/$BRANCH" HEAD rm -rf "$tmp" ok "version $ver downloaded, signature verified, installed in $INSTALL_DIR" } # ---- code ----------------------------------------------------------------------------------- fetch_code() { local url="https://github.com/$REPO.git" export GIT_TERMINAL_PROMPT=0 git_auth_args if [ -d "$INSTALL_DIR/.git" ] && [ "$LICENSED" = 1 ]; then ok "licensed install in $INSTALL_DIR - kept (update with: maxxing update)" return fi if [ "$LICENSED" = 1 ]; then if [ -e "$INSTALL_DIR" ] && [ -n "$(ls -A "$INSTALL_DIR" 2>/dev/null)" ]; then die "$INSTALL_DIR exists and is not a Harness Maxxing install - move it away or set INSTALL_DIR" fi fetch_release return fi if [ -d "$INSTALL_DIR/.git" ]; then if [ -z "$GH_TOKEN" ]; then GH_TOKEN=$(MAXXING_STATE="$STATE_DIR" "$(venv_py)" -c 'from maxxing import config; print(config.get_secret("github_update_token"))' 2>/dev/null || true) git_auth_args fi if git ${GIT_AUTH[@]+"${GIT_AUTH[@]}"} -C "$INSTALL_DIR" fetch -q origin "$BRANCH" 2>/dev/null; then [ -n "$GH_TOKEN" ] && TOKEN_USED=1 git -C "$INSTALL_DIR" checkout -q "$BRANCH" 2>/dev/null || git -C "$INSTALL_DIR" checkout -q -b "$BRANCH" --track "origin/$BRANCH" if git -C "$INSTALL_DIR" merge -q --ff-only "origin/$BRANCH" >/dev/null 2>&1; then ok "updated to $(git -C "$INSTALL_DIR" rev-parse --short HEAD)" else warn "customizations in $INSTALL_DIR - kept; update with: maxxing update (carries them over)" fi else warn "could not reach $url - keeping the current code" fi return fi if [ -e "$INSTALL_DIR" ] && [ -n "$(ls -A "$INSTALL_DIR" 2>/dev/null)" ]; then die "$INSTALL_DIR exists and is not a git clone - move it away or set INSTALL_DIR" fi mkdir -p "$(dirname "$INSTALL_DIR")" if [ -z "$GH_TOKEN" ] && git clone -q -b "$BRANCH" "$url" "$INSTALL_DIR" 2>/dev/null; then ok "cloned into $INSTALL_DIR" return fi rm -rf "$INSTALL_DIR" [ -n "$GH_TOKEN" ] || die "cannot clone $url (private repository? set GH_TOKEN)" git_auth_args git "${GIT_AUTH[@]}" clone -q -b "$BRANCH" "$url" "$INSTALL_DIR" 2>/dev/null \ || die "cannot clone $url with this token (needs Contents: read on $REPO)" TOKEN_USED=1 ok "cloned into $INSTALL_DIR (token kept out of .git/config)" } setup_python() { local extra=() if [ "$VOICE" = local ] || [ -f "$STATE_DIR/voice/extra-installed" ]; then extra=(--extra voice) # faster-whisper (local voice transcription) mkdir -p "$STATE_DIR/voice" && touch "$STATE_DIR/voice/extra-installed" fi (cd "$INSTALL_DIR/server" && uv sync --frozen --no-dev --quiet "${extra[@]}") || die "uv sync failed in $INSTALL_DIR/server" local exe="$INSTALL_DIR/server/.venv/bin/maxxing" [ -x "$exe" ] || die "$exe missing after uv sync" if [ "$STATE_DIR" = "$DEFAULT_STATE" ]; then ln -sfn "$exe" "$BIN" else rm -f "$BIN" printf '#!/bin/sh\n# claude-maxxing CLI wrapper (custom state dir)\nexport MAXXING_STATE=%s\nexec %s "$@"\n' \ "'$STATE_DIR'" "'$exe'" > "$BIN" chmod 755 "$BIN" fi ok "$(mx --version 2>/dev/null || echo maxxing) -> $BIN" } init_state() { mkdir -p "$STATE_DIR/logs" local args=(init --workspace "$WORKSPACE" --port "$PORT" --bind "$BIND" --public-url "$PUBLIC_URL") [ -n "$TIMEZONE" ] && args+=(--timezone "$TIMEZONE") : mx "${args[@]}" > "$STATE_DIR/logs/install-init.log" 2>&1 || { cat "$STATE_DIR/logs/install-init.log"; die "maxxing init failed"; } grep -E '^ *[✔!✘–]' "$STATE_DIR/logs/install-init.log" | sed -E 's/^ *//; s/^/ /' || true if [ "$TOKEN_USED" = 1 ] && [ -n "$GH_TOKEN" ]; then printf '%s' "$GH_TOKEN" | mx init --quiet --no-crons --secret-from-stdin github_update_token \ && ok "update token stored in $STATE_DIR/secrets.json (used by maxxing update)" fi if [ "$LICENSED" = 1 ] && [ -n "$LICENSE" ]; then printf '%s\n' "$LICENSE" | mx license activate --key-from-stdin --no-fetch >/dev/null \ && ok "licence stored (updates: maxxing update, status: maxxing license status)" \ || warn "could not store the licence - run: maxxing license activate $LICENSE" fi if [ -n "$CLAUDE_TOKEN" ]; then printf '%s' "$CLAUDE_TOKEN" | mx init --quiet --no-crons --secret-from-stdin claude_oauth_token && ok "Claude token stored" fi if [ -n "$ANTHROPIC_API_KEY" ]; then printf '%s' "$ANTHROPIC_API_KEY" | mx init --quiet --no-crons --secret-from-stdin anthropic_api_key && ok "Anthropic API key stored" fi if [ -n "$VOICE" ]; then mx voice set --engine "$VOICE" >/dev/null || warn "could not enable voice messages (Settings -> Voice)" case "$VOICE" in local) # model download in the background (~150-500 MB) mkdir -p "$STATE_DIR/logs" nohup "$BIN" voice download > "$STATE_DIR/logs/voice-download.log" 2>&1 & info "the speech model downloads in the background (log: $STATE_DIR/logs/voice-download.log)" ;; groq|openai) info "add your ${VOICE^^}_API_KEY in Settings -> Voice to finish" ;; esac fi } restore_backup() { if [ -z "$RESTORE_FROM" ]; then info "skipped (no RESTORE_FROM) - configure the backup later in Settings -> Backup" return fi if ! mx backup restore --help >/dev/null 2>&1; then warn "this version has no 'maxxing backup restore' - restore later from Settings -> Backup" return fi if MAXXING_GITHUB_TOKEN="$RESTORE_TOKEN" mx backup restore --from "$RESTORE_FROM" /dev/null 2>&1; then if mx custom restore >/dev/null 2>&1; then ok "customizations of the product code re-applied (CUSTOMIZATIONS.md)" else warn "saved customizations do not apply to this version - ask Claude in the topic system/update" fi fi else warn "restore failed - retry with: MAXXING_GITHUB_TOKEN= maxxing backup restore --from $RESTORE_FROM" fi } # ---- access --------------------------------------------------------------------------------- setup_caddy() { if ! command -v caddy >/dev/null 2>&1; then apt-get install -y -qq debian-keyring debian-archive-keyring apt-transport-https gnupg >/dev/null 2>&1 || true curl -fsSL 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' \ | gpg --batch --yes --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg curl -fsSL 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' > /etc/apt/sources.list.d/caddy-stable.list { apt-get update -qq && apt-get install -y -qq caddy; } >/tmp/maxxing-apt.log 2>&1 \ || { tail -n 15 /tmp/maxxing-apt.log; die "Caddy installation failed"; } ok "Caddy installed" fi mkdir -p /etc/caddy cat > "$CADDY_SITE" < "$CADDYFILE" # stock welcome page -> replaced elif ! grep -qF "$CADDY_SITE" "$CADDYFILE"; then if grep -qE "^[[:space:]]*$DOMAIN([[:space:]:,{]|$)" "$CADDYFILE"; then die "$CADDYFILE already serves $DOMAIN - remove that block, then re-run" fi printf '\nimport %s\n' "$CADDY_SITE" >> "$CADDYFILE" fi caddy validate --config "$CADDYFILE" --adapter caddyfile >/dev/null 2>&1 \ || die "invalid Caddy configuration - see: caddy validate --config $CADDYFILE --adapter caddyfile" systemctl enable --now caddy >/dev/null 2>&1 || true systemctl reload caddy >/dev/null 2>&1 || systemctl restart caddy >/dev/null 2>&1 || die "Caddy did not start: journalctl -u caddy -n 50" ok "Caddy serves https://$DOMAIN -> 127.0.0.1:$PORT" } # dns_check - does DOMAIN point to this server? (warning only: proxies and NAT exist) dns_check() { local resolved mine resolved=$(getent ahostsv4 "$DOMAIN" 2>/dev/null | awk 'NR==1{print $1}' || true) mine=$(curl -4 -fsS --max-time 4 https://api.ipify.org 2>/dev/null || true) if [ -z "$resolved" ]; then warn "$DOMAIN does not resolve yet - create its DNS A record; the certificate is issued once it does" elif [ "$resolved" = "$mine" ] || hostname -I 2>/dev/null | tr ' ' '\n' | grep -qxF "$resolved"; then ok "$DOMAIN points to this server ($resolved)" else warn "$DOMAIN resolves to $resolved, not to this server${mine:+ ($mine)} - behind a proxy? turn it off (DNS only)" fi } setup_access() { case "$ACCESS" in domain) setup_caddy ;; tailscale) ok "reachable at $PUBLIC_URL from your Tailscale devices" ;; local) ok "listening on 127.0.0.1:$PORT only (SSH tunnel)" ;; esac if [ "$ACCESS" != domain ] && [ -f "$CADDY_SITE" ]; then warn "an old Caddy site exists ($CADDY_SITE) - remove it and its import line in $CADDYFILE if unused" fi } # ---- service -------------------------------------------------------------------------------- setup_service() { mkdir -p "$WORKSPACE" cat > "$UNIT" </dev/null 2>&1 systemctl restart "$SERVICE" local url="http://$BIND:$PORT/api/auth/state" i for i in $(seq 1 30); do curl -fsS -o /dev/null --max-time 2 "$url" 2>/dev/null && break sleep 1 done curl -fsS -o /dev/null --max-time 3 "$url" 2>/dev/null \ || die "the server does not answer on $url - journalctl -u $SERVICE -n 50 --no-pager" ok "running on $BIND:$PORT (systemctl status $SERVICE)" } # box_row "text with colours" - one line of the final box, padded to BOX_W visible characters box_row() { local plain pad plain=$(printf '%s' "$1" | sed $'s/\033\\[[0-9;]*m//g') pad=$((BOX_W - ${#plain})) [ "$pad" -lt 0 ] && pad=0 printf ' %s│%s %s%*s %s│%s\n' "$C_A" "$C_0" "$1" "$pad" "" "$C_A" "$C_0" } box_rule() { # box_rule ╭ ╮ local line line=$(printf '%*s' $((BOX_W + 4)) '' | sed 's/ /─/g') printf ' %s%s%s%s%s\n' "$C_A" "$1" "$line" "$2" "$C_0" } banner() { local link ip took link=$(mx setup-link 2>/dev/null || true) ip=$(hostname -I 2>/dev/null | awk '{print $1}') took=$(fmt_secs $((SECONDS - T_START))) BOX_W=62 [ ${#link} -gt $BOX_W ] && BOX_W=${#link} echo box_rule ╭ ╮ box_row "" box_row "$C_G$C_B✔ Harness Maxxing is installed$C_0 $C_D($took)$C_0" box_row "" if [ "$ACCESS" = local ]; then box_row "From your computer, open an SSH tunnel and keep it open:" box_row "$C_B""ssh -N -L $PORT:127.0.0.1:$PORT root@${ip:-}$C_0" box_row "" fi case "$link" in http*) box_row "Open this link to create your password:" box_row "" box_row "$C_W$C_B$link$C_0" box_row "" box_row "$C_Y!$C_0 $C_D""One-time link: whoever opens it first owns this server.$C_0" box_row "$C_D"" Lost it? run: maxxing setup-link$C_0" ;; *) box_row "Already set up - sign in at:" box_row "$C_W$C_B${PUBLIC_URL:-http://127.0.0.1:$PORT}$C_0" box_row "$C_D""Password lost? run: maxxing password$C_0" ;; esac box_row "" box_row "The web onboarding then connects Claude, your first project," box_row "Telegram, voice and the memory backup." box_row "" box_rule ╰ ╯ echo printf ' %smaxxing doctor%s check everything %smaxxing update%s update\n' "$C_C" "$C_0" "$C_C" "$C_0" printf ' %smaxxing status%s summary %smaxxing --help%s all commands\n' "$C_C" "$C_0" "$C_C" "$C_0" echo } # ---- entry points --------------------------------------------------------------------------- do_update() { [ "$(id -u)" = 0 ] || die "Run as root." [ -x "$BIN" ] || die "Harness Maxxing is not installed ($BIN missing) - run the installer without --update" mx update } do_uninstall() { [ "$(id -u)" = 0 ] || die "Run as root." local args=(uninstall) [ "$ASSUME_YES" = 1 ] && args+=(--yes) [ "$PURGE" = 1 ] && args+=(--purge) [ "$PURGE_WS" = 1 ] && args+=(--purge-workspace) if [ -x "$BIN" ]; then mx "${args[@]}" else warn "maxxing CLI not found - removing the service only" systemctl disable --now "$SERVICE" >/dev/null 2>&1 || true rm -f "$UNIT" systemctl daemon-reload ok "service removed" fi } main() { local mode=install PURGE=0 PURGE_WS=0 while [ $# -gt 0 ]; do case "$1" in --update) mode=update ;; --uninstall) mode=uninstall ;; --tools) mode=tools ;; --secure) mode=secure ;; --purge) PURGE=1 ;; --purge-workspace) PURGE_WS=1 ;; -y|--yes) ASSUME_YES=1 ;; -h|--help) usage; exit 0 ;; *) die "unknown option: $1 (see --help)" ;; esac shift done case "$mode" in update) do_update; exit $? ;; uninstall) do_uninstall; exit $? ;; secure) [ "$(id -u)" = 0 ] || die "Run as root." if [ -z "$ACCESS" ]; then if [ -f "$CADDY_SITE" ]; then ACCESS=domain else case "$(cfg_get bind)" in 100.*) ACCESS=tailscale ;; *) ACCESS=local ;; esac; fi fi TOTAL=1; run_step "Server security ($ACCESS)" secure_server; exit $? ;; tools) [ "$(id -u)" = 0 ] || die "Run as root."; [ -d "$INSTALL_DIR/templates/remotion" ] || die "Harness Maxxing code not found in $INSTALL_DIR" TOTAL=1; run_step "Tools (zip, jq, Node.js, Remotion, Scrapling)" install_tools; exit $? ;; esac logo check_system choose_access [ -n "$RESTORE_FROM" ] || TOTAL=$((TOTAL - 1)) section "Installing" printf ' %sabout 2 minutes - nothing else to answer%s\n\n' "$C_D" "$C_0" run_step "System packages" install_packages align_tz run_step "Python toolchain (uv)" install_uv run_step "Claude Code" install_claude run_step "Harness Maxxing code" fetch_code run_step "Python environment" setup_python run_step "Workspace, skills and jobs" init_state run_step "Tools (zip, jq, Node.js, Remotion, Scrapling)" install_tools [ -z "$RESTORE_FROM" ] || run_step "Memory restore" restore_backup case "$ACCESS" in domain) run_step "HTTPS with Caddy" setup_access ;; *) run_step "Access ($ACCESS)" setup_access ;; esac run_step "Server security" secure_server run_step "Service" setup_service banner } main "$@"