#!/usr/bin/env bash # ============================================================================== # Paperclip Local (Bare-Metal) Deployment Script # ============================================================================== # Installs Paperclip directly on the host with Node.js + pnpm. # Uses embedded PostgreSQL — no external database needed. # Does NOT auto-bootstrap Traefik (prints instructions if needed). # # Source: https://github.com/paperclipai/paperclip # # Usage: deploy-local-paperclip.sh # deploy-local-paperclip.sh --domain clip.us.an2.io # deploy-local-paperclip.sh --remove [--purge] [--yes] # ============================================================================== set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" source "${SCRIPT_DIR}/common.sh" # --- Parse arguments --- parse_args "$@" # --- Service config --- SERVICE_NAME="paperclip" BASE_DIR="/opt/${SERVICE_NAME}" CONFIG_NAME="${SERVICE_NAME}" UNIT_NAME="${SERVICE_NAME}" # --- Handle --remove --- if [[ "$ARG_REMOVE" == "1" ]]; then require_root echo "" echo -e "${YELLOW}══════════════════════════════════════════════${NC}" echo -e "${YELLOW} Removing ${SERVICE_NAME} (local)${NC}" echo -e "${YELLOW}══════════════════════════════════════════════${NC}" if [[ "$ARG_PURGE" == "1" ]]; then echo -e " Mode: ${RED}Purge (delete all data)${NC}" else echo -e " Mode: Safe (keep data)" fi echo -e " Data: ${BASE_DIR}" echo -e " Traefik: ${TRAEFIK_DYNAMIC_DIR}/${CONFIG_NAME}.yml" echo -e " Systemd: ${UNIT_NAME}.service" echo -e "${YELLOW}══════════════════════════════════════════════${NC}" echo "" if [[ "$ARG_PURGE" == "1" ]] && [[ -d "$BASE_DIR" ]]; then backup_script="${SCRIPT_DIR}/backup-${SERVICE_NAME}.sh" if [[ -f "$backup_script" ]]; then warn "Back up first? ${backup_script}" fi echo "" fi # Stop and remove systemd unit unit_file="/etc/systemd/system/${UNIT_NAME}.service" if [[ -f "$unit_file" ]]; then systemctl stop "${UNIT_NAME}.service" > /dev/null 2>&1 || true systemctl disable "${UNIT_NAME}.service" > /dev/null 2>&1 || true rm -f "$unit_file" systemctl daemon-reload ok "Removed systemd unit: ${UNIT_NAME}.service" fi # Remove traefik config if it exists (user may have set it up manually) remove_traefik_dynamic_config "$SERVICE_NAME" if [[ "$ARG_PURGE" == "1" ]]; then echo "" warn "This will permanently delete: $BASE_DIR" if [[ "$ARG_YES" != "1" ]]; then read -rp "Are you sure? [y/N] " confirm if [[ "${confirm,,}" != "y" ]]; then info "Purge cancelled." exit 0 fi fi rm -rf "$BASE_DIR" ok "Purged: $BASE_DIR" fi echo "" echo -e "${GREEN}══════════════════════════════════════════════${NC}" echo -e "${GREEN} ${SERVICE_NAME} removed${NC}" echo -e "${GREEN}══════════════════════════════════════════════${NC}" if [[ "$ARG_PURGE" != "1" ]]; then echo -e " Data preserved at ${BASE_DIR}" echo -e " To purge: $0 --remove --purge" fi echo -e " To redeploy: $0" echo -e "${GREEN}══════════════════════════════════════════════${NC}" echo "" exit 0 fi # --- Domain resolution --- if [[ -n "$ARG_DOMAIN" ]]; then DOMAIN="$ARG_DOMAIN" elif [[ -f "${BASE_DIR}/.env" ]] && grep -q '^DOMAIN=' "${BASE_DIR}/.env"; then DOMAIN="$(grep '^DOMAIN=' "${BASE_DIR}/.env" | cut -d= -f2)" else DOMAIN="paperclip.an2.io" fi # --- Detect current state for banner --- traefik_status="Not found — will deploy" if docker ps --format '{{.Names}}' 2>/dev/null | grep -q '^traefik$'; then traefik_status="Running" elif [[ -f "${TRAEFIK_DIR}/docker-compose.yml" ]]; then traefik_status="Stopped — will start" fi install_mode="Fresh install" if [[ -f "${BASE_DIR}/.env" ]]; then install_mode="Re-run (preserving data)" fi service_status="Not running" if systemctl is-active --quiet "${UNIT_NAME}.service" 2>/dev/null; then service_status="Running" fi # --- Print deployment plan --- echo "" echo -e "${CYAN}══════════════════════════════════════════════${NC}" echo -e "${CYAN} Deploying Paperclip (Local)${NC}" echo -e "${CYAN}══════════════════════════════════════════════${NC}" echo -e " Domain: ${DOMAIN}" echo -e " Data: ${BASE_DIR}" echo -e " Service: ${service_status}" echo -e " Traefik: ${traefik_status}" echo -e " Mode: ${install_mode}" echo -e "${CYAN}══════════════════════════════════════════════${NC}" echo "" # --- Check if already deployed and running --- if [[ -f "${BASE_DIR}/.env" ]] && \ systemctl is-active --quiet "${UNIT_NAME}.service" 2>/dev/null; then echo -e "${GREEN} Paperclip is already running — nothing to do.${NC}" echo "" echo -e " Health: curl http://localhost:3100/api/health" echo -e " Logs: journalctl -u ${UNIT_NAME}.service -f" echo -e " Backup: ${SCRIPT_DIR}/backup-paperclip.sh" echo -e " Remove: $0 --remove [--purge]" echo "" exit 0 fi # --- Shared infrastructure --- require_root detect_os install_prerequisites ensure_docker_network ensure_traefik configure_firewall # --- Install Node.js + pnpm --- install_nodejs() { if command -v node &>/dev/null; then local node_major node_major="$(node --version | sed 's/^v//' | cut -d. -f1)" if (( node_major >= 20 )); then ok "Node.js $(node --version) already installed." return 0 fi warn "Node.js $(node --version) is too old (need 20+). Upgrading..." fi info "Installing Node.js 20..." case "$DISTRO_FAMILY" in debian) curl -fsSL https://deb.nodesource.com/setup_20.x | bash - > /dev/null 2>&1 apt-get install -y -qq nodejs > /dev/null 2>&1 ;; fedora) curl -fsSL https://rpm.nodesource.com/setup_20.x | bash - > /dev/null 2>&1 dnf install -y -q nodejs > /dev/null 2>&1 ;; esac ok "Node.js $(node --version) installed." } install_pnpm() { if command -v pnpm &>/dev/null; then local pnpm_major pnpm_major="$(pnpm --version | cut -d. -f1)" if (( pnpm_major >= 9 )); then ok "pnpm $(pnpm --version) already installed." return 0 fi fi info "Installing pnpm 9+..." corepack enable 2>/dev/null || npm install -g corepack corepack prepare pnpm@latest --activate 2>/dev/null || npm install -g pnpm@latest ok "pnpm $(pnpm --version) installed." } # Install base prerequisites (curl, openssl, etc.) case "$DISTRO_FAMILY" in debian) export DEBIAN_FRONTEND=noninteractive apt-get update -qq apt-get install -y -qq curl openssl jq git ca-certificates > /dev/null 2>&1 ;; fedora) dnf install -y -q curl openssl jq git ca-certificates > /dev/null 2>&1 ;; esac install_nodejs install_pnpm # --- Ensure system-wide node exists --- # nvm installs node under /root/ which other users cannot access. # The paperclip service user needs a system-wide node binary. SYS_NODE="" for _p in /usr/bin/node /usr/local/bin/node; do [[ -x "$_p" ]] && SYS_NODE="$_p" && break done if [[ -z "$SYS_NODE" ]]; then warn "Node.js only available via nvm (under /root/) — installing system-wide..." case "$DISTRO_FAMILY" in debian) curl -fsSL https://deb.nodesource.com/setup_22.x | bash - > /dev/null 2>&1 apt-get install -y -qq nodejs > /dev/null 2>&1 ;; fedora) curl -fsSL https://rpm.nodesource.com/setup_22.x | bash - > /dev/null 2>&1 dnf install -y -q nodejs > /dev/null 2>&1 ;; esac for _p in /usr/bin/node /usr/local/bin/node; do [[ -x "$_p" ]] && SYS_NODE="$_p" && break done [[ -x "$SYS_NODE" ]] || fatal "Failed to install system-wide Node.js" ok "System-wide Node.js installed: ${SYS_NODE}" fi # --- Create service user (embedded Postgres refuses to run as root) --- if ! id -u paperclip &>/dev/null; then useradd -r -s /usr/sbin/nologin -d "$BASE_DIR" paperclip ok "Created system user: paperclip" fi # --- Backup restore check --- if check_and_restore_backup "$SERVICE_NAME"; then saved_domain="$DOMAIN" # shellcheck source=/dev/null source "${BASE_DIR}/.env" DOMAIN="$saved_domain" ok "Backup restored. Skipping fresh install." else # --- Fresh install --- if [[ -f "${BASE_DIR}/.env" ]]; then # Re-run: update dependencies only saved_domain="$DOMAIN" # shellcheck source=/dev/null source "${BASE_DIR}/.env" DOMAIN="$saved_domain" info "Existing installation found. Updating dependencies..." (cd "$BASE_DIR" && pnpm install --frozen-lockfile 2>/dev/null || pnpm install) info "Rebuilding project..." (cd "$BASE_DIR" && pnpm build) else # Fresh clone PAPERCLIP_REPO="${PAPERCLIP_REPO:-https://github.com/paperclipai/paperclip.git}" info "Cloning Paperclip from ${PAPERCLIP_REPO}..." if [[ -d "${BASE_DIR}/.git" ]]; then (cd "$BASE_DIR" && git pull) else git clone "$PAPERCLIP_REPO" "$BASE_DIR" fi info "Installing dependencies..." (cd "$BASE_DIR" && pnpm install) info "Building project (this may take a minute)..." (cd "$BASE_DIR" && pnpm build) mkdir -p "${BASE_DIR}/data" # --- Generate .env --- # authenticated mode + HOST=0.0.0.0 so Traefik (in Docker) can reach the service BETTER_AUTH_SECRET="$(generate_password)" cat > "${BASE_DIR}/.env" </dev/null; wait "$_onboard_pid" 2>/dev/null || true pkill -u paperclip -f "cli/src/index" 2>/dev/null || true sleep 2 # Switch config to authenticated mode (onboard defaults to local_trusted) # Key is nested: server.deploymentMode (not top-level) PAPERCLIP_CONFIG="${BASE_DIR}/data/instances/default/config.json" if [[ -f "$PAPERCLIP_CONFIG" ]] && command -v jq &>/dev/null; then jq '.server.deploymentMode = "authenticated"' "$PAPERCLIP_CONFIG" > "${PAPERCLIP_CONFIG}.tmp" && \ mv "${PAPERCLIP_CONFIG}.tmp" "$PAPERCLIP_CONFIG" chown paperclip:paperclip "$PAPERCLIP_CONFIG" ok "Config set to authenticated mode." fi fi # --- Register domain as allowed hostname --- info "Registering allowed hostname: ${DOMAIN}" sudo -u paperclip bash -c "cd '${BASE_DIR}' && PAPERCLIP_HOME='${BASE_DIR}/data' ${PAPERCLIP_CMD} allowed-hostname '${DOMAIN}'" || \ warn "Could not register hostname — you may need to run: pnpm paperclipai allowed-hostname ${DOMAIN}" # --- Create paperclipai symlink --- cat > /usr/local/bin/paperclipai <&1)" || true # --- Install systemd unit (process, not Docker) --- cat > "/etc/systemd/system/${UNIT_NAME}.service" < /dev/null 2>&1 systemctl restart "${UNIT_NAME}.service" ok "Systemd unit installed and started: ${UNIT_NAME}.service" # --- Ensure Traefik can reach host services --- # Traefik runs in Docker; it needs host.docker.internal to reach bare-metal services. # New deployments get this via ensure_traefik. Patch existing ones if missing. if ! grep -q "host.docker.internal" "${TRAEFIK_DIR}/docker-compose.yml" 2>/dev/null; then info "Updating Traefik with host.docker.internal mapping..." cat > "${TRAEFIK_DIR}/docker-compose.override.yml" <<'OVERRIDE' services: traefik: extra_hosts: - "host.docker.internal:host-gateway" OVERRIDE (cd "$TRAEFIK_DIR" && docker compose up -d) ok "Traefik updated." fi # --- Write Traefik dynamic config --- write_traefik_dynamic_config "$CONFIG_NAME" "$DOMAIN" "http://host.docker.internal:3100" # --- Wait for health --- info "Waiting for Paperclip to become healthy (up to 90s)..." healthy=0 elapsed=0 while (( elapsed < 90 )); do if curl -sf http://localhost:3100/api/health > /dev/null 2>&1; then ok "Paperclip is healthy." healthy=1 break fi sleep 5 (( elapsed += 5 )) done # --- Summary --- echo "" if (( healthy )); then echo -e "${GREEN}══════════════════════════════════════════════${NC}" echo -e "${GREEN} Paperclip deployed successfully (local)${NC}" echo -e "${GREEN}══════════════════════════════════════════════${NC}" else echo -e "${YELLOW}══════════════════════════════════════════════${NC}" echo -e "${YELLOW} Paperclip installed but not yet healthy${NC}" echo -e "${YELLOW}══════════════════════════════════════════════${NC}" fi echo -e " URL: https://${DOMAIN}" echo -e " Local: http://localhost:3100" echo -e " Data: ${BASE_DIR}/data/" echo -e " Traefik: ${TRAEFIK_DYNAMIC_DIR}/${CONFIG_NAME}.yml" echo -e " Systemd: systemctl status ${UNIT_NAME}" echo -e " Logs: journalctl -u ${UNIT_NAME}.service -f" # Show invite URL if bootstrap-ceo produced one INVITE_URL="$(echo "$INVITE_OUTPUT" | grep -oE 'https?://[^ ]+' | head -1)" || true if [[ -n "$INVITE_URL" ]]; then echo "" echo -e " ${CYAN}Admin invite:${NC} ${INVITE_URL}" fi if (( healthy )); then echo -e "${GREEN}══════════════════════════════════════════════${NC}" else echo -e "${YELLOW}══════════════════════════════════════════════${NC}" fi echo ""