bambuddy/install/install.sh
maziggy 5bbfeefa65 fix(backup): diagnose an unwritable backup path instead of quoting errno 30 (#2544)
Nightly backups to a mounted NAS share ran from May and then stopped, failing
with [Errno 30] Read-only file system. The reporter checked folder permissions
-- correctly: the mount is gid=backup,dir_mode=0775, the service user is in that
group, and his own shell writes to the share fine.

Errno 30 is EROFS. A permission problem is errno 13. EROFS means the filesystem
refused the write, and it refused because we told it to: our systemd unit ships
ProtectSystem=strict, which mounts everything read-only inside the service's
mount namespace and carves back out only ReadWritePaths=<install> <data> <logs>.
A NAS share is not one of those three. Reads are unaffected -- which is why the
UI happily listed his existing backups from the share while being unable to
write a new one -- and his shell is outside the namespace entirely, so every
check he could think to run said the directory was fine.

Both installers write the unit file wholesale, so a ReadWritePaths line added by
hand disappeared on the next install, taking the backups with it. They now back
the old unit up (.bak-<timestamp>) and carry the operator's extra writable paths
forward, reporting which ones they kept. The unit template documents the
carve-out.

The output directory is probed with a real write when it is saved and when the
backup card loads, so an unwritable path is caught there rather than at 03:00
for a week. On failure the card names the cause and hands over the fix with the
operator's path already in it (systemctl edit bambuddy -> ReadWritePaths=...),
and a failed run reports the same diagnosis rather than the raw OSError. EROFS
outside systemd, permission-denied, out-of-space, not-a-directory and missing are
told apart, in all 11 locales.

Docker: a backup path that is not bind-mounted is writable -- the write lands in
the container's ephemeral layer and is lost on the next compose up. The probe
compares the directory's device against the container root and warns, with the
compose snippet that mounts it properly.
2026-07-12 08:44:53 +02:00

1047 lines
35 KiB
Bash
Executable file

#!/usr/bin/env bash
#
# BamBuddy Native Installation Script
# Supports: Debian/Ubuntu, RHEL/Fedora/CentOS, Arch Linux, macOS
#
# Usage:
# Interactive: curl -fsSL https://raw.githubusercontent.com/maziggy/bambuddy/main/install/install.sh -o install.sh && chmod +x install.sh && ./install.sh
# Unattended: ./install.sh --path /opt/bambuddy --port 8000 --yes
#
# Options:
# --path PATH Installation directory (default: /opt/bambuddy)
# --port PORT Port to listen on (default: 8000)
# --bind ADDRESS Bind address: 0.0.0.0 (network) or 127.0.0.1 (local only)
# --tz TIMEZONE Timezone (default: system timezone or UTC)
# --data-dir PATH Data directory (default: INSTALL_PATH/data)
# --log-dir PATH Log directory (default: INSTALL_PATH/logs)
# --debug Enable debug mode
# --log-level LEVEL Log level: DEBUG, INFO, WARNING, ERROR (default: INFO)
# --branch BRANCH Git branch to install (default: main)
# --no-service Skip systemd service setup (Linux only)
# --set-system-tz Set system timezone to match (for unattended installs)
# --yes, -y Non-interactive mode, accept defaults
# --help, -h Show this help message
#
set -e
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
CYAN='\033[0;36m'
NC='\033[0m' # No Color
BOLD='\033[1m'
# Default values
DEFAULT_INSTALL_PATH="/opt/bambuddy"
DEFAULT_PORT="8000"
DEFAULT_BIND_ADDRESS="0.0.0.0"
DEFAULT_LOG_LEVEL="INFO"
DEFAULT_DEBUG="false"
# Script variables
INSTALL_PATH=""
PORT=""
BIND_ADDRESS=""
TIMEZONE=""
DATA_DIR=""
LOG_DIR=""
DEBUG_MODE=""
LOG_LEVEL=""
SKIP_SERVICE="false"
SET_SYSTEM_TZ=""
NON_INTERACTIVE="false"
OS_TYPE=""
PKG_MANAGER=""
PYTHON_CMD=""
BRANCH=""
SERVICE_USER="bambuddy"
# -----------------------------------------------------------------------------
# Helper Functions
# -----------------------------------------------------------------------------
print_banner() {
echo -e "${CYAN}"
echo "╔════════════════════════════════════════════════════════╗"
echo "║ ║"
echo "║ ____ _ _ _ ║"
echo "║ | __ ) __ _ _ __ ___ | |__ _ _ __| | __| |_ _ ║"
echo "║ | _ \\ / _\` | '_ \` _ \\| '_ \\| | | |/ _\` |/ _\` | | | | ║"
echo "║ | |_) | (_| | | | | | | |_) | |_| | (_| | (_| | |_| | ║"
echo "║ |____/ \\__,_|_| |_| |_|_.__/ \\__,_|\\__,_|\\__,_|\\__, | ║"
echo "║ |___/ ║"
echo "║ ║"
echo "║ Native Installation Script ║"
echo "║ ║"
echo "╚════════════════════════════════════════════════════════╝"
echo -e "${NC}"
}
log_info() {
echo -e "${BLUE}[INFO]${NC} $1"
}
log_success() {
echo -e "${GREEN}[OK]${NC} $1"
}
log_warn() {
echo -e "${YELLOW}[WARN]${NC} $1"
}
log_error() {
echo -e "${RED}[ERROR]${NC} $1"
}
prompt() {
local prompt_text="$1"
local default_value="$2"
local var_name="$3"
if [[ "$NON_INTERACTIVE" == "true" ]]; then
eval "$var_name=\"$default_value\""
return
fi
if [[ -n "$default_value" ]]; then
echo -en "${BOLD}$prompt_text${NC} [${CYAN}$default_value${NC}]: "
else
echo -en "${BOLD}$prompt_text${NC}: "
fi
read -r input
if [[ -z "$input" ]]; then
eval "$var_name=\"$default_value\""
else
eval "$var_name=\"$input\""
fi
}
prompt_yes_no() {
local prompt_text="$1"
local default="$2" # y or n
if [[ "$NON_INTERACTIVE" == "true" ]]; then
[[ "$default" == "y" ]] && return 0 || return 1
fi
local yn_hint="[y/n]"
[[ "$default" == "y" ]] && yn_hint="[Y/n]"
[[ "$default" == "n" ]] && yn_hint="[y/N]"
while true; do
echo -en "${BOLD}$prompt_text${NC} $yn_hint: "
read -r yn
[[ -z "$yn" ]] && yn="$default"
case "$yn" in
[Yy]* ) return 0;;
[Nn]* ) return 1;;
* ) echo "Please answer yes or no.";;
esac
done
}
show_help() {
echo "BamBuddy Native Installation Script"
echo ""
echo "Usage: $0 [OPTIONS]"
echo ""
echo "Options:"
echo " --path PATH Installation directory (default: /opt/bambuddy)"
echo " --port PORT Port to listen on (default: 8000)"
echo " --bind ADDRESS Bind address: 0.0.0.0 (network) or 127.0.0.1 (local only)"
echo " --tz TIMEZONE Timezone (default: system timezone or UTC)"
echo " --data-dir PATH Data directory (default: INSTALL_PATH/data)"
echo " --log-dir PATH Log directory (default: INSTALL_PATH/logs)"
echo " --debug Enable debug mode"
echo " --log-level LEVEL Log level: DEBUG, INFO, WARNING, ERROR (default: INFO)"
echo " --branch BRANCH Git branch to install (default: main)"
echo " --no-service Skip systemd service setup (Linux only)"
echo " --set-system-tz Set system timezone to match (for unattended installs)"
echo " --yes, -y Non-interactive mode, accept defaults"
echo " --help, -h Show this help message"
echo ""
echo "Examples:"
echo " Interactive installation:"
echo " ./install.sh"
echo ""
echo " Unattended installation with custom settings:"
echo " ./install.sh --path /srv/bambuddy --port 3000 --tz America/New_York --yes"
echo ""
echo " Minimal unattended installation:"
echo " ./install.sh -y"
exit 0
}
# -----------------------------------------------------------------------------
# System Detection
# -----------------------------------------------------------------------------
detect_os() {
if [[ "$OSTYPE" == "darwin"* ]]; then
OS_TYPE="macos"
PKG_MANAGER="brew"
return
fi
if [[ -f /etc/os-release ]]; then
. /etc/os-release
case "$ID" in
ubuntu|debian|raspbian|linuxmint|pop)
OS_TYPE="debian"
PKG_MANAGER="apt"
;;
fedora|rhel|centos|rocky|almalinux|ol)
OS_TYPE="rhel"
if command -v dnf &>/dev/null; then
PKG_MANAGER="dnf"
else
PKG_MANAGER="yum"
fi
;;
arch|manjaro|endeavouros)
OS_TYPE="arch"
PKG_MANAGER="pacman"
;;
opensuse*|sles)
OS_TYPE="suse"
PKG_MANAGER="zypper"
;;
*)
log_error "Unsupported Linux distribution: $ID"
exit 1
;;
esac
else
log_error "Cannot detect operating system"
exit 1
fi
}
detect_python() {
# Try python3 first, then python
if command -v python3 &>/dev/null; then
PYTHON_CMD="python3"
elif command -v python &>/dev/null; then
local version
version=$(python --version 2>&1 | cut -d' ' -f2 | cut -d'.' -f1)
if [[ "$version" -ge 3 ]]; then
PYTHON_CMD="python"
fi
fi
if [[ -z "$PYTHON_CMD" ]]; then
return 1
fi
# Check version >= 3.10
local version
version=$($PYTHON_CMD -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")')
local major minor
major=$(echo "$version" | cut -d'.' -f1)
minor=$(echo "$version" | cut -d'.' -f2)
if [[ "$major" -lt 3 ]] || { [[ "$major" -eq 3 ]] && [[ "$minor" -lt 10 ]]; }; then
log_warn "Python $version found, but 3.10 or newer is required"
return 1
fi
log_success "Found Python $version"
return 0
}
detect_timezone() {
if [[ -n "$TIMEZONE" ]]; then
return 0
fi
# Try to get system timezone (with error handling for set -e)
TIMEZONE=""
if [[ -f /etc/timezone ]]; then
TIMEZONE=$(cat /etc/timezone 2>/dev/null) || true
fi
if [[ -z "$TIMEZONE" ]] && [[ -L /etc/localtime ]]; then
TIMEZONE=$(readlink /etc/localtime 2>/dev/null | sed 's|.*/zoneinfo/||') || true
fi
if [[ -z "$TIMEZONE" ]] && command -v timedatectl &>/dev/null; then
TIMEZONE=$(timedatectl show --property=Timezone --value 2>/dev/null) || true
fi
# Default to UTC if not found (use if/then to avoid set -e issue with &&)
if [[ -z "$TIMEZONE" ]]; then
TIMEZONE="UTC"
fi
return 0
}
# -----------------------------------------------------------------------------
# Package Installation
# -----------------------------------------------------------------------------
install_dependencies() {
log_info "Installing system dependencies..."
case "$PKG_MANAGER" in
apt)
sudo apt-get update
sudo apt-get install -y python3 python3-pip python3-venv git curl ffmpeg
;;
dnf|yum)
sudo $PKG_MANAGER install -y python3 python3-pip git curl ffmpeg
;;
pacman)
sudo pacman -Sy --noconfirm python python-pip git curl ffmpeg
;;
zypper)
sudo zypper install -y python3 python3-pip git curl ffmpeg
;;
brew)
# Check if Homebrew is installed
if ! command -v brew &>/dev/null; then
log_error "Homebrew not found. Please install it first: https://brew.sh"
exit 1
fi
brew install python git curl ffmpeg
;;
esac
log_success "System dependencies installed"
}
# -----------------------------------------------------------------------------
# Installation Steps
# -----------------------------------------------------------------------------
create_user() {
if [[ "$OS_TYPE" == "macos" ]]; then
return # Skip user creation on macOS
fi
if id "$SERVICE_USER" &>/dev/null; then
log_info "User '$SERVICE_USER' already exists"
return
fi
log_info "Creating service user '$SERVICE_USER'..."
sudo useradd --system --shell /usr/sbin/nologin --home-dir "$INSTALL_PATH" "$SERVICE_USER"
log_success "Service user created"
}
# Ensure a directory exists and is owned by the current user. Used on macOS so
# the install tree stays user-owned (git/venv/npm never touch a root-owned dir).
# Only elevates when the target's parent is root-owned (e.g. /opt); a path under
# $HOME is created without any sudo prompt.
ensure_user_owned_dir() {
local dir="$1"
if [[ -d "$dir" ]] && [[ -w "$dir" ]]; then
return
fi
if mkdir -p "$dir" 2>/dev/null; then
return
fi
log_info "Creating $dir (requires your password)..."
sudo mkdir -p "$dir"
sudo chown "$(id -un):$(id -gn)" "$dir"
}
download_bambuddy() {
log_info "Downloading BamBuddy..."
# Validate branch exists on remote before proceeding
if ! git ls-remote --exit-code --heads https://github.com/maziggy/bambuddy.git "$BRANCH" &>/dev/null; then
log_error "Branch '$BRANCH' not found in the BamBuddy repository."
log_info "Available branches:"
git ls-remote --heads https://github.com/maziggy/bambuddy.git | sed 's|.*refs/heads/| - |'
exit 1
fi
if [[ "$OS_TYPE" == "macos" ]]; then
# macOS has no service user — the whole install runs as the current user.
# Create the target user-owned (elevating only if its parent is root-owned,
# e.g. /opt), then clone/update without sudo so the venv/frontend the user
# builds next aren't fighting a root-owned tree.
ensure_user_owned_dir "$INSTALL_PATH"
if [[ -d "$INSTALL_PATH/.git" ]]; then
log_info "Existing installation found, updating..."
git config --global --add safe.directory "$INSTALL_PATH" 2>/dev/null || true
cd "$INSTALL_PATH"
git fetch origin
git checkout "$BRANCH" 2>/dev/null || git checkout -b "$BRANCH" "origin/$BRANCH"
git reset --hard "origin/$BRANCH"
else
git clone --branch "$BRANCH" https://github.com/maziggy/bambuddy.git "$INSTALL_PATH"
fi
elif [[ -d "$INSTALL_PATH/.git" ]]; then
log_info "Existing installation found, updating..."
# Add safe.directory to avoid "dubious ownership" error when running as root
git config --global --add safe.directory "$INSTALL_PATH" 2>/dev/null || true
cd "$INSTALL_PATH"
git fetch origin
git checkout "$BRANCH" 2>/dev/null || git checkout -b "$BRANCH" "origin/$BRANCH"
git reset --hard "origin/$BRANCH"
# Ensure correct ownership after update
sudo chown -R "$SERVICE_USER:$SERVICE_USER" "$INSTALL_PATH" 2>/dev/null || true
else
# Clone as root so we have write access regardless of the installing user,
# then hand ownership to the service user. Previously we chown'd the empty
# dir to the service user before the clone, which left the install-running
# user (not root, not bambuddy) unable to write .git into it.
sudo mkdir -p "$INSTALL_PATH"
sudo git clone --branch "$BRANCH" https://github.com/maziggy/bambuddy.git "$INSTALL_PATH"
sudo chown -R "$SERVICE_USER:$SERVICE_USER" "$INSTALL_PATH" 2>/dev/null || true
fi
log_success "BamBuddy downloaded to $INSTALL_PATH (branch: $BRANCH)"
}
setup_virtualenv() {
log_info "Setting up Python virtual environment..."
cd "$INSTALL_PATH"
if [[ "$OS_TYPE" == "macos" ]]; then
$PYTHON_CMD -m venv venv
"$INSTALL_PATH/venv/bin/pip" install --upgrade pip
"$INSTALL_PATH/venv/bin/pip" install -r requirements.txt
else
# Venv is owned by the service user, so pip must also run as that user —
# otherwise `pip install --upgrade pip` fails trying to rewrite its own
# binary inside the venv it doesn't own.
sudo -u "$SERVICE_USER" $PYTHON_CMD -m venv venv 2>/dev/null || $PYTHON_CMD -m venv venv
sudo -u "$SERVICE_USER" "$INSTALL_PATH/venv/bin/pip" install --upgrade pip
sudo -u "$SERVICE_USER" "$INSTALL_PATH/venv/bin/pip" install -r requirements.txt
fi
log_success "Virtual environment configured"
}
check_node_version() {
# Returns 0 if Node.js 20+ is available, 1 otherwise
if ! command -v node &>/dev/null; then
return 1
fi
local version
version=$(node --version 2>/dev/null | sed 's/^v//')
local major
major=$(echo "$version" | cut -d'.' -f1)
if [[ "$major" -ge 20 ]]; then
log_success "Found Node.js v$version"
return 0
else
log_warn "Found Node.js v$version (need 20+)"
return 1
fi
}
install_nodejs() {
log_info "Installing Node.js 22..."
case "$PKG_MANAGER" in
apt)
# Remove old nodejs if present
sudo apt-get remove -y nodejs npm 2>/dev/null || true
curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -
sudo apt-get install -y nodejs
;;
dnf|yum)
sudo $PKG_MANAGER remove -y nodejs npm 2>/dev/null || true
curl -fsSL https://rpm.nodesource.com/setup_22.x | sudo bash -
sudo $PKG_MANAGER install -y nodejs
;;
pacman)
sudo pacman -S --noconfirm nodejs npm
;;
zypper)
sudo zypper install -y nodejs22
;;
brew)
brew install node@22
brew link --overwrite node@22
;;
*)
log_error "Please install Node.js 20+ manually: https://nodejs.org/"
exit 1
;;
esac
# Refresh PATH
hash -r 2>/dev/null || true
}
build_frontend() {
log_info "Building frontend..."
cd "$INSTALL_PATH/frontend"
# Check for Node.js 20+
if ! check_node_version; then
install_nodejs
# Verify installation
if ! check_node_version; then
log_error "Failed to install Node.js 20+. Please install manually."
exit 1
fi
fi
# Frontend tree is owned by the service user, so npm must run as that user —
# otherwise creating node_modules/ and writing build output fails. macOS
# keeps the current-user flow since it has no service user.
if [[ "$OS_TYPE" == "macos" ]]; then
npm ci
npm run build
else
sudo -H -u "$SERVICE_USER" npm ci
sudo -H -u "$SERVICE_USER" npm run build
fi
log_success "Frontend built"
}
create_directories() {
log_info "Creating data directories..."
if [[ "$OS_TYPE" == "macos" ]]; then
# Rootless: DATA_DIR/LOG_DIR default under the user-owned install path.
ensure_user_owned_dir "$DATA_DIR"
ensure_user_owned_dir "$LOG_DIR"
else
sudo mkdir -p "$DATA_DIR" "$LOG_DIR"
sudo chown -R "$SERVICE_USER:$SERVICE_USER" "$DATA_DIR" "$LOG_DIR"
fi
log_success "Directories created"
}
create_env_file() {
log_info "Creating environment configuration..."
local env_file="$INSTALL_PATH/.env"
# Note: Only include settings recognized by the app's pydantic Settings class
# Other settings (PORT, BIND_ADDRESS, DATA_DIR, LOG_DIR, TZ) are set in systemd service
cat > /tmp/bambuddy.env << EOF
# BamBuddy Configuration
# Generated by install.sh on $(date)
# Debug mode (true = verbose logging)
DEBUG=$DEBUG_MODE
# Log level (only used when DEBUG=false)
# Options: DEBUG, INFO, WARNING, ERROR
LOG_LEVEL=$LOG_LEVEL
# Enable file logging
LOG_TO_FILE=true
EOF
if [[ "$OS_TYPE" == "macos" ]]; then
# Rootless: install path is user-owned, so write it directly.
mv /tmp/bambuddy.env "$env_file"
chmod 600 "$env_file"
else
sudo mv /tmp/bambuddy.env "$env_file"
sudo chown "$SERVICE_USER:$SERVICE_USER" "$env_file"
sudo chmod 600 "$env_file"
fi
log_success "Environment file created at $env_file"
}
create_systemd_service() {
if [[ "$OS_TYPE" == "macos" ]] || [[ "$SKIP_SERVICE" == "true" ]]; then
return
fi
log_info "Creating systemd service..."
# ProtectHome=true hides /home/* from the service, which breaks ExecStart
# when INSTALL_PATH lives under /home (issue #1685). Loosen to read-only in
# that case so the venv binary is still resolvable; ReadWritePaths below
# re-grants writes for the install/data/log dirs.
local protect_home="true"
if [[ "$INSTALL_PATH" == /home/* ]]; then
protect_home="read-only"
fi
# This function overwrites /etc/systemd/system/bambuddy.service outright. Any
# ReadWritePaths the operator added by hand — a NAS share for Scheduled
# Backups, typically — used to disappear with it, and the next backup failed
# with EROFS ("Read-only file system"), which reads like a permission problem
# and is not one (issue #2544). Back the old unit up and carry those paths
# forward.
local existing_unit="/etc/systemd/system/bambuddy.service"
local extra_rw=""
if [[ -f "$existing_unit" ]]; then
local backup_unit="${existing_unit}.bak-$(date +%Y%m%d-%H%M%S)"
sudo cp "$existing_unit" "$backup_unit"
log_info "Existing service backed up to $backup_unit"
local prev_rw
prev_rw=$(sudo grep -hE '^ReadWritePaths=' "$existing_unit" 2>/dev/null | sed 's/^ReadWritePaths=//' || true)
local p
for p in $prev_rw; do
case "$p" in
"$DATA_DIR" | "$LOG_DIR" | "$INSTALL_PATH") continue ;;
esac
extra_rw+=" $p"
done
if [[ -n "$extra_rw" ]]; then
log_info "Keeping custom writable paths from the previous service:$extra_rw"
fi
fi
cat > /tmp/bambuddy.service << EOF
[Unit]
Description=BamBuddy - Bambu Lab Print Management
Documentation=https://github.com/maziggy/bambuddy
After=network.target
[Service]
Type=simple
User=$SERVICE_USER
Group=$SERVICE_USER
WorkingDirectory=$INSTALL_PATH
# App settings from .env file
EnvironmentFile=$INSTALL_PATH/.env
# Service settings (not in .env to avoid pydantic validation errors)
Environment="DATA_DIR=$DATA_DIR"
Environment="LOG_DIR=$LOG_DIR"
Environment="TZ=$TIMEZONE"
# --loop asyncio required: uvloop can truncate VP FTP uploads (#1896)
# --timeout-graceful-shutdown required: uvicorn otherwise waits forever for
# in-flight requests, and an MJPEG camera stream never completes — one open
# camera tile hangs the stop until systemd SIGKILLs, skipping the WAL
# checkpoint and the MQTT / virtual-printer teardown.
ExecStart=$INSTALL_PATH/venv/bin/uvicorn backend.app.main:app --host $BIND_ADDRESS --port $PORT --loop asyncio --timeout-graceful-shutdown 5
Restart=on-failure
RestartSec=5
# Backstop only — uvicorn bounds its own wait at 5s and teardown takes ~1-2s.
TimeoutStopSec=30
StandardOutput=journal
StandardError=journal
# Allow binding to privileged ports (322, 990, 2024-2026) for Virtual Printer proxy mode
AmbientCapabilities=CAP_NET_BIND_SERVICE
# Security hardening
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=$protect_home
# ProtectSystem=strict makes EVERY path outside the ones below read-only for this
# service — including a NAS share you mounted yourself and can write to from your
# own shell. If you point Scheduled Backups at such a directory, add it here, or
# better in a drop-in that survives a reinstall (#2544):
#
# sudo systemctl edit bambuddy
# [Service]
# ReadWritePaths=/mnt/your-nas-share
#
ReadWritePaths=$DATA_DIR $LOG_DIR $INSTALL_PATH$extra_rw
[Install]
WantedBy=multi-user.target
EOF
sudo mv /tmp/bambuddy.service /etc/systemd/system/bambuddy.service
sudo systemctl daemon-reload
log_success "Systemd service created"
if prompt_yes_no "Enable BamBuddy to start on boot?" "y"; then
sudo systemctl enable bambuddy
log_success "Service enabled"
fi
if prompt_yes_no "Start BamBuddy now?" "y"; then
sudo systemctl start bambuddy
sleep 2
if sudo systemctl is-active --quiet bambuddy; then
log_success "BamBuddy is running"
else
log_warn "Service may have failed to start. Check: sudo journalctl -u bambuddy -f"
fi
fi
}
create_launchd_service() {
if [[ "$OS_TYPE" != "macos" ]] || [[ "$SKIP_SERVICE" == "true" ]]; then
return
fi
log_info "Creating launchd service..."
local plist_path="$HOME/Library/LaunchAgents/com.bambuddy.app.plist"
cat > "$plist_path" << EOF
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.bambuddy.app</string>
<key>ProgramArguments</key>
<array>
<string>$INSTALL_PATH/venv/bin/uvicorn</string>
<string>backend.app.main:app</string>
<string>--host</string>
<string>$BIND_ADDRESS</string>
<string>--port</string>
<string>$PORT</string>
<!-- the loop asyncio flag below is required: uvloop can truncate VP FTP uploads, #1896 -->
<string>--loop</string>
<string>asyncio</string>
<!-- required: uvicorn otherwise waits forever for in-flight requests, and an
MJPEG camera stream never completes — one open camera tile hangs the stop
until launchd SIGKILLs, skipping the WAL checkpoint and MQTT teardown -->
<string>--timeout-graceful-shutdown</string>
<string>5</string>
</array>
<key>WorkingDirectory</key>
<string>$INSTALL_PATH</string>
<key>EnvironmentVariables</key>
<dict>
<key>DEBUG</key>
<string>$DEBUG_MODE</string>
<key>LOG_LEVEL</key>
<string>$LOG_LEVEL</string>
<key>DATA_DIR</key>
<string>$DATA_DIR</string>
<key>LOG_DIR</key>
<string>$LOG_DIR</string>
<key>TZ</key>
<string>$TIMEZONE</string>
</dict>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>StandardOutPath</key>
<string>$LOG_DIR/bambuddy.log</string>
<key>StandardErrorPath</key>
<string>$LOG_DIR/bambuddy.error.log</string>
</dict>
</plist>
EOF
log_success "Launchd plist created at $plist_path"
if prompt_yes_no "Load BamBuddy service now?" "y"; then
launchctl load "$plist_path"
sleep 2
if launchctl list | grep -q "com.bambuddy.app"; then
log_success "BamBuddy is running"
else
log_warn "Service may have failed to start. Check: cat $LOG_DIR/bambuddy.error.log"
fi
fi
}
# -----------------------------------------------------------------------------
# Main Installation Flow
# -----------------------------------------------------------------------------
parse_args() {
while [[ $# -gt 0 ]]; do
case "$1" in
--path)
INSTALL_PATH="$2"
shift 2
;;
--port)
PORT="$2"
shift 2
;;
--bind)
BIND_ADDRESS="$2"
shift 2
;;
--tz)
TIMEZONE="$2"
shift 2
;;
--data-dir)
DATA_DIR="$2"
shift 2
;;
--log-dir)
LOG_DIR="$2"
shift 2
;;
--debug)
DEBUG_MODE="true"
shift
;;
--log-level)
LOG_LEVEL="$2"
shift 2
;;
--branch)
BRANCH="$2"
shift 2
;;
--no-service)
SKIP_SERVICE="true"
shift
;;
--set-system-tz)
SET_SYSTEM_TZ="true"
shift
;;
--yes|-y)
NON_INTERACTIVE="true"
shift
;;
--help|-h)
show_help
;;
*)
log_error "Unknown option: $1"
show_help
;;
esac
done
}
gather_config() {
echo ""
echo -e "${BOLD}Installation Configuration${NC}"
echo -e "${CYAN}─────────────────────────────────────────${NC}"
echo ""
# Installation path
[[ -z "$INSTALL_PATH" ]] && prompt "Installation directory" "$DEFAULT_INSTALL_PATH" INSTALL_PATH
# Branch
[[ -z "$BRANCH" ]] && prompt "Git branch" "main" BRANCH
# Port
[[ -z "$PORT" ]] && prompt "Port to listen on" "$DEFAULT_PORT" PORT
# Bind address
if [[ -z "$BIND_ADDRESS" ]]; then
echo ""
echo "Network access:"
echo " 0.0.0.0 - Accessible from other devices on your network (recommended)"
echo " 127.0.0.1 - Only accessible from this machine"
prompt "Bind address" "$DEFAULT_BIND_ADDRESS" BIND_ADDRESS
fi
# Timezone
detect_timezone
prompt "Timezone" "$TIMEZONE" TIMEZONE
# Offer to set system timezone if different from current (skip if already set via --set-system-tz)
if [[ -z "$SET_SYSTEM_TZ" ]]; then
local current_tz
current_tz=$(timedatectl show --property=Timezone --value 2>/dev/null) || true
if [[ -n "$TIMEZONE" ]] && [[ "$TIMEZONE" != "$current_tz" ]]; then
# Default to "n" so unattended installs don't change system TZ unless --set-system-tz is used
if prompt_yes_no "Set system timezone to $TIMEZONE?" "n"; then
SET_SYSTEM_TZ="true"
else
SET_SYSTEM_TZ="false"
fi
else
SET_SYSTEM_TZ="false"
fi
fi
# Data directory
[[ -z "$DATA_DIR" ]] && DATA_DIR="$INSTALL_PATH/data"
prompt "Data directory" "$DATA_DIR" DATA_DIR
# Log directory
[[ -z "$LOG_DIR" ]] && LOG_DIR="$INSTALL_PATH/logs"
prompt "Log directory" "$LOG_DIR" LOG_DIR
# Debug mode
if [[ -z "$DEBUG_MODE" ]]; then
if prompt_yes_no "Enable debug mode?" "n"; then
DEBUG_MODE="true"
else
DEBUG_MODE="false"
fi
fi
# Log level
if [[ -z "$LOG_LEVEL" ]]; then
echo ""
echo "Log levels: DEBUG, INFO, WARNING, ERROR"
prompt "Log level" "$DEFAULT_LOG_LEVEL" LOG_LEVEL
fi
# Confirm
echo ""
echo -e "${BOLD}Installation Summary${NC}"
echo -e "${CYAN}─────────────────────────────────────────${NC}"
echo -e " Install path: ${GREEN}$INSTALL_PATH${NC}"
if [[ "$BRANCH" != "main" ]]; then
echo -e " Branch: ${YELLOW}$BRANCH${NC} (beta)"
else
echo -e " Branch: ${GREEN}$BRANCH${NC}"
fi
echo -e " Port: ${GREEN}$PORT${NC}"
echo -e " Bind address: ${GREEN}$BIND_ADDRESS${NC}"
echo -e " Timezone: ${GREEN}$TIMEZONE${NC}"
echo -e " Data dir: ${GREEN}$DATA_DIR${NC}"
echo -e " Log dir: ${GREEN}$LOG_DIR${NC}"
echo -e " Debug mode: ${GREEN}$DEBUG_MODE${NC}"
echo -e " Log level: ${GREEN}$LOG_LEVEL${NC}"
echo ""
if ! prompt_yes_no "Proceed with installation?" "y"; then
echo "Installation cancelled."
exit 0
fi
}
main() {
parse_args "$@"
print_banner
# Check if running via pipe (curl | bash) - interactive mode won't work
if [[ ! -t 0 ]] && [[ "$NON_INTERACTIVE" != "true" ]]; then
log_error "Interactive mode requires a terminal."
log_info "When using 'curl | bash', you must use non-interactive mode:"
echo ""
echo " curl -fsSL URL | bash -s -- --yes"
echo ""
log_info "Or download and run directly:"
echo ""
echo " curl -fsSL URL -o install.sh && chmod +x install.sh && ./install.sh"
echo ""
exit 1
fi
# Check for root (we need sudo for some operations)
if [[ "$EUID" -eq 0 ]] && [[ "$OS_TYPE" != "macos" ]]; then
log_warn "Running as root. Consider using a regular user with sudo privileges."
fi
# Detect system
log_info "Detecting system..."
detect_os
log_success "Detected: $OS_TYPE (package manager: $PKG_MANAGER)"
# macOS must run rootless. Homebrew hard-refuses to run as root, and a venv
# / node_modules created by root can't be managed by the launchd agent (which
# runs as the user). Bail early with an actionable message instead of dying
# halfway through on "brew: running as root is not supported".
if [[ "$OS_TYPE" == "macos" ]]; then
if [[ "$EUID" -eq 0 ]]; then
log_error "Don't run the macOS installer with sudo."
log_info "Homebrew, the Python venv, and the launchd agent must all be created as your"
log_info "normal user. Re-run without sudo (the script elevates only when it truly needs to):"
echo ""
echo " ./install.sh"
echo ""
exit 1
fi
# /opt requires sudo to create and would leave a root-owned tree; default
# macOS installs to a user-owned location so the whole flow stays rootless.
if [[ "$DEFAULT_INSTALL_PATH" == "/opt/bambuddy" ]]; then
DEFAULT_INSTALL_PATH="$HOME/bambuddy"
fi
fi
# Check/install Python
if ! detect_python; then
log_info "Python 3.10+ not found, will install..."
fi
# Gather configuration
gather_config
# Install steps
echo ""
echo -e "${BOLD}Starting Installation${NC}"
echo -e "${CYAN}─────────────────────────────────────────${NC}"
echo ""
install_dependencies
detect_python || { log_error "Failed to install Python"; exit 1; }
# Set system timezone if requested
if [[ "$SET_SYSTEM_TZ" == "true" ]]; then
log_info "Setting system timezone to $TIMEZONE..."
if [[ "$OS_TYPE" == "macos" ]]; then
sudo systemsetup -settimezone "$TIMEZONE" 2>/dev/null || true
else
sudo timedatectl set-timezone "$TIMEZONE" 2>/dev/null || true
fi
log_success "System timezone set to $TIMEZONE"
fi
if [[ "$OS_TYPE" != "macos" ]]; then
create_user
else
SERVICE_USER="$USER"
fi
download_bambuddy
setup_virtualenv
build_frontend
create_directories
create_env_file
if [[ "$OS_TYPE" == "macos" ]]; then
create_launchd_service
else
create_systemd_service
fi
# Done!
echo ""
echo -e "${GREEN}╔══════════════════════════════════════════════════════════════╗${NC}"
echo -e "${GREEN}║ ║${NC}"
echo -e "${GREEN}║ Installation Complete! ║${NC}"
echo -e "${GREEN}║ ║${NC}"
echo -e "${GREEN}╚══════════════════════════════════════════════════════════════╝${NC}"
echo ""
# Show appropriate URL based on bind address
if [[ "$BIND_ADDRESS" == "0.0.0.0" ]]; then
local ip_addr
ip_addr=$(hostname -I 2>/dev/null | awk '{print $1}') || ip_addr="<your-ip>"
echo -e " ${BOLD}Access BamBuddy:${NC} ${CYAN}http://localhost:$PORT${NC}"
echo -e " ${CYAN}http://$ip_addr:$PORT${NC} (from other devices)"
else
echo -e " ${BOLD}Access BamBuddy:${NC} ${CYAN}http://localhost:$PORT${NC}"
fi
echo ""
if [[ "$OS_TYPE" == "macos" ]]; then
echo -e " ${BOLD}Manage service:${NC}"
echo -e " Start: launchctl load ~/Library/LaunchAgents/com.bambuddy.app.plist"
echo -e " Stop: launchctl unload ~/Library/LaunchAgents/com.bambuddy.app.plist"
echo -e " Logs: tail -f $LOG_DIR/bambuddy.log"
else
echo -e " ${BOLD}Manage service:${NC}"
echo -e " Status: sudo systemctl status bambuddy"
echo -e " Start: sudo systemctl start bambuddy"
echo -e " Stop: sudo systemctl stop bambuddy"
echo -e " Logs: sudo journalctl -u bambuddy -f"
fi
echo ""
echo -e " ${BOLD}Update BamBuddy:${NC}"
echo -e " cd $INSTALL_PATH && git pull && source venv/bin/activate"
echo -e " pip install -r requirements.txt && cd frontend && npm ci && npm run build"
if [[ "$OS_TYPE" != "macos" ]]; then
echo -e " sudo systemctl restart bambuddy"
fi
echo ""
echo -e " ${BOLD}Documentation:${NC} ${CYAN}https://wiki.bambuddy.cool${NC}"
echo ""
}
main "$@"