install.sh 32 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000
  1. #!/usr/bin/env bash
  2. #
  3. # BamBuddy Native Installation Script
  4. # Supports: Debian/Ubuntu, RHEL/Fedora/CentOS, Arch Linux, macOS
  5. #
  6. # Usage:
  7. # Interactive: curl -fsSL https://raw.githubusercontent.com/maziggy/bambuddy/main/install/install.sh -o install.sh && chmod +x install.sh && ./install.sh
  8. # Unattended: ./install.sh --path /opt/bambuddy --port 8000 --yes
  9. #
  10. # Options:
  11. # --path PATH Installation directory (default: /opt/bambuddy)
  12. # --port PORT Port to listen on (default: 8000)
  13. # --bind ADDRESS Bind address: 0.0.0.0 (network) or 127.0.0.1 (local only)
  14. # --tz TIMEZONE Timezone (default: system timezone or UTC)
  15. # --data-dir PATH Data directory (default: INSTALL_PATH/data)
  16. # --log-dir PATH Log directory (default: INSTALL_PATH/logs)
  17. # --debug Enable debug mode
  18. # --log-level LEVEL Log level: DEBUG, INFO, WARNING, ERROR (default: INFO)
  19. # --branch BRANCH Git branch to install (default: main)
  20. # --no-service Skip systemd service setup (Linux only)
  21. # --set-system-tz Set system timezone to match (for unattended installs)
  22. # --yes, -y Non-interactive mode, accept defaults
  23. # --help, -h Show this help message
  24. #
  25. set -e
  26. # Colors for output
  27. RED='\033[0;31m'
  28. GREEN='\033[0;32m'
  29. YELLOW='\033[1;33m'
  30. BLUE='\033[0;34m'
  31. CYAN='\033[0;36m'
  32. NC='\033[0m' # No Color
  33. BOLD='\033[1m'
  34. # Default values
  35. DEFAULT_INSTALL_PATH="/opt/bambuddy"
  36. DEFAULT_PORT="8000"
  37. DEFAULT_BIND_ADDRESS="0.0.0.0"
  38. DEFAULT_LOG_LEVEL="INFO"
  39. DEFAULT_DEBUG="false"
  40. # Script variables
  41. INSTALL_PATH=""
  42. PORT=""
  43. BIND_ADDRESS=""
  44. TIMEZONE=""
  45. DATA_DIR=""
  46. LOG_DIR=""
  47. DEBUG_MODE=""
  48. LOG_LEVEL=""
  49. SKIP_SERVICE="false"
  50. SET_SYSTEM_TZ=""
  51. NON_INTERACTIVE="false"
  52. OS_TYPE=""
  53. PKG_MANAGER=""
  54. PYTHON_CMD=""
  55. BRANCH=""
  56. SERVICE_USER="bambuddy"
  57. # -----------------------------------------------------------------------------
  58. # Helper Functions
  59. # -----------------------------------------------------------------------------
  60. print_banner() {
  61. echo -e "${CYAN}"
  62. echo "╔════════════════════════════════════════════════════════╗"
  63. echo "║ ║"
  64. echo "║ ____ _ _ _ ║"
  65. echo "║ | __ ) __ _ _ __ ___ | |__ _ _ __| | __| |_ _ ║"
  66. echo "║ | _ \\ / _\` | '_ \` _ \\| '_ \\| | | |/ _\` |/ _\` | | | | ║"
  67. echo "║ | |_) | (_| | | | | | | |_) | |_| | (_| | (_| | |_| | ║"
  68. echo "║ |____/ \\__,_|_| |_| |_|_.__/ \\__,_|\\__,_|\\__,_|\\__, | ║"
  69. echo "║ |___/ ║"
  70. echo "║ ║"
  71. echo "║ Native Installation Script ║"
  72. echo "║ ║"
  73. echo "╚════════════════════════════════════════════════════════╝"
  74. echo -e "${NC}"
  75. }
  76. log_info() {
  77. echo -e "${BLUE}[INFO]${NC} $1"
  78. }
  79. log_success() {
  80. echo -e "${GREEN}[OK]${NC} $1"
  81. }
  82. log_warn() {
  83. echo -e "${YELLOW}[WARN]${NC} $1"
  84. }
  85. log_error() {
  86. echo -e "${RED}[ERROR]${NC} $1"
  87. }
  88. prompt() {
  89. local prompt_text="$1"
  90. local default_value="$2"
  91. local var_name="$3"
  92. if [[ "$NON_INTERACTIVE" == "true" ]]; then
  93. eval "$var_name=\"$default_value\""
  94. return
  95. fi
  96. if [[ -n "$default_value" ]]; then
  97. echo -en "${BOLD}$prompt_text${NC} [${CYAN}$default_value${NC}]: "
  98. else
  99. echo -en "${BOLD}$prompt_text${NC}: "
  100. fi
  101. read -r input
  102. if [[ -z "$input" ]]; then
  103. eval "$var_name=\"$default_value\""
  104. else
  105. eval "$var_name=\"$input\""
  106. fi
  107. }
  108. prompt_yes_no() {
  109. local prompt_text="$1"
  110. local default="$2" # y or n
  111. if [[ "$NON_INTERACTIVE" == "true" ]]; then
  112. [[ "$default" == "y" ]] && return 0 || return 1
  113. fi
  114. local yn_hint="[y/n]"
  115. [[ "$default" == "y" ]] && yn_hint="[Y/n]"
  116. [[ "$default" == "n" ]] && yn_hint="[y/N]"
  117. while true; do
  118. echo -en "${BOLD}$prompt_text${NC} $yn_hint: "
  119. read -r yn
  120. [[ -z "$yn" ]] && yn="$default"
  121. case "$yn" in
  122. [Yy]* ) return 0;;
  123. [Nn]* ) return 1;;
  124. * ) echo "Please answer yes or no.";;
  125. esac
  126. done
  127. }
  128. show_help() {
  129. echo "BamBuddy Native Installation Script"
  130. echo ""
  131. echo "Usage: $0 [OPTIONS]"
  132. echo ""
  133. echo "Options:"
  134. echo " --path PATH Installation directory (default: /opt/bambuddy)"
  135. echo " --port PORT Port to listen on (default: 8000)"
  136. echo " --bind ADDRESS Bind address: 0.0.0.0 (network) or 127.0.0.1 (local only)"
  137. echo " --tz TIMEZONE Timezone (default: system timezone or UTC)"
  138. echo " --data-dir PATH Data directory (default: INSTALL_PATH/data)"
  139. echo " --log-dir PATH Log directory (default: INSTALL_PATH/logs)"
  140. echo " --debug Enable debug mode"
  141. echo " --log-level LEVEL Log level: DEBUG, INFO, WARNING, ERROR (default: INFO)"
  142. echo " --branch BRANCH Git branch to install (default: main)"
  143. echo " --no-service Skip systemd service setup (Linux only)"
  144. echo " --set-system-tz Set system timezone to match (for unattended installs)"
  145. echo " --yes, -y Non-interactive mode, accept defaults"
  146. echo " --help, -h Show this help message"
  147. echo ""
  148. echo "Examples:"
  149. echo " Interactive installation:"
  150. echo " ./install.sh"
  151. echo ""
  152. echo " Unattended installation with custom settings:"
  153. echo " ./install.sh --path /srv/bambuddy --port 3000 --tz America/New_York --yes"
  154. echo ""
  155. echo " Minimal unattended installation:"
  156. echo " ./install.sh -y"
  157. exit 0
  158. }
  159. # -----------------------------------------------------------------------------
  160. # System Detection
  161. # -----------------------------------------------------------------------------
  162. detect_os() {
  163. if [[ "$OSTYPE" == "darwin"* ]]; then
  164. OS_TYPE="macos"
  165. PKG_MANAGER="brew"
  166. return
  167. fi
  168. if [[ -f /etc/os-release ]]; then
  169. . /etc/os-release
  170. case "$ID" in
  171. ubuntu|debian|raspbian|linuxmint|pop)
  172. OS_TYPE="debian"
  173. PKG_MANAGER="apt"
  174. ;;
  175. fedora|rhel|centos|rocky|almalinux|ol)
  176. OS_TYPE="rhel"
  177. if command -v dnf &>/dev/null; then
  178. PKG_MANAGER="dnf"
  179. else
  180. PKG_MANAGER="yum"
  181. fi
  182. ;;
  183. arch|manjaro|endeavouros)
  184. OS_TYPE="arch"
  185. PKG_MANAGER="pacman"
  186. ;;
  187. opensuse*|sles)
  188. OS_TYPE="suse"
  189. PKG_MANAGER="zypper"
  190. ;;
  191. *)
  192. log_error "Unsupported Linux distribution: $ID"
  193. exit 1
  194. ;;
  195. esac
  196. else
  197. log_error "Cannot detect operating system"
  198. exit 1
  199. fi
  200. }
  201. detect_python() {
  202. # Try python3 first, then python
  203. if command -v python3 &>/dev/null; then
  204. PYTHON_CMD="python3"
  205. elif command -v python &>/dev/null; then
  206. local version
  207. version=$(python --version 2>&1 | cut -d' ' -f2 | cut -d'.' -f1)
  208. if [[ "$version" -ge 3 ]]; then
  209. PYTHON_CMD="python"
  210. fi
  211. fi
  212. if [[ -z "$PYTHON_CMD" ]]; then
  213. return 1
  214. fi
  215. # Check version >= 3.10
  216. local version
  217. version=$($PYTHON_CMD -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")')
  218. local major minor
  219. major=$(echo "$version" | cut -d'.' -f1)
  220. minor=$(echo "$version" | cut -d'.' -f2)
  221. if [[ "$major" -lt 3 ]] || { [[ "$major" -eq 3 ]] && [[ "$minor" -lt 10 ]]; }; then
  222. log_warn "Python $version found, but 3.10 or newer is required"
  223. return 1
  224. fi
  225. log_success "Found Python $version"
  226. return 0
  227. }
  228. detect_timezone() {
  229. if [[ -n "$TIMEZONE" ]]; then
  230. return 0
  231. fi
  232. # Try to get system timezone (with error handling for set -e)
  233. TIMEZONE=""
  234. if [[ -f /etc/timezone ]]; then
  235. TIMEZONE=$(cat /etc/timezone 2>/dev/null) || true
  236. fi
  237. if [[ -z "$TIMEZONE" ]] && [[ -L /etc/localtime ]]; then
  238. TIMEZONE=$(readlink /etc/localtime 2>/dev/null | sed 's|.*/zoneinfo/||') || true
  239. fi
  240. if [[ -z "$TIMEZONE" ]] && command -v timedatectl &>/dev/null; then
  241. TIMEZONE=$(timedatectl show --property=Timezone --value 2>/dev/null) || true
  242. fi
  243. # Default to UTC if not found (use if/then to avoid set -e issue with &&)
  244. if [[ -z "$TIMEZONE" ]]; then
  245. TIMEZONE="UTC"
  246. fi
  247. return 0
  248. }
  249. # -----------------------------------------------------------------------------
  250. # Package Installation
  251. # -----------------------------------------------------------------------------
  252. install_dependencies() {
  253. log_info "Installing system dependencies..."
  254. case "$PKG_MANAGER" in
  255. apt)
  256. sudo apt-get update
  257. sudo apt-get install -y python3 python3-pip python3-venv git curl ffmpeg
  258. ;;
  259. dnf|yum)
  260. sudo $PKG_MANAGER install -y python3 python3-pip git curl ffmpeg
  261. ;;
  262. pacman)
  263. sudo pacman -Sy --noconfirm python python-pip git curl ffmpeg
  264. ;;
  265. zypper)
  266. sudo zypper install -y python3 python3-pip git curl ffmpeg
  267. ;;
  268. brew)
  269. # Check if Homebrew is installed
  270. if ! command -v brew &>/dev/null; then
  271. log_error "Homebrew not found. Please install it first: https://brew.sh"
  272. exit 1
  273. fi
  274. brew install python git curl ffmpeg
  275. ;;
  276. esac
  277. log_success "System dependencies installed"
  278. }
  279. # -----------------------------------------------------------------------------
  280. # Installation Steps
  281. # -----------------------------------------------------------------------------
  282. create_user() {
  283. if [[ "$OS_TYPE" == "macos" ]]; then
  284. return # Skip user creation on macOS
  285. fi
  286. if id "$SERVICE_USER" &>/dev/null; then
  287. log_info "User '$SERVICE_USER' already exists"
  288. return
  289. fi
  290. log_info "Creating service user '$SERVICE_USER'..."
  291. sudo useradd --system --shell /usr/sbin/nologin --home-dir "$INSTALL_PATH" "$SERVICE_USER"
  292. log_success "Service user created"
  293. }
  294. # Ensure a directory exists and is owned by the current user. Used on macOS so
  295. # the install tree stays user-owned (git/venv/npm never touch a root-owned dir).
  296. # Only elevates when the target's parent is root-owned (e.g. /opt); a path under
  297. # $HOME is created without any sudo prompt.
  298. ensure_user_owned_dir() {
  299. local dir="$1"
  300. if [[ -d "$dir" ]] && [[ -w "$dir" ]]; then
  301. return
  302. fi
  303. if mkdir -p "$dir" 2>/dev/null; then
  304. return
  305. fi
  306. log_info "Creating $dir (requires your password)..."
  307. sudo mkdir -p "$dir"
  308. sudo chown "$(id -un):$(id -gn)" "$dir"
  309. }
  310. download_bambuddy() {
  311. log_info "Downloading BamBuddy..."
  312. # Validate branch exists on remote before proceeding
  313. if ! git ls-remote --exit-code --heads https://github.com/maziggy/bambuddy.git "$BRANCH" &>/dev/null; then
  314. log_error "Branch '$BRANCH' not found in the BamBuddy repository."
  315. log_info "Available branches:"
  316. git ls-remote --heads https://github.com/maziggy/bambuddy.git | sed 's|.*refs/heads/| - |'
  317. exit 1
  318. fi
  319. if [[ "$OS_TYPE" == "macos" ]]; then
  320. # macOS has no service user — the whole install runs as the current user.
  321. # Create the target user-owned (elevating only if its parent is root-owned,
  322. # e.g. /opt), then clone/update without sudo so the venv/frontend the user
  323. # builds next aren't fighting a root-owned tree.
  324. ensure_user_owned_dir "$INSTALL_PATH"
  325. if [[ -d "$INSTALL_PATH/.git" ]]; then
  326. log_info "Existing installation found, updating..."
  327. git config --global --add safe.directory "$INSTALL_PATH" 2>/dev/null || true
  328. cd "$INSTALL_PATH"
  329. git fetch origin
  330. git checkout "$BRANCH" 2>/dev/null || git checkout -b "$BRANCH" "origin/$BRANCH"
  331. git reset --hard "origin/$BRANCH"
  332. else
  333. git clone --branch "$BRANCH" https://github.com/maziggy/bambuddy.git "$INSTALL_PATH"
  334. fi
  335. elif [[ -d "$INSTALL_PATH/.git" ]]; then
  336. log_info "Existing installation found, updating..."
  337. # Add safe.directory to avoid "dubious ownership" error when running as root
  338. git config --global --add safe.directory "$INSTALL_PATH" 2>/dev/null || true
  339. cd "$INSTALL_PATH"
  340. git fetch origin
  341. git checkout "$BRANCH" 2>/dev/null || git checkout -b "$BRANCH" "origin/$BRANCH"
  342. git reset --hard "origin/$BRANCH"
  343. # Ensure correct ownership after update
  344. sudo chown -R "$SERVICE_USER:$SERVICE_USER" "$INSTALL_PATH" 2>/dev/null || true
  345. else
  346. # Clone as root so we have write access regardless of the installing user,
  347. # then hand ownership to the service user. Previously we chown'd the empty
  348. # dir to the service user before the clone, which left the install-running
  349. # user (not root, not bambuddy) unable to write .git into it.
  350. sudo mkdir -p "$INSTALL_PATH"
  351. sudo git clone --branch "$BRANCH" https://github.com/maziggy/bambuddy.git "$INSTALL_PATH"
  352. sudo chown -R "$SERVICE_USER:$SERVICE_USER" "$INSTALL_PATH" 2>/dev/null || true
  353. fi
  354. log_success "BamBuddy downloaded to $INSTALL_PATH (branch: $BRANCH)"
  355. }
  356. setup_virtualenv() {
  357. log_info "Setting up Python virtual environment..."
  358. cd "$INSTALL_PATH"
  359. if [[ "$OS_TYPE" == "macos" ]]; then
  360. $PYTHON_CMD -m venv venv
  361. "$INSTALL_PATH/venv/bin/pip" install --upgrade pip
  362. "$INSTALL_PATH/venv/bin/pip" install -r requirements.txt
  363. else
  364. # Venv is owned by the service user, so pip must also run as that user —
  365. # otherwise `pip install --upgrade pip` fails trying to rewrite its own
  366. # binary inside the venv it doesn't own.
  367. sudo -u "$SERVICE_USER" $PYTHON_CMD -m venv venv 2>/dev/null || $PYTHON_CMD -m venv venv
  368. sudo -u "$SERVICE_USER" "$INSTALL_PATH/venv/bin/pip" install --upgrade pip
  369. sudo -u "$SERVICE_USER" "$INSTALL_PATH/venv/bin/pip" install -r requirements.txt
  370. fi
  371. log_success "Virtual environment configured"
  372. }
  373. check_node_version() {
  374. # Returns 0 if Node.js 20+ is available, 1 otherwise
  375. if ! command -v node &>/dev/null; then
  376. return 1
  377. fi
  378. local version
  379. version=$(node --version 2>/dev/null | sed 's/^v//')
  380. local major
  381. major=$(echo "$version" | cut -d'.' -f1)
  382. if [[ "$major" -ge 20 ]]; then
  383. log_success "Found Node.js v$version"
  384. return 0
  385. else
  386. log_warn "Found Node.js v$version (need 20+)"
  387. return 1
  388. fi
  389. }
  390. install_nodejs() {
  391. log_info "Installing Node.js 22..."
  392. case "$PKG_MANAGER" in
  393. apt)
  394. # Remove old nodejs if present
  395. sudo apt-get remove -y nodejs npm 2>/dev/null || true
  396. curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -
  397. sudo apt-get install -y nodejs
  398. ;;
  399. dnf|yum)
  400. sudo $PKG_MANAGER remove -y nodejs npm 2>/dev/null || true
  401. curl -fsSL https://rpm.nodesource.com/setup_22.x | sudo bash -
  402. sudo $PKG_MANAGER install -y nodejs
  403. ;;
  404. pacman)
  405. sudo pacman -S --noconfirm nodejs npm
  406. ;;
  407. zypper)
  408. sudo zypper install -y nodejs22
  409. ;;
  410. brew)
  411. brew install node@22
  412. brew link --overwrite node@22
  413. ;;
  414. *)
  415. log_error "Please install Node.js 20+ manually: https://nodejs.org/"
  416. exit 1
  417. ;;
  418. esac
  419. # Refresh PATH
  420. hash -r 2>/dev/null || true
  421. }
  422. build_frontend() {
  423. log_info "Building frontend..."
  424. cd "$INSTALL_PATH/frontend"
  425. # Check for Node.js 20+
  426. if ! check_node_version; then
  427. install_nodejs
  428. # Verify installation
  429. if ! check_node_version; then
  430. log_error "Failed to install Node.js 20+. Please install manually."
  431. exit 1
  432. fi
  433. fi
  434. # Frontend tree is owned by the service user, so npm must run as that user —
  435. # otherwise creating node_modules/ and writing build output fails. macOS
  436. # keeps the current-user flow since it has no service user.
  437. if [[ "$OS_TYPE" == "macos" ]]; then
  438. npm ci
  439. npm run build
  440. else
  441. sudo -H -u "$SERVICE_USER" npm ci
  442. sudo -H -u "$SERVICE_USER" npm run build
  443. fi
  444. log_success "Frontend built"
  445. }
  446. create_directories() {
  447. log_info "Creating data directories..."
  448. if [[ "$OS_TYPE" == "macos" ]]; then
  449. # Rootless: DATA_DIR/LOG_DIR default under the user-owned install path.
  450. ensure_user_owned_dir "$DATA_DIR"
  451. ensure_user_owned_dir "$LOG_DIR"
  452. else
  453. sudo mkdir -p "$DATA_DIR" "$LOG_DIR"
  454. sudo chown -R "$SERVICE_USER:$SERVICE_USER" "$DATA_DIR" "$LOG_DIR"
  455. fi
  456. log_success "Directories created"
  457. }
  458. create_env_file() {
  459. log_info "Creating environment configuration..."
  460. local env_file="$INSTALL_PATH/.env"
  461. # Note: Only include settings recognized by the app's pydantic Settings class
  462. # Other settings (PORT, BIND_ADDRESS, DATA_DIR, LOG_DIR, TZ) are set in systemd service
  463. cat > /tmp/bambuddy.env << EOF
  464. # BamBuddy Configuration
  465. # Generated by install.sh on $(date)
  466. # Debug mode (true = verbose logging)
  467. DEBUG=$DEBUG_MODE
  468. # Log level (only used when DEBUG=false)
  469. # Options: DEBUG, INFO, WARNING, ERROR
  470. LOG_LEVEL=$LOG_LEVEL
  471. # Enable file logging
  472. LOG_TO_FILE=true
  473. EOF
  474. if [[ "$OS_TYPE" == "macos" ]]; then
  475. # Rootless: install path is user-owned, so write it directly.
  476. mv /tmp/bambuddy.env "$env_file"
  477. chmod 600 "$env_file"
  478. else
  479. sudo mv /tmp/bambuddy.env "$env_file"
  480. sudo chown "$SERVICE_USER:$SERVICE_USER" "$env_file"
  481. sudo chmod 600 "$env_file"
  482. fi
  483. log_success "Environment file created at $env_file"
  484. }
  485. create_systemd_service() {
  486. if [[ "$OS_TYPE" == "macos" ]] || [[ "$SKIP_SERVICE" == "true" ]]; then
  487. return
  488. fi
  489. log_info "Creating systemd service..."
  490. # ProtectHome=true hides /home/* from the service, which breaks ExecStart
  491. # when INSTALL_PATH lives under /home (issue #1685). Loosen to read-only in
  492. # that case so the venv binary is still resolvable; ReadWritePaths below
  493. # re-grants writes for the install/data/log dirs.
  494. local protect_home="true"
  495. if [[ "$INSTALL_PATH" == /home/* ]]; then
  496. protect_home="read-only"
  497. fi
  498. cat > /tmp/bambuddy.service << EOF
  499. [Unit]
  500. Description=BamBuddy - Bambu Lab Print Management
  501. Documentation=https://github.com/maziggy/bambuddy
  502. After=network.target
  503. [Service]
  504. Type=simple
  505. User=$SERVICE_USER
  506. Group=$SERVICE_USER
  507. WorkingDirectory=$INSTALL_PATH
  508. # App settings from .env file
  509. EnvironmentFile=$INSTALL_PATH/.env
  510. # Service settings (not in .env to avoid pydantic validation errors)
  511. Environment="DATA_DIR=$DATA_DIR"
  512. Environment="LOG_DIR=$LOG_DIR"
  513. Environment="TZ=$TIMEZONE"
  514. # --loop asyncio required: uvloop can truncate VP FTP uploads (#1896)
  515. ExecStart=$INSTALL_PATH/venv/bin/uvicorn backend.app.main:app --host $BIND_ADDRESS --port $PORT --loop asyncio
  516. Restart=on-failure
  517. RestartSec=5
  518. StandardOutput=journal
  519. StandardError=journal
  520. # Allow binding to privileged ports (322, 990, 2024-2026) for Virtual Printer proxy mode
  521. AmbientCapabilities=CAP_NET_BIND_SERVICE
  522. # Security hardening
  523. NoNewPrivileges=true
  524. PrivateTmp=true
  525. ProtectSystem=strict
  526. ProtectHome=$protect_home
  527. ReadWritePaths=$DATA_DIR $LOG_DIR $INSTALL_PATH
  528. [Install]
  529. WantedBy=multi-user.target
  530. EOF
  531. sudo mv /tmp/bambuddy.service /etc/systemd/system/bambuddy.service
  532. sudo systemctl daemon-reload
  533. log_success "Systemd service created"
  534. if prompt_yes_no "Enable BamBuddy to start on boot?" "y"; then
  535. sudo systemctl enable bambuddy
  536. log_success "Service enabled"
  537. fi
  538. if prompt_yes_no "Start BamBuddy now?" "y"; then
  539. sudo systemctl start bambuddy
  540. sleep 2
  541. if sudo systemctl is-active --quiet bambuddy; then
  542. log_success "BamBuddy is running"
  543. else
  544. log_warn "Service may have failed to start. Check: sudo journalctl -u bambuddy -f"
  545. fi
  546. fi
  547. }
  548. create_launchd_service() {
  549. if [[ "$OS_TYPE" != "macos" ]] || [[ "$SKIP_SERVICE" == "true" ]]; then
  550. return
  551. fi
  552. log_info "Creating launchd service..."
  553. local plist_path="$HOME/Library/LaunchAgents/com.bambuddy.app.plist"
  554. cat > "$plist_path" << EOF
  555. <?xml version="1.0" encoding="UTF-8"?>
  556. <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
  557. <plist version="1.0">
  558. <dict>
  559. <key>Label</key>
  560. <string>com.bambuddy.app</string>
  561. <key>ProgramArguments</key>
  562. <array>
  563. <string>$INSTALL_PATH/venv/bin/uvicorn</string>
  564. <string>backend.app.main:app</string>
  565. <string>--host</string>
  566. <string>$BIND_ADDRESS</string>
  567. <string>--port</string>
  568. <string>$PORT</string>
  569. <!-- the loop asyncio flag below is required: uvloop can truncate VP FTP uploads, #1896 -->
  570. <string>--loop</string>
  571. <string>asyncio</string>
  572. </array>
  573. <key>WorkingDirectory</key>
  574. <string>$INSTALL_PATH</string>
  575. <key>EnvironmentVariables</key>
  576. <dict>
  577. <key>DEBUG</key>
  578. <string>$DEBUG_MODE</string>
  579. <key>LOG_LEVEL</key>
  580. <string>$LOG_LEVEL</string>
  581. <key>DATA_DIR</key>
  582. <string>$DATA_DIR</string>
  583. <key>LOG_DIR</key>
  584. <string>$LOG_DIR</string>
  585. <key>TZ</key>
  586. <string>$TIMEZONE</string>
  587. </dict>
  588. <key>RunAtLoad</key>
  589. <true/>
  590. <key>KeepAlive</key>
  591. <true/>
  592. <key>StandardOutPath</key>
  593. <string>$LOG_DIR/bambuddy.log</string>
  594. <key>StandardErrorPath</key>
  595. <string>$LOG_DIR/bambuddy.error.log</string>
  596. </dict>
  597. </plist>
  598. EOF
  599. log_success "Launchd plist created at $plist_path"
  600. if prompt_yes_no "Load BamBuddy service now?" "y"; then
  601. launchctl load "$plist_path"
  602. sleep 2
  603. if launchctl list | grep -q "com.bambuddy.app"; then
  604. log_success "BamBuddy is running"
  605. else
  606. log_warn "Service may have failed to start. Check: cat $LOG_DIR/bambuddy.error.log"
  607. fi
  608. fi
  609. }
  610. # -----------------------------------------------------------------------------
  611. # Main Installation Flow
  612. # -----------------------------------------------------------------------------
  613. parse_args() {
  614. while [[ $# -gt 0 ]]; do
  615. case "$1" in
  616. --path)
  617. INSTALL_PATH="$2"
  618. shift 2
  619. ;;
  620. --port)
  621. PORT="$2"
  622. shift 2
  623. ;;
  624. --bind)
  625. BIND_ADDRESS="$2"
  626. shift 2
  627. ;;
  628. --tz)
  629. TIMEZONE="$2"
  630. shift 2
  631. ;;
  632. --data-dir)
  633. DATA_DIR="$2"
  634. shift 2
  635. ;;
  636. --log-dir)
  637. LOG_DIR="$2"
  638. shift 2
  639. ;;
  640. --debug)
  641. DEBUG_MODE="true"
  642. shift
  643. ;;
  644. --log-level)
  645. LOG_LEVEL="$2"
  646. shift 2
  647. ;;
  648. --branch)
  649. BRANCH="$2"
  650. shift 2
  651. ;;
  652. --no-service)
  653. SKIP_SERVICE="true"
  654. shift
  655. ;;
  656. --set-system-tz)
  657. SET_SYSTEM_TZ="true"
  658. shift
  659. ;;
  660. --yes|-y)
  661. NON_INTERACTIVE="true"
  662. shift
  663. ;;
  664. --help|-h)
  665. show_help
  666. ;;
  667. *)
  668. log_error "Unknown option: $1"
  669. show_help
  670. ;;
  671. esac
  672. done
  673. }
  674. gather_config() {
  675. echo ""
  676. echo -e "${BOLD}Installation Configuration${NC}"
  677. echo -e "${CYAN}─────────────────────────────────────────${NC}"
  678. echo ""
  679. # Installation path
  680. [[ -z "$INSTALL_PATH" ]] && prompt "Installation directory" "$DEFAULT_INSTALL_PATH" INSTALL_PATH
  681. # Branch
  682. [[ -z "$BRANCH" ]] && prompt "Git branch" "main" BRANCH
  683. # Port
  684. [[ -z "$PORT" ]] && prompt "Port to listen on" "$DEFAULT_PORT" PORT
  685. # Bind address
  686. if [[ -z "$BIND_ADDRESS" ]]; then
  687. echo ""
  688. echo "Network access:"
  689. echo " 0.0.0.0 - Accessible from other devices on your network (recommended)"
  690. echo " 127.0.0.1 - Only accessible from this machine"
  691. prompt "Bind address" "$DEFAULT_BIND_ADDRESS" BIND_ADDRESS
  692. fi
  693. # Timezone
  694. detect_timezone
  695. prompt "Timezone" "$TIMEZONE" TIMEZONE
  696. # Offer to set system timezone if different from current (skip if already set via --set-system-tz)
  697. if [[ -z "$SET_SYSTEM_TZ" ]]; then
  698. local current_tz
  699. current_tz=$(timedatectl show --property=Timezone --value 2>/dev/null) || true
  700. if [[ -n "$TIMEZONE" ]] && [[ "$TIMEZONE" != "$current_tz" ]]; then
  701. # Default to "n" so unattended installs don't change system TZ unless --set-system-tz is used
  702. if prompt_yes_no "Set system timezone to $TIMEZONE?" "n"; then
  703. SET_SYSTEM_TZ="true"
  704. else
  705. SET_SYSTEM_TZ="false"
  706. fi
  707. else
  708. SET_SYSTEM_TZ="false"
  709. fi
  710. fi
  711. # Data directory
  712. [[ -z "$DATA_DIR" ]] && DATA_DIR="$INSTALL_PATH/data"
  713. prompt "Data directory" "$DATA_DIR" DATA_DIR
  714. # Log directory
  715. [[ -z "$LOG_DIR" ]] && LOG_DIR="$INSTALL_PATH/logs"
  716. prompt "Log directory" "$LOG_DIR" LOG_DIR
  717. # Debug mode
  718. if [[ -z "$DEBUG_MODE" ]]; then
  719. if prompt_yes_no "Enable debug mode?" "n"; then
  720. DEBUG_MODE="true"
  721. else
  722. DEBUG_MODE="false"
  723. fi
  724. fi
  725. # Log level
  726. if [[ -z "$LOG_LEVEL" ]]; then
  727. echo ""
  728. echo "Log levels: DEBUG, INFO, WARNING, ERROR"
  729. prompt "Log level" "$DEFAULT_LOG_LEVEL" LOG_LEVEL
  730. fi
  731. # Confirm
  732. echo ""
  733. echo -e "${BOLD}Installation Summary${NC}"
  734. echo -e "${CYAN}─────────────────────────────────────────${NC}"
  735. echo -e " Install path: ${GREEN}$INSTALL_PATH${NC}"
  736. if [[ "$BRANCH" != "main" ]]; then
  737. echo -e " Branch: ${YELLOW}$BRANCH${NC} (beta)"
  738. else
  739. echo -e " Branch: ${GREEN}$BRANCH${NC}"
  740. fi
  741. echo -e " Port: ${GREEN}$PORT${NC}"
  742. echo -e " Bind address: ${GREEN}$BIND_ADDRESS${NC}"
  743. echo -e " Timezone: ${GREEN}$TIMEZONE${NC}"
  744. echo -e " Data dir: ${GREEN}$DATA_DIR${NC}"
  745. echo -e " Log dir: ${GREEN}$LOG_DIR${NC}"
  746. echo -e " Debug mode: ${GREEN}$DEBUG_MODE${NC}"
  747. echo -e " Log level: ${GREEN}$LOG_LEVEL${NC}"
  748. echo ""
  749. if ! prompt_yes_no "Proceed with installation?" "y"; then
  750. echo "Installation cancelled."
  751. exit 0
  752. fi
  753. }
  754. main() {
  755. parse_args "$@"
  756. print_banner
  757. # Check if running via pipe (curl | bash) - interactive mode won't work
  758. if [[ ! -t 0 ]] && [[ "$NON_INTERACTIVE" != "true" ]]; then
  759. log_error "Interactive mode requires a terminal."
  760. log_info "When using 'curl | bash', you must use non-interactive mode:"
  761. echo ""
  762. echo " curl -fsSL URL | bash -s -- --yes"
  763. echo ""
  764. log_info "Or download and run directly:"
  765. echo ""
  766. echo " curl -fsSL URL -o install.sh && chmod +x install.sh && ./install.sh"
  767. echo ""
  768. exit 1
  769. fi
  770. # Check for root (we need sudo for some operations)
  771. if [[ "$EUID" -eq 0 ]] && [[ "$OS_TYPE" != "macos" ]]; then
  772. log_warn "Running as root. Consider using a regular user with sudo privileges."
  773. fi
  774. # Detect system
  775. log_info "Detecting system..."
  776. detect_os
  777. log_success "Detected: $OS_TYPE (package manager: $PKG_MANAGER)"
  778. # macOS must run rootless. Homebrew hard-refuses to run as root, and a venv
  779. # / node_modules created by root can't be managed by the launchd agent (which
  780. # runs as the user). Bail early with an actionable message instead of dying
  781. # halfway through on "brew: running as root is not supported".
  782. if [[ "$OS_TYPE" == "macos" ]]; then
  783. if [[ "$EUID" -eq 0 ]]; then
  784. log_error "Don't run the macOS installer with sudo."
  785. log_info "Homebrew, the Python venv, and the launchd agent must all be created as your"
  786. log_info "normal user. Re-run without sudo (the script elevates only when it truly needs to):"
  787. echo ""
  788. echo " ./install.sh"
  789. echo ""
  790. exit 1
  791. fi
  792. # /opt requires sudo to create and would leave a root-owned tree; default
  793. # macOS installs to a user-owned location so the whole flow stays rootless.
  794. if [[ "$DEFAULT_INSTALL_PATH" == "/opt/bambuddy" ]]; then
  795. DEFAULT_INSTALL_PATH="$HOME/bambuddy"
  796. fi
  797. fi
  798. # Check/install Python
  799. if ! detect_python; then
  800. log_info "Python 3.10+ not found, will install..."
  801. fi
  802. # Gather configuration
  803. gather_config
  804. # Install steps
  805. echo ""
  806. echo -e "${BOLD}Starting Installation${NC}"
  807. echo -e "${CYAN}─────────────────────────────────────────${NC}"
  808. echo ""
  809. install_dependencies
  810. detect_python || { log_error "Failed to install Python"; exit 1; }
  811. # Set system timezone if requested
  812. if [[ "$SET_SYSTEM_TZ" == "true" ]]; then
  813. log_info "Setting system timezone to $TIMEZONE..."
  814. if [[ "$OS_TYPE" == "macos" ]]; then
  815. sudo systemsetup -settimezone "$TIMEZONE" 2>/dev/null || true
  816. else
  817. sudo timedatectl set-timezone "$TIMEZONE" 2>/dev/null || true
  818. fi
  819. log_success "System timezone set to $TIMEZONE"
  820. fi
  821. if [[ "$OS_TYPE" != "macos" ]]; then
  822. create_user
  823. else
  824. SERVICE_USER="$USER"
  825. fi
  826. download_bambuddy
  827. setup_virtualenv
  828. build_frontend
  829. create_directories
  830. create_env_file
  831. if [[ "$OS_TYPE" == "macos" ]]; then
  832. create_launchd_service
  833. else
  834. create_systemd_service
  835. fi
  836. # Done!
  837. echo ""
  838. echo -e "${GREEN}╔══════════════════════════════════════════════════════════════╗${NC}"
  839. echo -e "${GREEN}║ ║${NC}"
  840. echo -e "${GREEN}║ Installation Complete! ║${NC}"
  841. echo -e "${GREEN}║ ║${NC}"
  842. echo -e "${GREEN}╚══════════════════════════════════════════════════════════════╝${NC}"
  843. echo ""
  844. # Show appropriate URL based on bind address
  845. if [[ "$BIND_ADDRESS" == "0.0.0.0" ]]; then
  846. local ip_addr
  847. ip_addr=$(hostname -I 2>/dev/null | awk '{print $1}') || ip_addr="<your-ip>"
  848. echo -e " ${BOLD}Access BamBuddy:${NC} ${CYAN}http://localhost:$PORT${NC}"
  849. echo -e " ${CYAN}http://$ip_addr:$PORT${NC} (from other devices)"
  850. else
  851. echo -e " ${BOLD}Access BamBuddy:${NC} ${CYAN}http://localhost:$PORT${NC}"
  852. fi
  853. echo ""
  854. if [[ "$OS_TYPE" == "macos" ]]; then
  855. echo -e " ${BOLD}Manage service:${NC}"
  856. echo -e " Start: launchctl load ~/Library/LaunchAgents/com.bambuddy.app.plist"
  857. echo -e " Stop: launchctl unload ~/Library/LaunchAgents/com.bambuddy.app.plist"
  858. echo -e " Logs: tail -f $LOG_DIR/bambuddy.log"
  859. else
  860. echo -e " ${BOLD}Manage service:${NC}"
  861. echo -e " Status: sudo systemctl status bambuddy"
  862. echo -e " Start: sudo systemctl start bambuddy"
  863. echo -e " Stop: sudo systemctl stop bambuddy"
  864. echo -e " Logs: sudo journalctl -u bambuddy -f"
  865. fi
  866. echo ""
  867. echo -e " ${BOLD}Update BamBuddy:${NC}"
  868. echo -e " cd $INSTALL_PATH && git pull && source venv/bin/activate"
  869. echo -e " pip install -r requirements.txt && cd frontend && npm ci && npm run build"
  870. if [[ "$OS_TYPE" != "macos" ]]; then
  871. echo -e " sudo systemctl restart bambuddy"
  872. fi
  873. echo ""
  874. echo -e " ${BOLD}Documentation:${NC} ${CYAN}https://wiki.bambuddy.cool${NC}"
  875. echo ""
  876. }
  877. main "$@"