447 lines
17 KiB
Bash
Executable File
447 lines
17 KiB
Bash
Executable File
#!/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" <<DOTENV
|
|
# Generated by deploy-local-paperclip.sh on $(date -Iseconds)
|
|
DOMAIN=${DOMAIN}
|
|
PAPERCLIP_HOME=${BASE_DIR}/data
|
|
HOST=0.0.0.0
|
|
PAPERCLIP_DEPLOYMENT_MODE=authenticated
|
|
BETTER_AUTH_SECRET=${BETTER_AUTH_SECRET}
|
|
DOTENV
|
|
chmod 600 "${BASE_DIR}/.env"
|
|
ok ".env written."
|
|
fi
|
|
fi
|
|
|
|
# --- Set ownership (must happen before onboarding and service start) ---
|
|
chown -R paperclip:paperclip "$BASE_DIR"
|
|
ok "Ownership set to paperclip:paperclip"
|
|
|
|
# --- Run onboarding as paperclip user (creates config for non-interactive use) ---
|
|
# Use system node directly — pnpm may be under /root/.nvm/ and inaccessible
|
|
# "pnpm paperclipai X" is equivalent to: node cli/node_modules/tsx/dist/cli.mjs cli/src/index.ts X
|
|
PAPERCLIP_CMD="${SYS_NODE} cli/node_modules/tsx/dist/cli.mjs cli/src/index.ts"
|
|
|
|
if [[ ! -f "${BASE_DIR}/data/instances/default/config.json" ]]; then
|
|
info "Running initial onboarding..."
|
|
# onboard --yes creates config then starts the server (blocking).
|
|
# We only need the config — systemd manages the service.
|
|
# Run in background, wait for config, then kill it.
|
|
sudo -u paperclip bash -c "cd '${BASE_DIR}' && PAPERCLIP_HOME='${BASE_DIR}/data' ${PAPERCLIP_CMD} onboard --yes" &
|
|
_onboard_pid=$!
|
|
_waited=0
|
|
while (( _waited < 120 )); do
|
|
if [[ -f "${BASE_DIR}/data/instances/default/config.json" ]]; then
|
|
ok "Onboarding complete — config created."
|
|
break
|
|
fi
|
|
sleep 2
|
|
(( _waited += 2 ))
|
|
done
|
|
# Kill onboard + any server/postgres it spawned
|
|
kill "$_onboard_pid" 2>/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 <<WRAPPER
|
|
#!/usr/bin/env bash
|
|
cd "${BASE_DIR}" && PAPERCLIP_HOME="${BASE_DIR}/data" exec ${PAPERCLIP_CMD} "\$@"
|
|
WRAPPER
|
|
chmod +x /usr/local/bin/paperclipai
|
|
ok "Command available: paperclipai"
|
|
|
|
# --- Bootstrap admin (authenticated mode requires it) ---
|
|
info "Bootstrapping admin invite..."
|
|
INVITE_OUTPUT="$(sudo -u paperclip bash -c "cd '${BASE_DIR}' && PAPERCLIP_HOME='${BASE_DIR}/data' ${PAPERCLIP_CMD} auth bootstrap-ceo --data-dir '${BASE_DIR}/data' --base-url 'https://${DOMAIN}'" 2>&1)" || true
|
|
|
|
# --- Install systemd unit (process, not Docker) ---
|
|
cat > "/etc/systemd/system/${UNIT_NAME}.service" <<EOF
|
|
[Unit]
|
|
Description=Paperclip Service
|
|
After=network.target
|
|
|
|
[Service]
|
|
Type=simple
|
|
User=paperclip
|
|
Group=paperclip
|
|
WorkingDirectory=${BASE_DIR}
|
|
EnvironmentFile=${BASE_DIR}/.env
|
|
ExecStart=${PAPERCLIP_CMD} run
|
|
Restart=on-failure
|
|
RestartSec=10
|
|
TimeoutStartSec=120
|
|
|
|
[Install]
|
|
WantedBy=multi-user.target
|
|
EOF
|
|
|
|
systemctl daemon-reload
|
|
systemctl enable "${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 ""
|