#!/bin/bash set -euo pipefail LOG_FILE="/tmp/sleepypod-install.log" exec > >(tee -a "$LOG_FILE") 2>&1 echo "========================================" echo " SleepyPod Core Installation Script" echo "========================================" echo "" echo "Logging to $LOG_FILE" echo "" # -------------------------------------------------------------------------------- # Mode flags # --local Skip download (code already on disk, e.g. via scp/deploy) # --no-ssh Skip interactive SSH setup prompt # --branch X Install a specific branch (default: main) INSTALL_LOCAL=false SKIP_SSH=false INSTALL_BRANCH="" while [ $# -gt 0 ]; do case "$1" in --local) INSTALL_LOCAL=true ;; --no-ssh) SKIP_SSH=true ;; --branch) shift; INSTALL_BRANCH="${1:-}" ;; esac shift done # Cleanup handler — re-blocks WAN if we unblocked it WAN_WAS_BLOCKED=false DOWNLOAD_DIR="" cleanup() { local exit_code=$? # Clean up temp download directory [ -n "$DOWNLOAD_DIR" ] && rm -rf "$DOWNLOAD_DIR" # Restore iptables if we temporarily unblocked if [ "$WAN_WAS_BLOCKED" = true ]; then echo "Restoring iptables rules..." restore_wan fi if [ $exit_code -ne 0 ]; then echo "" >&2 echo "========================================" >&2 echo " Installation failed" >&2 echo "========================================" >&2 echo " Exit code: $exit_code" >&2 echo " Full log: $LOG_FILE" >&2 echo "" >&2 echo " For help, share the log file above:" >&2 echo " Discord: https://discord.gg/UMmv5R6MXa" >&2 echo " GitHub: https://github.com/sleepypod/core/issues" >&2 echo "========================================" >&2 fi } trap 'cleanup $LINENO' EXIT # -------------------------------------------------------------------------------- # iptables helpers # Canonical copy: scripts/lib/iptables-helpers (sourced by sp-update at runtime). # Kept inline here because install needs them before the source tree is on disk. wan_is_blocked() { # Check if the OUTPUT chain has a DROP rule (WAN is blocked) iptables -L OUTPUT -n 2>/dev/null | grep -q "DROP" 2>/dev/null } SAVED_IPTABLES="" unblock_wan() { echo "Temporarily unblocking WAN access..." SAVED_IPTABLES="$(iptables-save 2>/dev/null || true)" iptables -F iptables -X iptables -t nat -F iptables -t nat -X echo "WAN unblocked." } restore_wan() { if [ -n "$SAVED_IPTABLES" ]; then echo "Restoring saved iptables rules..." echo "$SAVED_IPTABLES" | iptables-restore 2>/dev/null || block_wan else block_wan fi } block_wan() { echo "Re-blocking WAN access..." # Flush first to avoid duplicate rules iptables -F 2>/dev/null || true iptables -X 2>/dev/null || true iptables -t nat -F 2>/dev/null || true iptables -t nat -X 2>/dev/null || true # Allow established connections iptables -I INPUT -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT iptables -I OUTPUT -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT # Allow LAN traffic (RFC 1918) for cidr in 10.0.0.0/8 172.16.0.0/12 192.168.0.0/16; do iptables -A INPUT -s "$cidr" -j ACCEPT iptables -A OUTPUT -d "$cidr" -j ACCEPT done # Allow NTP iptables -I OUTPUT -p udp --dport 123 -j ACCEPT iptables -I INPUT -p udp --sport 123 -j ACCEPT # Allow mDNS (Bonjour — iOS discovers pod via _sleepypod._tcp) # Port 5353 UDP covers IPv4 (224.0.0.251) and IPv6 (ff02::fb) iptables -I OUTPUT -p udp --dport 5353 -j ACCEPT iptables -I OUTPUT -p udp --sport 5353 -j ACCEPT iptables -I INPUT -p udp --dport 5353 -j ACCEPT iptables -I INPUT -p udp --sport 5353 -j ACCEPT # Allow loopback iptables -A INPUT -i lo -j ACCEPT iptables -A OUTPUT -o lo -j ACCEPT # Block everything else iptables -A INPUT -j DROP iptables -A OUTPUT -j DROP # Persist if command -v iptables-save &>/dev/null; then mkdir -p /etc/iptables iptables-save > /etc/iptables/iptables.rules fi echo "WAN blocked." } # Check if running as root if [ "$EUID" -ne 0 ]; then echo "Error: This script must be run as root (use sudo)" >&2 exit 1 fi # Ensure /usr/local/bin is in PATH (Yocto's root PATH is minimal) case ":$PATH:" in *:/usr/local/bin:*) ;; *) export PATH="/usr/local/bin:$PATH" ;; esac INSTALL_DIR="/home/dac/sleepypod-core" GITHUB_REPO="${SLEEPYPOD_GITHUB_REPO:-sleepypod/core}" SCRIPT_DEFAULT_BRANCH="__BRANCH__" # substituted at release time by .github/workflows/{release,dev-release}.yml # Lock file to prevent concurrent installs LOCKFILE="/var/run/sleepypod-install.lock" exec 200>"$LOCKFILE" if ! flock -n 200; then echo "Error: Another installation is already running" >&2 exit 1 fi # Check for required commands REQUIRED_CMDS=(curl systemctl ip groupadd usermod) if [ "$INSTALL_LOCAL" = true ] && [ -n "$INSTALL_BRANCH" ]; then REQUIRED_CMDS+=(git) fi for cmd in "${REQUIRED_CMDS[@]}"; do if ! command -v "$cmd" &>/dev/null; then echo "Error: Required command '$cmd' not found." >&2 exit 1 fi done # Handle WAN connectivity — always needed (npm packages, node binary download) echo "Checking network connectivity..." if ! curl -sf --max-time 10 https://github.com > /dev/null 2>&1; then if wan_is_blocked; then echo "WAN is blocked by iptables. Temporarily unblocking for install..." # Ensure /etc/hosts telemetry block is in place before opening WAN # This prevents Eight Sleep processes from phoning home during the window if ! grep -q "# BEGIN sleepypod-telemetry-block" /etc/hosts 2>/dev/null; then echo "Installing /etc/hosts telemetry block before opening WAN..." scripts/internet-control hosts-block 2>/dev/null || true fi WAN_WAS_BLOCKED=true unblock_wan # Re-check after unblock if ! curl -sf --max-time 10 https://github.com > /dev/null 2>&1; then echo "Error: Still cannot reach GitHub after unblocking. Check Wi-Fi." >&2 exit 1 fi else echo "Error: Cannot reach GitHub. Check network and try again." >&2 exit 1 fi fi # Check disk space (need 500MB for build) DISK_AVAIL=$(df -m /home | awk 'NR==2{print $4}') if [ "$DISK_AVAIL" -lt 500 ]; then echo "Error: Need at least 500MB free. Only ${DISK_AVAIL}MB available." >&2 echo "Clean up with: rm -rf /home/dac/sleepypod-core/node_modules/.cache" >&2 exit 1 fi # DAC_SOCK_PATH detection is in scripts/pod/detect — sourced after code is on disk. # Pre-create both possible parent directories so mkdir works before detection runs. # Create data directory with shared group for multi-user SQLite access. # The Node.js app (root) creates biometrics.db, but calibrator and # environment-monitor run as User=dac. Setgid ensures all files created # inside inherit the sleepypod group; UMask=0002 on services ensures # group-write bits are preserved (including SQLite WAL/SHM files). DATA_DIR="/persistent/sleepypod-data" echo "Creating data directory at $DATA_DIR..." # Ensure ReadWritePaths directories exist — systemd's ProtectSystem=strict # requires all listed paths to exist or the namespace setup fails. # /run/dac is handled by RuntimeDirectory=dac in the service unit. mkdir -p /persistent/deviceinfo mkdir -p /deviceinfo groupadd --force sleepypod if id dac &>/dev/null; then usermod -aG sleepypod dac else echo "Warning: user 'dac' not found — skipping group membership (not a Pod?)" fi mkdir -p "$DATA_DIR" chown root:sleepypod "$DATA_DIR" chmod 2770 "$DATA_DIR" # Fix permissions on existing database files (upgrades from older installs) fix_db_permissions() { if find "$DATA_DIR" -maxdepth 1 \( -name '*.db' -o -name '*.db-wal' -o -name '*.db-shm' \) -print -quit | grep -q .; then echo "Fixing database file permissions..." find "$DATA_DIR" -type f \( -name '*.db' -o -name '*.db-wal' -o -name '*.db-shm' \) \ -exec chown root:sleepypod {} + -exec chmod 660 {} + fi } fix_db_permissions # ============================================================================ # Install Node.js via binary download (works on any Linux, no apt required) # ============================================================================ NODE_WANTED=22 NODE_FULL="22.17.0" NODE_DIR="/usr/local/lib/nodejs" # Detect architecture ARCH=$(uname -m) case "$ARCH" in aarch64|arm64) NODE_ARCH="arm64" ;; x86_64) NODE_ARCH="x64" ;; armv7l) NODE_ARCH="armv7l" ;; *) echo "Error: Unsupported architecture: $ARCH" >&2; exit 1 ;; esac # Check if adequate Node is already installed CURRENT_NODE_MAJOR=$(node -v 2>/dev/null | cut -d. -f1 | tr -d v || echo "0") if [ "$CURRENT_NODE_MAJOR" -lt "$NODE_WANTED" ]; then echo "Installing Node.js $NODE_FULL ($NODE_ARCH)..." NODE_TARBALL="node-v${NODE_FULL}-linux-${NODE_ARCH}.tar.gz" NODE_URL="https://nodejs.org/dist/v${NODE_FULL}/${NODE_TARBALL}" mkdir -p "$NODE_DIR" curl -fSL "$NODE_URL" | tar -xz -C "$NODE_DIR" # Symlink binaries into /usr/local/bin mkdir -p /usr/local/bin NODE_BIN="$NODE_DIR/node-v${NODE_FULL}-linux-${NODE_ARCH}/bin" for bin in node npm npx; do ln -sf "$NODE_BIN/$bin" "/usr/local/bin/$bin" done echo "Node.js $(node -v) installed." else echo "Node.js $(node -v) already installed (>= $NODE_WANTED)." fi # Verify Node.js version NODE_MAJOR=$(node -v | cut -d. -f1 | tr -d v) if [ "$NODE_MAJOR" -lt "$NODE_WANTED" ]; then echo "Error: Node.js >= $NODE_WANTED required, found $(node -v)" >&2 exit 1 fi # Install pnpm if not present if ! command -v pnpm &>/dev/null; then echo "Installing pnpm..." npm install -g pnpm # Ensure pnpm is in PATH (npm global bin may differ from /usr/local/bin) NPM_BIN="$(npm config get prefix)/bin" if [ ! -f "/usr/local/bin/pnpm" ] && [ -f "$NPM_BIN/pnpm" ]; then ln -sf "$NPM_BIN/pnpm" /usr/local/bin/pnpm fi fi # Get the code in place if [ "$INSTALL_LOCAL" = true ]; then echo "Local install mode — using code already at $INSTALL_DIR" if [ ! -d "$INSTALL_DIR" ]; then echo "Error: $INSTALL_DIR does not exist. SCP the code first or run without --local." >&2 exit 1 fi cd "$INSTALL_DIR" # Checkout a specific branch if requested (requires git) if [ -n "$INSTALL_BRANCH" ]; then if ! command -v git &>/dev/null; then echo "Error: git required for --branch but not found." >&2 exit 1 fi echo "Checking out branch: $INSTALL_BRANCH" git fetch origin git checkout "$INSTALL_BRANCH" git reset --hard "origin/$INSTALL_BRANCH" fi else # Download code via tarball (no git required on device) DOWNLOAD_BRANCH="${INSTALL_BRANCH:-$SCRIPT_DEFAULT_BRANCH}" echo "Downloading $DOWNLOAD_BRANCH from GitHub..." DOWNLOAD_DIR=$(mktemp -d) HAS_BUILD=false # Try CI release first (includes pre-built .next) if [ "$DOWNLOAD_BRANCH" = "main" ] || [ "$DOWNLOAD_BRANCH" = "latest" ]; then RELEASE_API="https://api.github.com/repos/${GITHUB_REPO}/releases/latest" elif [ "$DOWNLOAD_BRANCH" = "dev" ]; then RELEASE_API="https://api.github.com/repos/${GITHUB_REPO}/releases/tags/dev" fi if [ -n "${RELEASE_API:-}" ]; then RELEASE_URL=$(curl -sf "$RELEASE_API" \ | grep -om1 '"browser_download_url": *"[^"]*sleepypod-core\.tar\.gz"' \ | grep -o 'https://[^"]*' || true) if [ -n "$RELEASE_URL" ]; then echo "Downloading CI release..." if curl -fSL "$RELEASE_URL" | tar xz -C "$DOWNLOAD_DIR"; then HAS_BUILD=true echo "CI release downloaded (pre-built)." else # Clean partial download before fallback find "$DOWNLOAD_DIR" -mindepth 1 -delete 2>/dev/null || true fi fi fi # Fall back to source tarball if [ "$HAS_BUILD" = false ]; then # "latest" is not a real branch — use main for the source tarball SOURCE_BRANCH="$DOWNLOAD_BRANCH" [ "$SOURCE_BRANCH" = "latest" ] && SOURCE_BRANCH="main" TARBALL_URL="https://github.com/${GITHUB_REPO}/archive/refs/heads/${SOURCE_BRANCH}.tar.gz" if ! curl -fSL "$TARBALL_URL" | tar xz -C "$DOWNLOAD_DIR"; then echo "Error: Failed to download branch '$SOURCE_BRANCH'" >&2 exit 1 fi # Source tarball extracts to a subdirectory (e.g., core-main/) SUBDIR=$(ls "$DOWNLOAD_DIR" | head -1) if [ -z "$SUBDIR" ] || [ ! -d "$DOWNLOAD_DIR/$SUBDIR" ]; then echo "Error: Unexpected tarball structure in $DOWNLOAD_DIR" >&2 exit 1 fi mv "$DOWNLOAD_DIR/$SUBDIR"/* "$DOWNLOAD_DIR/$SUBDIR"/.[!.]* "$DOWNLOAD_DIR/" 2>/dev/null || true rmdir "$DOWNLOAD_DIR/$SUBDIR" 2>/dev/null || true fi # Clean old files if updating (preserve node_modules, .env, data) if [ -d "$INSTALL_DIR" ]; then echo "Updating existing installation..." find "$INSTALL_DIR" -mindepth 1 -maxdepth 1 \ ! -name 'node_modules' \ ! -name '.env' \ -exec rm -rf {} + else mkdir -p "$INSTALL_DIR" fi cp -r "$DOWNLOAD_DIR/." "$INSTALL_DIR/" DOWNLOAD_DIR="" cd "$INSTALL_DIR" echo "Source downloaded." fi # Detect pod generation and DAC socket path (code is now on disk) if [ -f "$INSTALL_DIR/scripts/pod/detect" ]; then source "$INSTALL_DIR/scripts/pod/detect" else echo "Error: $INSTALL_DIR/scripts/pod/detect not found." >&2 echo " The downloaded code does not match this install script." >&2 echo " Try: curl -fsSL | sudo bash -s -- --branch $SCRIPT_DEFAULT_BRANCH" >&2 exit 1 fi echo "Pod generation: $POD_GEN (DAC: $DAC_SOCK_PATH)" # Install production dependencies (prebuild-install downloads prebuilt better-sqlite3) echo "Installing dependencies..." CI=true pnpm install --frozen-lockfile --prod # Verify better-sqlite3 native module is ready if [ ! -f "$INSTALL_DIR/node_modules/better-sqlite3/build/Release/better_sqlite3.node" ] && \ [ ! -f "$INSTALL_DIR/node_modules/better-sqlite3/prebuilds/linux-${NODE_ARCH}/node.napi.node" ]; then echo "Warning: better-sqlite3 native module not found, attempting manual prebuild..." cd "$INSTALL_DIR/node_modules/better-sqlite3" npx prebuild-install || { echo "Error: Failed to install better-sqlite3 prebuilt binary." >&2 echo "This platform may need gcc/g++ for native compilation." >&2 exit 1 } cd "$INSTALL_DIR" fi # Build application (skip if .next already exists — pre-built by deploy script or CI) # Effective branch: --branch flag, download branch, or script default EFFECTIVE_BRANCH="${INSTALL_BRANCH:-${DOWNLOAD_BRANCH:-$SCRIPT_DEFAULT_BRANCH}}" if [ -d "$INSTALL_DIR/.next" ]; then echo "Pre-built .next found, skipping build." # Regenerate .git-info with the correct branch (CI builds may have a different branch baked in) SP_BRANCH="$EFFECTIVE_BRANCH" node scripts/generate-git-info.mjs 2>/dev/null || true else echo "No pre-built .next found, building from source..." echo "Installing all dependencies (including devDependencies for build)..." CI=true pnpm install --frozen-lockfile SP_BRANCH="$EFFECTIVE_BRANCH" pnpm build fi # Turbopack hashes the better-sqlite3 module name — create a symlink so the # hashed reference resolves to the real module with the correct native binary HASHED_NAME=$(grep -roh 'better-sqlite3-[a-f0-9]\{16\}' "$INSTALL_DIR/.next/server/" 2>/dev/null | head -1) if [ -n "$HASHED_NAME" ] && [ ! -e "$INSTALL_DIR/node_modules/$HASHED_NAME" ]; then echo "Creating symlink for Turbopack module: $HASHED_NAME -> better-sqlite3" ln -sf better-sqlite3 "$INSTALL_DIR/node_modules/$HASHED_NAME" fi # Create or preserve environment file if [ -f "$INSTALL_DIR/.env" ]; then echo "Existing .env found, backing up..." cp "$INSTALL_DIR/.env" "$INSTALL_DIR/.env.bak.$(date +%s)" # Update managed keys grep -q "^DAC_SOCK_PATH=" "$INSTALL_DIR/.env" && \ sed -i "s|^DAC_SOCK_PATH=.*|DAC_SOCK_PATH=$DAC_SOCK_PATH|" "$INSTALL_DIR/.env" || \ echo "DAC_SOCK_PATH=$DAC_SOCK_PATH" >> "$INSTALL_DIR/.env" grep -q "^DATABASE_URL=" "$INSTALL_DIR/.env" && \ sed -i "s|^DATABASE_URL=.*|DATABASE_URL=file:$DATA_DIR/sleepypod.db|" "$INSTALL_DIR/.env" || \ echo "DATABASE_URL=file:$DATA_DIR/sleepypod.db" >> "$INSTALL_DIR/.env" grep -q "^BIOMETRICS_DATABASE_URL=" "$INSTALL_DIR/.env" && \ sed -i "s|^BIOMETRICS_DATABASE_URL=.*|BIOMETRICS_DATABASE_URL=file:$DATA_DIR/biometrics.db|" "$INSTALL_DIR/.env" || \ echo "BIOMETRICS_DATABASE_URL=file:$DATA_DIR/biometrics.db" >> "$INSTALL_DIR/.env" else echo "Creating environment file..." touch "$INSTALL_DIR/.env" chmod 600 "$INSTALL_DIR/.env" cat > "$INSTALL_DIR/.env" << EOF DATABASE_URL=file:$DATA_DIR/sleepypod.db BIOMETRICS_DATABASE_URL=file:$DATA_DIR/biometrics.db DAC_SOCK_PATH=$DAC_SOCK_PATH NODE_ENV=production EOF fi # Initialize database with migrations (not destructive push) if [ -f "$DATA_DIR/sleepypod.db" ]; then echo "Existing database found, backing up..." cp "$DATA_DIR/sleepypod.db" "$DATA_DIR/sleepypod.db.bak.$(date +%s)" fi # Database migrations run automatically on app startup (instrumentation.ts) echo "Database migrations will run on first startup." # Create systemd service echo "Creating systemd service..." cat > /etc/systemd/system/sleepypod.service << EOF [Unit] Description=SleepyPod Core Service After=network.target [Service] Type=simple User=root UMask=0002 WorkingDirectory=$INSTALL_DIR Environment="NODE_ENV=production" Environment="DATABASE_URL=file:$DATA_DIR/sleepypod.db" Environment="DAC_SOCK_PATH=$DAC_SOCK_PATH" ExecStartPre=/bin/sh -c '[ "$DAC_SOCK_PATH" != "/deviceinfo/dac.sock" ] && ln -sf $DAC_SOCK_PATH /deviceinfo/dac.sock 2>/dev/null; true' ExecStartPre=$INSTALL_DIR/scripts/bin/sp-maintenance Environment="PATH=/usr/local/bin:/usr/bin:/bin" ExecStart=/bin/sh -c 'if [ -f .next/standalone/server.js ]; then exec /usr/local/bin/node .next/standalone/server.js; else exec /usr/local/bin/pnpm start; fi' Restart=always RestartSec=10 # Hardening (optional, doesn't interfere with dac.sock) NoNewPrivileges=true RuntimeDirectory=dac ReadWritePaths=$DATA_DIR /persistent/deviceinfo /deviceinfo /run/dac ProtectSystem=strict ProtectKernelTunables=true ProtectKernelModules=true [Install] WantedBy=multi-user.target EOF # Cap journal size to prevent unbounded log growth on limited disk mkdir -p /etc/systemd/journald.conf.d cat > /etc/systemd/journald.conf.d/sleepypod.conf << 'JOURNALD' [Journal] SystemMaxUse=50M SystemKeepFree=200M MaxFileSec=1week JOURNALD systemctl restart systemd-journald 2>/dev/null || true # Reload systemd and enable service echo "Enabling service..." systemctl daemon-reload systemctl enable sleepypod.service # Stop and disable free-sleep services (both use port 3000) for fs_svc in free-sleep.service free-sleep-stream.service; do if systemctl list-unit-files "$fs_svc" &>/dev/null; then if systemctl is-active --quiet "$fs_svc" 2>/dev/null; then echo "Stopping $fs_svc (port 3000 conflict)..." systemctl stop "$fs_svc" fi if systemctl is-enabled --quiet "$fs_svc" 2>/dev/null; then echo "Disabling $fs_svc to prevent restart on reboot..." systemctl disable "$fs_svc" fi fi done # Kill anything else holding port 3000 if command -v fuser &>/dev/null; then PORT_PID=$(fuser 3000/tcp 2>/dev/null | tr -d ' ' || true) if [ -n "$PORT_PID" ]; then echo "Killing process $PORT_PID on port 3000..." kill "$PORT_PID" 2>/dev/null || true sleep 1 fi fi # Install Avahi mDNS service file (must happen here, not at runtime, # because ProtectSystem=strict makes /etc read-only for the service process). # Port values are baked in at install time; sp-update re-runs this to refresh. if [ -d /etc/avahi ]; then mkdir -p /etc/avahi/services TRPC_PORT=${PORT:-3000} WS_PORT=${PIEZO_WS_PORT:-3001} cat > /etc/avahi/services/sleepypod.service << AVAHI_EOF sleepypod _sleepypod._tcp $TRPC_PORT wsPort=$WS_PORT version=1.0.0 AVAHI_EOF # Reload avahi to pick up the new service file kill -HUP "$(pidof avahi-daemon 2>/dev/null)" 2>/dev/null || true echo "Avahi mDNS service installed (_sleepypod._tcp on port $TRPC_PORT)" fi systemctl restart sleepypod.service # Wait for sleepypod to create dac.sock before killing frankenfirmware. # frankenfirmware connects on startup and won't retry — if it restarts # before the socket exists, it silently fails to connect. echo "Waiting for dac.sock..." for i in $(seq 1 10); do [ -S "$DAC_SOCK_PATH" ] && break sleep 1 done if [ -S "$DAC_SOCK_PATH" ]; then # Kill frankenfirmware so its supervisor restarts it against the new dac.sock FRANKEN_PID=$(pgrep frankenfirmware 2>/dev/null || true) if [ -n "$FRANKEN_PID" ]; then echo "Killing frankenfirmware (PID $FRANKEN_PID) so supervisor reconnects to dac.sock..." kill "$FRANKEN_PID" 2>/dev/null || true fi else echo "Warning: dac.sock not found after 10s — skipping frankenfirmware restart" >&2 fi # ============================================================================ # Install bundled biometrics modules # ============================================================================ echo "" echo "========================================" echo " Installing Biometrics Modules" echo "========================================" echo "" MODULES_SRC="$INSTALL_DIR/modules" MODULES_DEST="/opt/sleepypod/modules" mkdir -p "$MODULES_DEST" mkdir -p /etc/sleepypod/modules # Install uv (Rust-based Python package manager — bypasses broken ensurepip/pyexpat on Yocto) if ! command -v uv &>/dev/null; then echo "Installing uv..." curl -LsSf https://astral.sh/uv/install.sh | UV_INSTALL_DIR=/usr/local/bin INSTALLER_NO_MODIFY_PATH=1 sh fi if ! command -v uv &>/dev/null; then echo "Warning: uv installation failed — skipping biometrics module installation" else install_module() { local name="$1" local src="$MODULES_SRC/$name" local dest="$MODULES_DEST/$name" local service="sleepypod-$name.service" if [ ! -d "$src" ]; then echo "Warning: module source not found at $src, skipping" return fi echo "Installing module: $name..." # Copy module files mkdir -p "$dest" cp -r "$src/." "$dest/" # Remove orphaned venv/ from pre-uv installs rm -rf "$dest/venv" # Create Python environment with uv (no ensurepip/pyexpat needed) (cd "$dest" && uv sync --frozen --python python3) || { echo "Warning: uv sync failed for module $name" return } # Install systemd service if [ -f "$dest/$service" ]; then cp "$dest/$service" "/etc/systemd/system/$service" systemctl daemon-reload systemctl enable "$service" systemctl restart "$service" echo "Module $name installed and started" else echo "Warning: no service file found for $name" fi } # Copy shared common package (used by all modules via sys.path) if [ -d "$MODULES_SRC/common" ]; then mkdir -p "$MODULES_DEST/common" cp -r "$MODULES_SRC/common/." "$MODULES_DEST/common/" fi install_module "piezo-processor" install_module "sleep-detector" install_module "environment-monitor" install_module "calibrator" fi # Re-fix DB permissions after all services restarted with new UMask. # On upgrades, old services (without UMask=0002) may have recreated WAL/SHM # files with 0644 between the initial fixup and the service restarts above. fix_db_permissions # Install CLI tools from scripts/bin/ if [ -d "$INSTALL_DIR/scripts/bin" ]; then echo "Installing CLI tools..." for tool in "$INSTALL_DIR/scripts/bin"/sp-*; do [ -f "$tool" ] || continue cp "$tool" /usr/local/bin/ chmod +x "/usr/local/bin/$(basename "$tool")" done fi # Optional SSH configuration if [ "$SKIP_SSH" = true ]; then echo "Skipping SSH setup (--no-ssh)" elif [ ! -t 0 ]; then echo "Skipping SSH setup (non-interactive)" else echo "" echo "========================================" echo " Optional SSH Configuration" echo "========================================" echo "" read -rp "Configure SSH on port 8822? [y/N] " SETUP_SSH fi if [ "${SKIP_SSH:-}" != true ] && [ -t 0 ] && [[ "${SETUP_SSH:-}" =~ ^[Yy]$ ]]; then echo "Configuring SSH..." # Backup sshd_config if [ ! -f /etc/ssh/sshd_config.backup ]; then cp /etc/ssh/sshd_config /etc/ssh/sshd_config.backup fi # Update SSH configuration sed -i "s/#\?Port .*/Port 8822/" /etc/ssh/sshd_config sed -i "s/#\?PermitRootLogin .*/PermitRootLogin prohibit-password/" /etc/ssh/sshd_config sed -i "s/#\?PasswordAuthentication .*/PasswordAuthentication no/" /etc/ssh/sshd_config sed -i "s/#\?PermitEmptyPasswords .*/PermitEmptyPasswords no/" /etc/ssh/sshd_config echo "" echo "Enter your SSH public key:" read -r SSH_KEY if [ -n "$SSH_KEY" ]; then # Validate key format if echo "$SSH_KEY" | grep -qE '^(ssh-rsa|ssh-ed25519|ecdsa-sha2-nistp[0-9]+) [A-Za-z0-9+/=]+'; then mkdir -p /root/.ssh echo "$SSH_KEY" >> /root/.ssh/authorized_keys chmod 700 /root/.ssh chmod 600 /root/.ssh/authorized_keys echo "SSH key added" else echo "Warning: Invalid SSH key format, skipping" fi fi # Validate SSH config before restarting if ! sshd -t; then echo "Error: sshd_config validation failed. Restoring backup..." >&2 cp /etc/ssh/sshd_config.backup /etc/ssh/sshd_config exit 1 fi # Restart SSH (try both service names for compatibility) if systemctl restart sshd 2>/dev/null; then echo "SSH configured on port 8822 (keys only)" elif systemctl restart ssh 2>/dev/null; then echo "SSH configured on port 8822 (keys only)" else echo "Error: Failed to restart SSH service" >&2 cp /etc/ssh/sshd_config.backup /etc/ssh/sshd_config exit 1 fi fi # Auto-detect network interface and get IP DEFAULT_IFACE=$(ip route | awk '/default/ {print $5; exit}') POD_IP=$(ip -4 addr show "$DEFAULT_IFACE" 2>/dev/null | grep -o 'inet [0-9.]*' | awk '{print $2}') || POD_IP="" # Wait for service to start with polling echo "Waiting for service to start..." for i in $(seq 1 30); do if systemctl is-active --quiet sleepypod.service; then SERVICE_STATUS="Running" break fi sleep 1 if [ $i -eq 30 ]; then SERVICE_STATUS="Failed (check logs with: sp-logs)" fi done echo "" echo "========================================" echo " Installation Complete!" echo "========================================" echo "" echo "Service Status: $SERVICE_STATUS" echo "" echo "Web Interface: http://$POD_IP:3000/" echo "" echo "Features:" echo " - Temperature & Power Scheduling" echo " - Alarm Management" echo " - Hardware Control via DAC socket" echo " - Automated job scheduler with timezone support" echo " - Heart rate, HRV, and breathing rate (piezo-processor)" echo " - Sleep session detection and movement tracking (sleep-detector)" echo " - Ambient, bed zone, and freezer temperature monitoring (environment-monitor)" echo "" echo "CLI Commands:" echo " sp-status - View service status" echo " sp-restart - Restart service" echo " sp-logs - View live logs" echo " sp-update - Update to latest version" echo " sp-freesleep - Switch to free-sleep" echo " sp-sleepypod - Switch to sleepypod" echo " sp-uninstall - Remove sleepypod" echo "" echo "Files:" echo " Config DB: $DATA_DIR/sleepypod.db" echo " Biometrics DB: $DATA_DIR/biometrics.db" echo " Config: $INSTALL_DIR/.env" echo " Module logs: journalctl -u sleepypod-piezo-processor.service" echo " journalctl -u sleepypod-sleep-detector.service" echo " journalctl -u sleepypod-environment-monitor.service" echo " App logs: journalctl -u sleepypod.service" echo ""