#!/usr/bin/env bash # Panguard AI Installer # Usage: curl -fsSL https://get.panguard.ai | bash # or: bash install.sh # # Downloads a prebuilt binary from GitHub Releases. # Falls back to source build if no prebuilt binary is available. # # Functions are defined inline so the script is self-contained (works # when piped via curl|bash). A matching install-functions.sh contains # only the function definitions for unit-testing. set -euo pipefail # ── Repository & Release URLs ──────────────────────────────────── REPO_URL="https://github.com/panguard-ai/panguard-ai" RELEASE_BASE="https://github.com/panguard-ai/panguard-ai/releases" FALLBACK_URL="https://github.com/panguard-ai/panguard-ai/archive/refs/heads/main.zip" # ── Install paths ──────────────────────────────────────────────── INSTALL_DIR="${HOME}/.panguard" BIN_DIR="${HOME}/.local/bin" SYMLINK_TARGET="/usr/local/bin/panguard" MIN_NODE_VERSION=20 # ── Colors ─────────────────────────────────────────────────────── : "${RED:='\033[0;31m'}" : "${GREEN:='\033[0;32m'}" : "${YELLOW:='\033[1;33m'}" : "${BLUE:='\033[0;34m'}" : "${BOLD:='\033[1m'}" : "${DIM:='\033[2m'}" : "${NC:='\033[0m'}" # ── Logging helpers ────────────────────────────────────────────── info() { printf "${BLUE}[INFO]${NC} %s\n" "$1"; } success() { printf "${GREEN}[ OK ]${NC} %s\n" "$1"; } warn() { printf "${YELLOW}[WARN]${NC} %s\n" "$1"; } fail() { printf "${RED}[FAIL]${NC} %s\n" "$1" >&2; exit 1; } # ── print_header() ─────────────────────────────────────────────── print_header() { echo "" echo -e " ${BOLD}Panguard AI Installer${NC}" echo -e " ${DIM}=====================${NC}" echo "" echo -e " ${DIM}The App Store Gatekeeper for AI Agents${NC}" echo "" } # ── detect_platform() ─────────────────────────────────────────── # Sets: PLATFORM_OS, PLATFORM_ARCH, PLATFORM # Returns 0 on success, exits 1 on unsupported OS/arch. detect_platform() { local os arch os="$(uname -s)" arch="$(uname -m)" SKIP_BINARY_DOWNLOAD=false info "Detected: ${os} ${arch}" case "$os" in Darwin) PLATFORM_OS="darwin" ;; Linux) PLATFORM_OS="linux" ;; MINGW*|MSYS*|CYGWIN*) warn "Windows detected. Please use WSL (Windows Subsystem for Linux) for full functionality." PLATFORM_OS="linux" ;; *) fail "Unsupported OS: ${os}. Panguard AI supports macOS and Linux." ;; esac case "$arch" in x86_64|amd64) PLATFORM_ARCH="x64" ;; arm64|aarch64) PLATFORM_ARCH="arm64" ;; *) fail "Unsupported architecture: ${arch}. Panguard supports x86_64 and arm64. Report at ${REPO_URL}/issues" ;; esac PLATFORM="${PLATFORM_OS}-${PLATFORM_ARCH}" info "Platform: ${PLATFORM}" return 0 } # ── detect_musl() ──────────────────────────────────────────────── # Sets SKIP_BINARY_DOWNLOAD=true if musl libc is detected. # Returns 0 if musl detected, 1 otherwise. detect_musl() { if [ "$PLATFORM_OS" = "linux" ]; then # Check multiple indicators for musl libc if (ldd --version 2>&1 | grep -qi musl) || [ -f /etc/alpine-release ] || ls /lib/ld-musl-*.so* &>/dev/null; then warn "Detected Alpine/musl Linux. Prebuilt binaries may not work." info "Will try npm install instead of prebuilt binary." SKIP_BINARY_DOWNLOAD=true return 0 fi fi return 1 } # ── check_node_version() ──────────────────────────────────────── # Takes optional min_version arg (default: $MIN_NODE_VERSION). # Exits 1 with message if node is missing or too old. check_node_version() { local min_ver="${1:-$MIN_NODE_VERSION}" if ! command -v node &>/dev/null; then fail "Node.js is required but not installed. Install v${min_ver}+ from https://nodejs.org" fi NODE_OUTPUT=$(node -v 2>/dev/null || echo "unknown") NODE_VERSION=$(echo "$NODE_OUTPUT" | grep -oE '^v?[0-9]+' | grep -oE '[0-9]+' || echo "0") if ! [[ "$NODE_VERSION" =~ ^[0-9]+$ ]] || [ "$NODE_VERSION" -lt "$min_ver" ]; then fail "Node.js v${min_ver}+ is required. Current: ${NODE_OUTPUT}. Please upgrade at https://nodejs.org" fi success "Node.js ${NODE_OUTPUT}" } # ── backup_existing() ─────────────────────────────────────────── backup_existing() { if [ -f "${INSTALL_DIR}/bin/panguard" ]; then local existing_ver existing_ver=$("${INSTALL_DIR}/bin/panguard" --version 2>/dev/null || echo "unknown") info "Existing installation found: v${existing_ver}. Creating backup..." mv "$INSTALL_DIR" "${INSTALL_DIR}.backup.$(date +%s)" elif [ -d "${INSTALL_DIR}" ]; then # Preserve Guard data (auth.db, config, etc.) during npm upgrades info "Existing data directory found. Creating backup..." mv "$INSTALL_DIR" "${INSTALL_DIR}.backup.$(date +%s)" fi # Clean up old backups (keep only last 3) # shellcheck disable=SC2012 ls -td "${INSTALL_DIR}.backup."* 2>/dev/null | tail -n +4 | xargs rm -rf 2>/dev/null || true } # ── download_file() ───────────────────────────────────────────── # Usage: download_file # Uses curl or wget. Exits 1 on failure. download_file() { local url="$1" local dest="$2" if command -v curl &>/dev/null; then local http_code http_code=$(curl -fsSL -w "%{http_code}" -o "$dest" "$url" 2>/dev/null) || { fail "curl failed to download: ${url}" } if [ "$http_code" -ge 400 ]; then fail "Download failed with HTTP ${http_code}: ${url}" fi elif command -v wget &>/dev/null; then wget -q -O "$dest" "$url" 2>/dev/null || fail "wget failed to download: ${url}" else fail "Neither curl nor wget found. Install one to proceed." fi } # ── verify_checksum() ─────────────────────────────────────────── # Usage: verify_checksum # Returns 0 on success (verified or skipped), exits 1 on mismatch. verify_checksum() { local file_path="$1" local checksums_file="$2" local expected_name="$3" if command -v sha256sum &>/dev/null; then local expected_hash expected_hash=$(grep "$expected_name" "$checksums_file" 2>/dev/null | awk '{print $1}') if [ -n "$expected_hash" ]; then local actual_hash actual_hash=$(sha256sum "$file_path" | awk '{print $1}') if [ "$actual_hash" = "$expected_hash" ]; then success "Checksum verified" return 0 else fail "Checksum mismatch for ${expected_name}. Expected: ${expected_hash} Got: ${actual_hash}" fi else warn "No checksum entry found for ${expected_name} in SHA256SUMS.txt. Skipping verification." return 0 fi elif command -v shasum &>/dev/null; then local expected_hash expected_hash=$(grep "$expected_name" "$checksums_file" 2>/dev/null | awk '{print $1}') if [ -n "$expected_hash" ]; then local actual_hash actual_hash=$(shasum -a 256 "$file_path" | awk '{print $1}') if [ "$actual_hash" = "$expected_hash" ]; then success "Checksum verified" return 0 else fail "Checksum mismatch for ${expected_name}. Expected: ${expected_hash} Got: ${actual_hash}" fi else warn "No checksum entry found for ${expected_name} in SHA256SUMS.txt. Skipping verification." return 0 fi else warn "Neither sha256sum nor shasum found. Skipping checksum verification." return 0 fi } # ── download_binary() ─────────────────────────────────────────── # Downloads prebuilt binary, verifies checksum, and extracts. # Returns 0 on success, 1 if binary download was skipped. download_binary() { if [ "$SKIP_BINARY_DOWNLOAD" = "true" ]; then return 1 fi local version="${PANGUARD_VERSION:-latest}" local download_url checksum_url if [ "$version" = "latest" ]; then download_url="${RELEASE_BASE}/latest/download/panguard-${PLATFORM}.tar.gz" checksum_url="${RELEASE_BASE}/latest/download/SHA256SUMS.txt" else download_url="${RELEASE_BASE}/download/${version}/panguard-${PLATFORM}.tar.gz" checksum_url="${RELEASE_BASE}/download/${version}/SHA256SUMS.txt" fi info "Downloading prebuilt binary for ${PLATFORM}..." info "URL: ${download_url}" mkdir -p "$INSTALL_DIR" local tmp_tarball tmp_tarball="$(mktemp /tmp/panguard-XXXXXX.tar.gz)" local tmp_checksums tmp_checksums="$(mktemp /tmp/panguard-SHA256SUMS-XXXXXX.txt)" # Trap to clean up temp files on exit/error trap 'rm -f "$tmp_tarball" "$tmp_checksums"' EXIT # Download the tarball to a temp file (non-fatal — fallback to npm if unavailable) if ! curl -fsSL -o "$tmp_tarball" "$download_url" 2>/dev/null; then info "Prebuilt binary not available for ${PLATFORM} (download failed)." rm -f "$tmp_tarball" "$tmp_checksums" return 1 fi # Verify tarball is non-empty if [ ! -s "$tmp_tarball" ]; then info "Prebuilt binary not available (empty response)." rm -f "$tmp_tarball" "$tmp_checksums" return 1 fi # Attempt checksum verification local tarball_name="panguard-${PLATFORM}.tar.gz" if download_file "$checksum_url" "$tmp_checksums" 2>/dev/null; then verify_checksum "$tmp_tarball" "$tmp_checksums" "$tarball_name" else warn "SHA256SUMS.txt not available for this release. Skipping checksum verification." fi # Extract the verified tarball if ! tar -xzf "$tmp_tarball" -C "$INSTALL_DIR" 2>/dev/null; then fail "Failed to extract tarball. The file may be corrupted: ${download_url}" fi rm -f "$tmp_tarball" "$tmp_checksums" trap - EXIT return 0 } # ── clone_from_git() ───────────────────────────────────────────── clone_from_git() { local url="$1" local dest="$2" info "Cloning from ${url}..." git clone --depth 1 "$url" "$dest" 2>/dev/null } # ── clone_from_zip() ──────────────────────────────────────────── clone_from_zip() { local dest="$1" info "Downloading source archive from ${FALLBACK_URL}..." local tmp_zip tmp_zip="$(mktemp /tmp/panguard-src-XXXXXX.zip)" download_file "$FALLBACK_URL" "$tmp_zip" if ! command -v unzip &>/dev/null; then rm -f "$tmp_zip" fail "unzip is required to extract source archive. Install unzip and try again." fi local tmp_extract tmp_extract="$(mktemp -d /tmp/panguard-src-extract-XXXXXX)" unzip -q "$tmp_zip" -d "$tmp_extract" || fail "Failed to extract source archive." # The zip produces a single top-level directory; move its contents to dest local extracted_dir extracted_dir="$(ls -d "${tmp_extract}"/*/ 2>/dev/null | head -1)" if [ -z "$extracted_dir" ]; then rm -rf "$tmp_zip" "$tmp_extract" fail "Unexpected structure in source archive." fi mkdir -p "$(dirname "$dest")" mv "$extracted_dir" "$dest" rm -rf "$tmp_zip" "$tmp_extract" success "Source archive extracted to ${dest}" } # ── build_from_source() ───────────────────────────────────────── build_from_source() { local source_dir="${INSTALL_DIR}/source" # Check git if ! command -v git &>/dev/null; then fail "git is required for source build. Install git from https://git-scm.com" fi success "git $(git --version | awk '{print $3}')" # Check pnpm (install if missing) if ! command -v pnpm &>/dev/null; then info "pnpm not found. Installing pnpm globally via npm..." if ! command -v npm &>/dev/null; then fail "npm is required to install pnpm but was not found." fi npm install -g pnpm || fail "Failed to install pnpm. Try running: npm install -g pnpm" if ! command -v pnpm &>/dev/null; then fail "pnpm was installed but cannot be found in PATH. You may need to restart your shell." fi success "pnpm installed successfully" else success "pnpm $(pnpm --version)" fi # Clone or update repository if [ -d "$source_dir/.git" ]; then info "Existing source found. Updating..." cd "$source_dir" git fetch origin 2>/dev/null || warn "git fetch failed, continuing with existing code" git reset --hard origin/main 2>/dev/null || git reset --hard origin/master 2>/dev/null || warn "git reset failed" success "Repository updated" else [ -d "$source_dir" ] && rm -rf "$source_dir" mkdir -p "$(dirname "$source_dir")" if ! clone_from_git "${REPO_URL}.git" "$source_dir"; then warn "git clone failed. Trying source archive fallback..." clone_from_zip "$source_dir" else success "Repository cloned to ${source_dir}" fi fi # Build cd "$source_dir" info "Installing dependencies (this may take a minute)..." pnpm install --frozen-lockfile 2>/dev/null || pnpm install || fail "Failed to install dependencies" success "Dependencies installed" info "Building project..." pnpm build || fail "Build failed. Please report this issue at ${REPO_URL}/issues" success "Build complete" } # ── setup_path() ───────────────────────────────────────────────── # Usage: setup_path # Creates a symlink or falls back to PATH addition. setup_path() { local bin_source="${1}" local symlink_target="${2}" local bin_dir="${3}" if [ ! -f "$bin_source" ]; then fail "CLI entry point not found at ${bin_source}. The build may have failed." fi chmod +x "$bin_source" # Prefer user-local bin dir (no sudo required) over system-wide symlink local symlink_created=false # First try user-local ~/.local/bin (no sudo) # NOTE: We create a wrapper script instead of a symlink because the # bin/panguard launcher uses dirname($0) to locate its install dir. # A symlink would resolve to ~/.local/bin → wrong SCRIPT_DIR. if mkdir -p "$bin_dir" 2>/dev/null; then printf '#!/usr/bin/env bash\nexec "%s" "$@"\n' "$bin_source" > "${bin_dir}/panguard" 2>/dev/null chmod +x "${bin_dir}/panguard" 2>/dev/null if [ -x "${bin_dir}/panguard" ]; then success "Installed to ${bin_dir}/panguard (no sudo required)" symlink_created=true # Ensure ~/.local/bin is in PATH (many systems don't include it by default) if ! echo "$PATH" | tr ':' '\n' | grep -qx "$bin_dir" 2>/dev/null; then local export_line="export PATH=\"${bin_dir}:\$PATH\"" local profiles_updated=() for profile in "${HOME}/.zshrc" "${HOME}/.zprofile" "${HOME}/.bashrc" "${HOME}/.bash_profile" "${HOME}/.profile"; do if [ -f "$profile" ]; then if ! grep -qF "$bin_dir" "$profile" 2>/dev/null; then printf '\n# Added by Panguard AI installer\n%s\n' "$export_line" >> "$profile" profiles_updated+=("$profile") fi fi done # On macOS with zsh, create .zprofile if no profile was found if [ "${#profiles_updated[@]}" -eq 0 ]; then local default_profile if [ "$(uname -s)" = "Darwin" ]; then default_profile="${HOME}/.zprofile" else default_profile="${HOME}/.bashrc" fi printf '\n# Added by Panguard AI installer\n%s\n' "$export_line" >> "$default_profile" profiles_updated+=("$default_profile") fi if [ "${#profiles_updated[@]}" -gt 0 ]; then info "Added ${bin_dir} to PATH in: ${profiles_updated[*]}" # Export for current session so verify step works export PATH="${bin_dir}:$PATH" fi fi fi fi # If user-local failed, try system-wide wrapper at /usr/local/bin if [ "$symlink_created" = "false" ]; then info "Creating wrapper at ${symlink_target}..." local wrapper_content wrapper_content="$(printf '#!/usr/bin/env bash\nexec "%s" "$@"\n' "$bin_source")" if printf '%s' "$wrapper_content" > "$symlink_target" 2>/dev/null && chmod +x "$symlink_target" 2>/dev/null; then success "Wrapper created at ${symlink_target}" symlink_created=true else warn "Permission denied. Requesting sudo..." if printf '%s' "$wrapper_content" | sudo tee "$symlink_target" >/dev/null 2>&1 && sudo chmod +x "$symlink_target" 2>/dev/null; then success "Wrapper created at ${symlink_target} (with sudo)" symlink_created=true fi fi fi if [ "$symlink_created" = "false" ]; then warn "Could not create wrapper. Adding ${bin_dir} to shell profiles..." mkdir -p "$bin_dir" printf '#!/usr/bin/env bash\nexec "%s" "$@"\n' "$bin_source" > "${bin_dir}/panguard" 2>/dev/null && \ chmod +x "${bin_dir}/panguard" 2>/dev/null || \ warn "Could not create wrapper at ${bin_dir}/panguard." local export_line="export PATH=\"${bin_dir}:\$PATH\"" local profiles_updated=() for profile in "${HOME}/.zshrc" "${HOME}/.zprofile" "${HOME}/.bashrc" "${HOME}/.bash_profile" "${HOME}/.profile"; do if [ -f "$profile" ]; then if ! grep -qF "$bin_dir" "$profile" 2>/dev/null; then printf '\n# Added by Panguard AI installer\n%s\n' "$export_line" >> "$profile" profiles_updated+=("$profile") fi fi done # On macOS with zsh, create .zprofile if no profile was found if [ "${#profiles_updated[@]}" -eq 0 ]; then local default_profile if [ "$(uname -s)" = "Darwin" ]; then default_profile="${HOME}/.zprofile" else default_profile="${HOME}/.bashrc" fi printf '\n# Added by Panguard AI installer\n%s\n' "$export_line" >> "$default_profile" profiles_updated+=("$default_profile") info "Created ${default_profile} with PATH entry" fi if [ "${#profiles_updated[@]}" -gt 0 ]; then success "Added ${bin_dir} to PATH in: ${profiles_updated[*]}" warn "Restart your terminal (or run: source ~/.zprofile / source ~/.bashrc) for PATH changes to take effect." else warn "Could not update any shell profile. Add the following line manually:" echo "" echo " ${export_line}" echo "" fi fi } # ── verify_installation() ─────────────────────────────────────── verify_installation() { local bin_source="$1" echo "" info "Verifying installation..." if command -v panguard &>/dev/null; then local installed_ver installed_ver=$(panguard --version 2>/dev/null || echo "") if [ -n "$installed_ver" ]; then success "panguard v${installed_ver} installed and verified" else fail "panguard is in PATH but cannot execute. Check Node.js installation." fi elif [ -x "$bin_source" ]; then local installed_ver installed_ver=$("$bin_source" --version 2>/dev/null || echo "") if [ -n "$installed_ver" ]; then success "panguard v${installed_ver} installed (via direct path)" else fail "panguard binary exists but cannot execute. Check Node.js installation." fi else fail "Installation verification failed. Binary not found." fi } # ── print_quickstart() ────────────────────────────────────────── print_quickstart() { echo "" echo -e " ${BOLD}Quick Start${NC}" echo -e " ${DIM}===========${NC}" echo "" echo " # Connect to Claude Code, Cursor, and other AI agents" echo " panguard setup" echo "" echo " # Audit installed AI skills for security threats" echo " panguard audit skill ." echo "" echo " # Run a security scan" echo " panguard scan" echo "" echo " # Start 24/7 real-time protection" echo " panguard guard start" echo "" echo -e " ${DIM}Documentation: https://panguard.ai/docs${NC}" echo -e " ${DIM}Report issues: ${REPO_URL}/issues${NC}" echo "" success "Installation complete!" } # ── detect_lang() ──────────────────────────────────────────── # Detect system language. Returns "zh-TW" for Chinese systems, "en" otherwise. detect_lang() { local sys_lang="${LANG:-${LC_ALL:-}}" case "$sys_lang" in zh*|Chinese*) echo "zh-TW" ;; *) echo "en" ;; esac } # ── spinner() ──────────────────────────────────────────────── # Show a spinner animation while a background process runs. # Usage: spinner [timeout_seconds] # Times out after timeout_seconds (default 60) to prevent hanging. spinner() { local pid="$1" msg="$2" timeout="${3:-60}" local i=0 start_time start_time=$(date +%s) # In pipe/non-TTY: just print once and wait silently if [ ! -t 1 ]; then printf " ${BLUE}[SCAN]${NC} %s...\n" "$msg" while kill -0 "$pid" 2>/dev/null; do local elapsed=$(( $(date +%s) - start_time )) if [ "$elapsed" -ge "$timeout" ]; then kill -9 "$pid" 2>/dev/null || true wait "$pid" 2>/dev/null || true printf " ${YELLOW}[WARN]${NC} %s (timed out after %ds)\n" "$msg" "$timeout" return 1 fi sleep 1 done wait "$pid" 2>/dev/null || true printf " ${GREEN}[ OK ]${NC} %s\n" "$msg" return 0 fi # TTY: animated spinner local frames=('.' '..' '...' '....' '.....') while kill -0 "$pid" 2>/dev/null; do local elapsed=$(( $(date +%s) - start_time )) if [ "$elapsed" -ge "$timeout" ]; then kill -9 "$pid" 2>/dev/null || true wait "$pid" 2>/dev/null || true printf "\r ${YELLOW}[WARN]${NC} %s (timed out after %ds)\n" "$msg" "$timeout" return 1 fi printf "\r ${BLUE}[SCAN]${NC} %s%s " "$msg" "${frames[$((i % ${#frames[@]}))]}" i=$((i + 1)) sleep 0.4 done wait "$pid" 2>/dev/null || true printf "\r ${GREEN}[ OK ]${NC} %s \n" "$msg" return 0 } # ── open_browser() ─────────────────────────────────────────── # Cross-platform browser opener: macOS, Linux, WSL, headless. # Returns 0 if browser opened, 1 if headless/no browser. # # IMPORTANT: When run via `curl | bash`, stdin is a pipe and the shell # environment is non-interactive. Browser-open commands may silently # fail or no-op in this context. We use `nohup ... /dev/null 2>&1 & return 0 fi # WSL (check before xdg-open — WSL may have xdg-open but it won't work) if [ -n "${WSL_DISTRO_NAME:-}" ]; then if command -v wslview &>/dev/null; then nohup wslview "$url" /dev/null 2>&1 & return 0 elif command -v explorer.exe &>/dev/null; then explorer.exe "$url" /dev/null 2>&1 & return 0 fi fi # Linux with display server if [ -n "${DISPLAY:-}" ] || [ -n "${WAYLAND_DISPLAY:-}" ]; then if command -v xdg-open &>/dev/null; then nohup xdg-open "$url" /dev/null 2>&1 & return 0 fi fi # Headless / SSH / Docker — no browser available return 1 } # ── auto_setup() ────────────────────────────────────────────── # Zero-interaction post-install: setup → guard → scan → dashboard. # No questions asked. Scan runs visibly, dashboard opens. auto_setup() { local DASHBOARD_PORT=3100 local DASHBOARD_URL="http://127.0.0.1:${DASHBOARD_PORT}" local UI_LANG UI_LANG="$(detect_lang 2>/dev/null || echo "en")" # 1. Connect AI agents echo "" info "Configuring AI agent protection..." if ! panguard setup --yes --skip-scan 2>/dev/null; then warn "Agent setup had issues. Continuing with Guard startup..." fi # 2. Start Guard (handle already-running gracefully) # IMPORTANT: `panguard guard start` is a BLOCKING foreground process # that never exits (it monitors). We MUST background it and NOT wait. echo "" if panguard guard status 2>/dev/null | grep -q "RUNNING"; then info "Guard is already running." else info "Starting Guard with dashboard..." # Background and detach — do NOT wait (it runs forever) nohup panguard guard start --dashboard >/dev/null 2>&1 & disown 2>/dev/null || true fi # Wait for dashboard to be ready (up to 15s) local attempts=0 dashboard_ready=false while [ $attempts -lt 15 ]; do if curl -sf "${DASHBOARD_URL}" >/dev/null 2>&1; then dashboard_ready=true break fi sleep 1 attempts=$((attempts + 1)) done if [ "$dashboard_ready" = "false" ]; then warn "Dashboard not responding on port ${DASHBOARD_PORT}." # Try to detect actual port from Guard status local actual_port actual_port=$(panguard guard status 2>/dev/null | grep -oE 'localhost:[0-9]+' | grep -oE '[0-9]+' || echo "") if [ -n "$actual_port" ] && [ "$actual_port" != "$DASHBOARD_PORT" ]; then DASHBOARD_PORT="$actual_port" DASHBOARD_URL="http://127.0.0.1:${DASHBOARD_PORT}" info "Dashboard detected on port ${DASHBOARD_PORT}." fi fi # 3. Run scan with spinner (user sees progress, 60s timeout) echo "" panguard scan --quick >/dev/null 2>&1 & local scan_pid=$! if [ "$UI_LANG" = "zh-TW" ]; then spinner "$scan_pid" "Scanning AI agent configurations / 正在掃描 AI 代理設定" 60 else spinner "$scan_pid" "Scanning AI agent configurations" 60 fi # 4. Open dashboard in browser (cross-platform) local browser_opened=false if open_browser "${DASHBOARD_URL}"; then browser_opened=true fi # 5. Final status echo "" if [ "$browser_opened" = "true" ]; then success "Done! Dashboard opened in your browser." else success "Done! Open the dashboard manually:" fi echo "" echo -e " Dashboard: ${BLUE}${DASHBOARD_URL}${NC}" echo " Guard: running (learning mode, day 1/7)" echo " ATR rules: 61 detection rules loaded" echo " Scan: complete" echo "" if [ "$UI_LANG" = "zh-TW" ]; then echo -e " ${BOLD}Panguard 已安裝完成。Guard 正在背景運行。${NC}" echo " 所有偵測到的 AI 平台已自動設定。掃描完成。" else echo -e " ${BOLD}Panguard is installed and protecting your AI agents.${NC}" echo " All detected AI platforms configured. Scan complete." fi echo "" echo " Other commands:" echo " panguard audit skill Audit a skill before installing" echo " panguard scan Full security scan" echo " panguard guard status Check Guard status" echo " panguard guard stop Stop Guard" echo "" } # ── Main ───────────────────────────────────────────────────────── main() { print_header detect_platform detect_musl || true # musl detection is informational, not fatal check_node_version backup_existing BINARY_INSTALLED=false NPM_INSTALLED=false # Strategy: npm first (fastest & most reliable), then prebuilt binary, then source build if command -v npm &>/dev/null; then info "Installing via npm..." if npm install -g @panguard-ai/panguard 2>/dev/null; then success "Panguard AI installed via npm" NPM_INSTALLED=true else warn "npm global install failed. Trying prebuilt binary..." fi fi if [ "$NPM_INSTALLED" = "false" ]; then if download_binary; then success "Prebuilt binary installed to ${INSTALL_DIR}" BINARY_INSTALLED=true else warn "No prebuilt binary available for ${PLATFORM}. Falling back to source build..." build_from_source fi fi # Determine binary location local bin_source if [ "$BINARY_INSTALLED" = "true" ]; then bin_source="${INSTALL_DIR}/bin/panguard" elif [ "$NPM_INSTALLED" = "true" ]; then # npm global install: find the binary via command -v, npm prefix, or common paths bin_source="$(command -v panguard 2>/dev/null || echo "")" if [ -z "$bin_source" ]; then # Not in PATH yet — check npm global prefix local npm_prefix npm_prefix="$(npm config get prefix 2>/dev/null || echo "")" if [ -n "$npm_prefix" ] && [ -x "${npm_prefix}/bin/panguard" ]; then bin_source="${npm_prefix}/bin/panguard" export PATH="${npm_prefix}/bin:$PATH" fi fi # Final fallback if [ -z "$bin_source" ]; then bin_source="${INSTALL_DIR}/bin/panguard" fi else bin_source="${INSTALL_DIR}/source/bin/panguard" fi # Set up PATH: npm install puts panguard in PATH, but we also need to # update any existing wrapper scripts (e.g. ~/.local/bin/panguard) that # may point to the old ~/.panguard/ location which was backed up. if [ "$NPM_INSTALLED" = "false" ]; then setup_path "$bin_source" "$SYMLINK_TARGET" "$BIN_DIR" else # Update existing wrapper if it points to a stale path local wrapper="${BIN_DIR}/panguard" if [ -f "$wrapper" ]; then local old_target old_target=$(grep -o 'exec "[^"]*"' "$wrapper" 2>/dev/null | sed 's/exec "//;s/"//') if [ -n "$old_target" ] && [ ! -x "$old_target" ]; then info "Updating wrapper at ${wrapper} to point to ${bin_source}" printf '#!/usr/bin/env bash\nexec "%s" "$@"\n' "$bin_source" > "$wrapper" 2>/dev/null chmod +x "$wrapper" 2>/dev/null fi fi # Also create pga alias wrapper if it doesn't exist local pga_wrapper="${BIN_DIR}/pga" if [ ! -f "$pga_wrapper" ] && mkdir -p "$BIN_DIR" 2>/dev/null; then printf '#!/usr/bin/env bash\nexec "%s" "$@"\n' "$bin_source" > "$pga_wrapper" 2>/dev/null chmod +x "$pga_wrapper" 2>/dev/null fi fi # Ensure PATH works in the current session (critical for curl|bash installs) if ! command -v panguard &>/dev/null; then if [ -n "$bin_source" ] && [ -x "$bin_source" ]; then export PATH="$(dirname "$bin_source"):$PATH" fi # Also try common install dirs for try_dir in "${BIN_DIR}" "/usr/local/bin" "${HOME}/.local/bin"; do if [ -x "${try_dir}/panguard" ]; then export PATH="${try_dir}:$PATH" break fi done if ! command -v panguard &>/dev/null; then echo "" warn "panguard not found in PATH. Run one of these to fix:" echo " source ~/.zshrc # macOS (zsh)" echo " source ~/.bashrc # Linux (bash)" echo " Or restart your terminal." echo "" fi fi verify_installation "$bin_source" auto_setup } # Run main only when executed directly (not when sourced for testing). # - Direct execution: BASH_SOURCE[0] == $0 # - curl|bash piping: BASH_SOURCE[0] is empty, $0 is "bash" -- use env var fallback # - Sourced by tests: BASH_SOURCE[0] != $0 and PANGUARD_INSTALLER_RUN is unset if [[ "${BASH_SOURCE[0]:-}" == "$0" ]] || [[ -z "${BASH_SOURCE[0]:-}" ]] || [[ "${PANGUARD_INSTALLER_RUN:-}" == "1" ]]; then main "$@" fi