#!/usr/bin/env bash # # install.sh — one-line self-host installer for Waggle OS (Linux + macOS). # # curl -fsSL https://raw.githubusercontent.com/marolinik/waggle-os/main/install.sh | bash # # WHY THIS EXISTS (steal #5, installer arc 2026-07-10): # Waggle had a Tauri desktop binary, a heavy Docker team stack, and a # "clone + two dev terminals" README — but no one-command headless self-host # story for the VPS/homelab audience the OSS funnel targets. This installer # fills that gap: prereqs -> clone -> build -> tiny wizard -> start -> URL. # # WHAT IT DOES NOT DO (by design, D2/D3): # No sudo, ever. No API keys, personas, or channel setup — the web UI's # OnboardingWizard owns all of that. Missing prerequisites print the exact # per-OS install command and exit; we never install system packages for you. # # FLAGS: # --yes non-interactive; accept every wizard default # --dir PATH install directory (default: ~/waggle-os) # --port N sidecar port (default: 3333) # --data-dir PATH data directory (default: ~/.waggle) # --no-web skip building the web UI (API/echo mode only) # --no-start install but do not start the server # --branch NAME git branch to clone (default: main) # --local-source PATH copy an existing checkout instead of git-cloning # (for CI and local testing; includes uncommitted files) # # Idempotent: re-running against an installed directory prints an upgrade hint # and exits without re-cloning or rebuilding. set -euo pipefail # ── Constants ───────────────────────────────────────────────────────────────── REPO_URL="https://github.com/marolinik/waggle-os.git" MIN_NODE_MAJOR=20 DEFAULT_PORT=3333 MARKER_NAME=".waggle-installed" # ── Wizard defaults ─────────────────────────────────────────────────────────── INSTALL_DIR="$HOME/waggle-os" PORT="$DEFAULT_PORT" DATA_DIR="$HOME/.waggle" BUILD_WEB=1 START_NOW=1 BRANCH="main" ASSUME_YES=0 LOCAL_SOURCE="" # ── Pretty output (disabled when not a TTY) ─────────────────────────────────── if [ -t 1 ]; then C_BOLD="$(printf '\033[1m')"; C_DIM="$(printf '\033[2m')" C_GREEN="$(printf '\033[32m')"; C_YELLOW="$(printf '\033[33m')" C_RED="$(printf '\033[31m')"; C_RESET="$(printf '\033[0m')" else C_BOLD=""; C_DIM=""; C_GREEN=""; C_YELLOW=""; C_RED=""; C_RESET="" fi say() { echo "${C_BOLD}==>${C_RESET} $*"; } info() { echo " $*"; } warn() { echo "${C_YELLOW}warning:${C_RESET} $*" >&2; } die() { echo "${C_RED}error:${C_RESET} $*" >&2; exit 1; } # True when $1 is a decimal integer within the valid TCP port range (1-65535). valid_port() { case "$1" in ''|*[!0-9]*) return 1 ;; esac [ "$1" -ge 1 ] && [ "$1" -le 65535 ] } on_interrupt() { # Restore sane terminal state if a wizard read was interrupted, then abort. if [ -e /dev/tty ]; then stty sane /dev/null 2>&1 || true; fi echo die "Aborted by user." } trap on_interrupt INT # ── Arg parsing ─────────────────────────────────────────────────────────────── while [ $# -gt 0 ]; do case "$1" in --yes|-y) ASSUME_YES=1; shift ;; --dir) INSTALL_DIR="${2:?--dir needs a value}"; shift 2 ;; --dir=*) INSTALL_DIR="${1#*=}"; shift ;; --port) PORT="${2:?--port needs a value}"; shift 2 ;; --port=*) PORT="${1#*=}"; shift ;; --data-dir) DATA_DIR="${2:?--data-dir needs a value}"; shift 2 ;; --data-dir=*) DATA_DIR="${1#*=}"; shift ;; --no-web) BUILD_WEB=0; shift ;; --no-start) START_NOW=0; shift ;; --branch) BRANCH="${2:?--branch needs a value}"; shift 2 ;; --branch=*) BRANCH="${1#*=}"; shift ;; --local-source) LOCAL_SOURCE="${2:?--local-source needs a value}"; shift 2 ;; --local-source=*) LOCAL_SOURCE="${1#*=}"; shift ;; -h|--help) sed -n '2,40p' "$0" | sed 's/^# \{0,1\}//' exit 0 ;; *) die "Unknown option: $1 (try --help)" ;; esac done valid_port "$PORT" || die "Invalid --port '${PORT}': must be an integer between 1 and 65535." # ── OS detection (for install hints; not a hard gate) ───────────────────────── UNAME_S="$(uname -s 2>/dev/null || echo unknown)" case "$UNAME_S" in Linux) OS="linux" ;; Darwin) OS="macos" ;; *) OS="other" ;; esac # Per-OS install command hint for a missing tool. install_hint() { local tool="$1" case "$OS" in linux) echo "sudo apt install ${tool} # or: sudo dnf install ${tool}" ;; macos) echo "brew install ${tool}" ;; *) echo "install ${tool} using your platform's package manager" ;; esac } # ── Preflight ───────────────────────────────────────────────────────────────── preflight() { say "Checking prerequisites" if [ "${BASH_VERSINFO:-0}" -lt 4 ]; then warn "bash ${BASH_VERSION} is old; this script targets bash >= 4 but avoids 4-only features. Continuing." fi if [ "$OS" = "other" ]; then warn "Unsupported OS '${UNAME_S}'. Official support is Linux and macOS; proceeding best-effort." fi command -v git >/dev/null 2>&1 || die "git is required. Install it: $(install_hint git)" if ! command -v node >/dev/null 2>&1; then die "Node.js >= ${MIN_NODE_MAJOR} is required. Install it: $(install_hint nodejs) or use nvm: https://github.com/nvm-sh/nvm" fi local node_major node_major="$(node -v 2>/dev/null | sed -E 's/^v([0-9]+).*/\1/')" if [ -z "$node_major" ] || [ "$node_major" -lt "$MIN_NODE_MAJOR" ]; then die "Node.js >= ${MIN_NODE_MAJOR} required, found $(node -v 2>/dev/null || echo none). Upgrade Node (nvm install ${MIN_NODE_MAJOR}) and re-run." fi command -v npm >/dev/null 2>&1 || die "npm is required (ships with Node.js). Re-install Node.js: $(install_hint nodejs)" # C++ toolchain: better-sqlite3 falls back to compiling from source when no # prebuilt binary matches. Warn only — a prebuilt often exists. if ! command -v cc >/dev/null 2>&1 && ! command -v gcc >/dev/null 2>&1 && ! command -v clang >/dev/null 2>&1; then warn "No C/C++ compiler found. If native modules (better-sqlite3) fail to install, install build tools: $( [ "$OS" = macos ] && echo 'xcode-select --install' || echo 'sudo apt install build-essential python3' )" fi info "git $(git --version | awk '{print $3}') · node $(node -v) · npm $(npm -v)" } # ── Acquire the source tree into INSTALL_DIR ────────────────────────────────── # git ls-files enumerates tracked + untracked-but-not-ignored files, so a # --local-source copy includes uncommitted work (this installer, the wrapper # script) yet excludes node_modules/dist/.git the same way a clone would. acquire_source() { if [ -n "$LOCAL_SOURCE" ]; then [ -d "$LOCAL_SOURCE" ] || die "--local-source path does not exist: ${LOCAL_SOURCE}" say "Copying source from ${LOCAL_SOURCE}" mkdir -p "$INSTALL_DIR" if command -v git >/dev/null 2>&1 && git -C "$LOCAL_SOURCE" rev-parse --show-toplevel >/dev/null 2>&1; then ( cd "$LOCAL_SOURCE" && git ls-files -z --cached --others --exclude-standard ) \ | ( cd "$LOCAL_SOURCE" && tar --null -T - -cf - ) \ | ( cd "$INSTALL_DIR" && tar -xf - ) else # Non-git source: copy everything except the heavy/regenerated dirs. tar -C "$LOCAL_SOURCE" --exclude=node_modules --exclude=dist --exclude=.git -cf - . \ | tar -C "$INSTALL_DIR" -xf - fi else say "Cloning ${REPO_URL} (branch ${BRANCH})" git clone --depth 1 --branch "$BRANCH" "$REPO_URL" "$INSTALL_DIR" fi } # ── Wizard (D4) ─────────────────────────────────────────────────────────────── # Reads from /dev/tty so `curl | bash` (stdin = the pipe) can still prompt. When # non-interactive (--yes, or no controlling terminal), every answer takes its # default silently. ask() { # ask local __var="$1" __prompt="$2" __default="$3" __reply="" if [ "$ASSUME_YES" -eq 1 ] || [ ! -e /dev/tty ]; then printf -v "$__var" '%s' "$__default" return fi # Relax errexit around the interactive read (EOF/empty must fall to default). set +e read -r -p "$(printf '%s [%s]: ' "$__prompt" "$__default")" __reply local __var="$1" __prompt="$2" __default="$3" __reply="" __hint [ "$__default" -eq 1 ] && __hint="Y/n" || __hint="y/N" if [ "$ASSUME_YES" -eq 1 ] || [ ! -e /dev/tty ]; then printf -v "$__var" '%s' "$__default" return fi set +e read -r -p "$(printf '%s [%s]: ' "$__prompt" "$__hint")" __reply "$errfile"; then info "$(cat "$errfile" 2>/dev/null || true) — OK" rm -f "$errfile" 2>/dev/null || true return 0 fi local err; err="$(cat "$errfile" 2>/dev/null || true)" rm -f "$errfile" 2>/dev/null || true warn "Native SQLite/sqlite-vec check failed: ${err} Remedies: - Point WAGGLE_SQLITE_VEC_PATH at a sqlite-vec loadable extension for your platform, then re-run. - Ensure a C/C++ toolchain is installed so better-sqlite3 can build: $( [ "$OS" = macos ] && echo 'xcode-select --install' || echo 'sudo apt install build-essential python3' ) - Re-run: cd ${INSTALL_DIR} && npm install" return 1 } # ── Persist the install marker (D5/D6) ──────────────────────────────────────── # Injection-safe: the port/data-dir/branch answers cross into the file only as # environment variables consumed by node's JSON.stringify — never interpolated # into shell or file text. write_marker() { local marker="$INSTALL_DIR/$MARKER_NAME" if [ -f "$marker" ]; then local backup backup="${marker}.$(date +%Y%m%d%H%M%S).bak" cp "$marker" "$backup" 2>/dev/null || true info "Backed up existing marker to ${backup}" fi WAGGLE_M_PORT="$PORT" \ WAGGLE_M_DATADIR="$DATA_DIR" \ WAGGLE_M_BRANCH="$BRANCH" \ node -e ' const fs = require("fs"); const out = { installedAt: new Date().toISOString(), port: process.env.WAGGLE_M_PORT, dataDir: process.env.WAGGLE_M_DATADIR, branch: process.env.WAGGLE_M_BRANCH, }; fs.writeFileSync(process.argv[1], JSON.stringify(out, null, 2) + "\n"); ' "$marker" } # ── Success card ────────────────────────────────────────────────────────────── success_card() { local mgr="$INSTALL_DIR/scripts/waggle-server.sh" echo echo "${C_GREEN}${C_BOLD} Waggle OS is installed.${C_RESET}" echo if [ "$START_NOW" -eq 1 ]; then echo " ${C_BOLD}Open:${C_RESET} http://127.0.0.1:${PORT}" else echo " ${C_BOLD}Start:${C_RESET} bash ${mgr} start --port ${PORT} --data-dir ${DATA_DIR}" echo " ${C_BOLD}Then open:${C_RESET} http://127.0.0.1:${PORT}" fi echo echo " ${C_DIM}Runs in echo mode with zero keys. Add an API key in Settings > API Keys${C_RESET}" echo " ${C_DIM}to enable real models. Connect Slack/Telegram/WhatsApp/Discord under${C_RESET}" echo " ${C_DIM}Settings > Channels to reach your agent from anywhere.${C_RESET}" echo echo " ${C_BOLD}Manage the server:${C_RESET}" echo " bash ${mgr} status" echo " bash ${mgr} logs" echo " bash ${mgr} stop" echo } # ── Main ────────────────────────────────────────────────────────────────────── main() { echo "${C_BOLD}Waggle OS installer${C_RESET}" preflight run_wizard local marker="$INSTALL_DIR/$MARKER_NAME" # D5 — 3-way idempotent directory branch. if [ -d "$INSTALL_DIR" ] && [ -f "$marker" ]; then say "Already installed at ${INSTALL_DIR}" info "To update: cd ${INSTALL_DIR} && git pull && npm install && npm run build:packages$( [ "$BUILD_WEB" -eq 1 ] && echo ' && npm run build' )" info "To run: bash ${INSTALL_DIR}/scripts/waggle-server.sh start --port ${PORT} --data-dir ${DATA_DIR}" info "To reinstall fresh: remove ${marker} (or the whole directory) and re-run." exit 0 elif [ -d "$INSTALL_DIR" ] && [ -n "$(ls -A "$INSTALL_DIR" 2>/dev/null)" ]; then say "Resuming a partial install in ${INSTALL_DIR} (directory exists, no marker)" if [ -n "$LOCAL_SOURCE" ]; then info "Refreshing files from ${LOCAL_SOURCE}" acquire_source else info "Skipping clone; using existing directory contents." fi else acquire_source fi say "Installing dependencies (this can take a few minutes)" ( cd "$INSTALL_DIR" && npm install --no-audit --no-fund ) say "Building packages" ( cd "$INSTALL_DIR" && npm run build:packages ) if [ "$BUILD_WEB" -eq 1 ]; then say "Building web UI" ( cd "$INSTALL_DIR" && npm run build ) else info "Skipping web UI build (--no-web); the UI serves in API/echo mode only." fi verify_runtime || warn "Continuing despite the SQLite check — the server may still fail to open its database." write_marker if [ "$START_NOW" -eq 1 ]; then say "Starting the server" bash "$INSTALL_DIR/scripts/waggle-server.sh" start --port "$PORT" --data-dir "$DATA_DIR" fi success_card } main