install.sh 39 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140
  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. # The distribution's python3 first, then a newer one next to it (Ubuntu
  203. # 22.04 ships 3.10 but has a python3.11 package). A candidate needs 3.11+
  204. # and a working venv module: Debian splits ensurepip into a -venv package,
  205. # and an interpreter without it cannot create the virtual environment.
  206. PYTHON_CMD=""
  207. local candidate version major minor found=""
  208. for candidate in python3 python3.13 python3.12 python3.11 python; do
  209. command -v "$candidate" &>/dev/null || continue
  210. version=$("$candidate" -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")' 2>/dev/null) || continue
  211. major=$(echo "$version" | cut -d'.' -f1)
  212. minor=$(echo "$version" | cut -d'.' -f2)
  213. if [[ "$major" -eq 3 ]] && [[ "$minor" -ge 11 ]]; then
  214. if "$candidate" -c 'import ensurepip, venv' &>/dev/null; then
  215. PYTHON_CMD="$candidate"
  216. log_success "Found Python $version ($candidate)"
  217. return 0
  218. fi
  219. found="$version (without the venv module)"
  220. continue
  221. fi
  222. [[ -n "$found" ]] || found="$version"
  223. done
  224. if [[ -n "$found" ]]; then
  225. log_warn "Python $found found, but 3.11 or newer with venv support is required"
  226. fi
  227. return 1
  228. }
  229. detect_timezone() {
  230. if [[ -n "$TIMEZONE" ]]; then
  231. return 0
  232. fi
  233. # Try to get system timezone (with error handling for set -e)
  234. TIMEZONE=""
  235. if [[ -f /etc/timezone ]]; then
  236. TIMEZONE=$(cat /etc/timezone 2>/dev/null) || true
  237. fi
  238. if [[ -z "$TIMEZONE" ]] && [[ -L /etc/localtime ]]; then
  239. TIMEZONE=$(readlink /etc/localtime 2>/dev/null | sed 's|.*/zoneinfo/||') || true
  240. fi
  241. if [[ -z "$TIMEZONE" ]] && command -v timedatectl &>/dev/null; then
  242. TIMEZONE=$(timedatectl show --property=Timezone --value 2>/dev/null) || true
  243. fi
  244. # Default to UTC if not found (use if/then to avoid set -e issue with &&)
  245. if [[ -z "$TIMEZONE" ]]; then
  246. TIMEZONE="UTC"
  247. fi
  248. return 0
  249. }
  250. # -----------------------------------------------------------------------------
  251. # Package Installation
  252. # -----------------------------------------------------------------------------
  253. install_dependencies() {
  254. log_info "Installing system dependencies..."
  255. case "$PKG_MANAGER" in
  256. apt)
  257. sudo apt-get update
  258. sudo apt-get install -y python3 python3-pip python3-venv git curl ffmpeg
  259. # Ubuntu 22.04's python3 is 3.10; its python3.11 package is enough.
  260. if ! detect_python >/dev/null 2>&1; then
  261. sudo apt-get install -y python3.11 python3.11-venv || true
  262. fi
  263. ;;
  264. dnf|yum)
  265. sudo $PKG_MANAGER install -y python3 python3-pip git curl ffmpeg
  266. # RHEL/Rocky 9's python3 is 3.9; their python3.11 package is enough.
  267. if ! detect_python >/dev/null 2>&1; then
  268. sudo $PKG_MANAGER install -y python3.11 python3.11-pip || true
  269. fi
  270. ;;
  271. pacman)
  272. sudo pacman -Sy --noconfirm python python-pip git curl ffmpeg
  273. ;;
  274. zypper)
  275. sudo zypper install -y python3 python3-pip git curl ffmpeg
  276. # openSUSE Leap's python3 is 3.6; its python311 package is enough.
  277. if ! detect_python >/dev/null 2>&1; then
  278. sudo zypper install -y python311 || true
  279. fi
  280. ;;
  281. brew)
  282. # Check if Homebrew is installed
  283. if ! command -v brew &>/dev/null; then
  284. log_error "Homebrew not found. Please install it first: https://brew.sh"
  285. exit 1
  286. fi
  287. brew install python git curl ffmpeg
  288. ;;
  289. esac
  290. log_success "System dependencies installed"
  291. }
  292. # -----------------------------------------------------------------------------
  293. # Installation Steps
  294. # -----------------------------------------------------------------------------
  295. create_user() {
  296. if [[ "$OS_TYPE" == "macos" ]]; then
  297. return # Skip user creation on macOS
  298. fi
  299. if id "$SERVICE_USER" &>/dev/null; then
  300. log_info "User '$SERVICE_USER' already exists"
  301. return
  302. fi
  303. log_info "Creating service user '$SERVICE_USER'..."
  304. sudo useradd --system --shell /usr/sbin/nologin --home-dir "$INSTALL_PATH" "$SERVICE_USER"
  305. log_success "Service user created"
  306. }
  307. # Ensure a directory exists and is owned by the current user. Used on macOS so
  308. # the install tree stays user-owned (git/venv/npm never touch a root-owned dir).
  309. # Only elevates when the target's parent is root-owned (e.g. /opt); a path under
  310. # $HOME is created without any sudo prompt.
  311. ensure_user_owned_dir() {
  312. local dir="$1"
  313. if [[ -d "$dir" ]] && [[ -w "$dir" ]]; then
  314. return
  315. fi
  316. if mkdir -p "$dir" 2>/dev/null; then
  317. return
  318. fi
  319. log_info "Creating $dir (requires your password)..."
  320. sudo mkdir -p "$dir"
  321. sudo chown "$(id -un):$(id -gn)" "$dir"
  322. }
  323. download_bambuddy() {
  324. log_info "Downloading BamBuddy..."
  325. # Validate branch exists on remote before proceeding
  326. if ! git ls-remote --exit-code --heads https://github.com/maziggy/bambuddy.git "$BRANCH" &>/dev/null; then
  327. log_error "Branch '$BRANCH' not found in the BamBuddy repository."
  328. log_info "Available branches:"
  329. git ls-remote --heads https://github.com/maziggy/bambuddy.git | sed 's|.*refs/heads/| - |'
  330. exit 1
  331. fi
  332. if [[ "$OS_TYPE" == "macos" ]]; then
  333. # macOS has no service user — the whole install runs as the current user.
  334. # Create the target user-owned (elevating only if its parent is root-owned,
  335. # e.g. /opt), then clone/update without sudo so the venv/frontend the user
  336. # builds next aren't fighting a root-owned tree.
  337. ensure_user_owned_dir "$INSTALL_PATH"
  338. if [[ -d "$INSTALL_PATH/.git" ]]; then
  339. log_info "Existing installation found, updating..."
  340. git config --global --add safe.directory "$INSTALL_PATH" 2>/dev/null || true
  341. cd "$INSTALL_PATH"
  342. git fetch origin
  343. git checkout "$BRANCH" 2>/dev/null || git checkout -b "$BRANCH" "origin/$BRANCH"
  344. git reset --hard "origin/$BRANCH"
  345. else
  346. git clone --branch "$BRANCH" https://github.com/maziggy/bambuddy.git "$INSTALL_PATH"
  347. fi
  348. elif [[ -d "$INSTALL_PATH/.git" ]]; then
  349. log_info "Existing installation found, updating..."
  350. # Add safe.directory to avoid "dubious ownership" error when running as root
  351. git config --global --add safe.directory "$INSTALL_PATH" 2>/dev/null || true
  352. cd "$INSTALL_PATH"
  353. git fetch origin
  354. git checkout "$BRANCH" 2>/dev/null || git checkout -b "$BRANCH" "origin/$BRANCH"
  355. git reset --hard "origin/$BRANCH"
  356. # Ensure correct ownership after update
  357. sudo chown -R "$SERVICE_USER:$SERVICE_USER" "$INSTALL_PATH" 2>/dev/null || true
  358. else
  359. # Clone as root so we have write access regardless of the installing user,
  360. # then hand ownership to the service user. Previously we chown'd the empty
  361. # dir to the service user before the clone, which left the install-running
  362. # user (not root, not bambuddy) unable to write .git into it.
  363. sudo mkdir -p "$INSTALL_PATH"
  364. sudo git clone --branch "$BRANCH" https://github.com/maziggy/bambuddy.git "$INSTALL_PATH"
  365. sudo chown -R "$SERVICE_USER:$SERVICE_USER" "$INSTALL_PATH" 2>/dev/null || true
  366. fi
  367. log_success "BamBuddy downloaded to $INSTALL_PATH (branch: $BRANCH)"
  368. }
  369. setup_virtualenv() {
  370. log_info "Setting up Python virtual environment..."
  371. cd "$INSTALL_PATH"
  372. # A venv from an older Python (an install from before 3.11 was required)
  373. # must be rebuilt: creating a venv over it keeps its old bin/python.
  374. local venv_args=""
  375. if [[ -x venv/bin/python ]] && ! venv/bin/python -c 'import sys; sys.exit(0 if sys.version_info >= (3, 11) else 1)' 2>/dev/null; then
  376. log_info "Rebuilding the virtual environment with Python 3.11+..."
  377. venv_args="--clear"
  378. fi
  379. if [[ "$OS_TYPE" == "macos" ]]; then
  380. $PYTHON_CMD -m venv $venv_args venv
  381. "$INSTALL_PATH/venv/bin/pip" install --upgrade pip
  382. "$INSTALL_PATH/venv/bin/pip" install -r requirements.lock
  383. else
  384. # Venv is owned by the service user, so pip must also run as that user —
  385. # otherwise `pip install --upgrade pip` fails trying to rewrite its own
  386. # binary inside the venv it doesn't own.
  387. sudo -u "$SERVICE_USER" $PYTHON_CMD -m venv $venv_args venv 2>/dev/null || $PYTHON_CMD -m venv $venv_args venv
  388. sudo -u "$SERVICE_USER" "$INSTALL_PATH/venv/bin/pip" install --upgrade pip
  389. sudo -u "$SERVICE_USER" "$INSTALL_PATH/venv/bin/pip" install -r requirements.lock
  390. fi
  391. log_success "Virtual environment configured"
  392. }
  393. # macOS attributes Local Network permission (TCC) to a process's code
  394. # signature, and judges a launchd-spawned process on its own rather than
  395. # letting it inherit the grant of the Terminal that started it. Homebrew's
  396. # Python is unsigned on Intel, so there is no identity for a grant to attach
  397. # to: every connection to a LAN address is dropped with no error the app can
  398. # log and no permission prompt, and the printer just reads as unreachable
  399. # (#3114).
  400. #
  401. # Signing only when currently unsigned is load-bearing, not tidiness. On
  402. # arm64 the linker ad-hoc signs every binary it produces, so the identity is
  403. # a hash of the file itself; re-signing rotates that hash, invalidates a
  404. # working grant, and causes the very outage this repairs -- on every update.
  405. # A python.org build carries a real Developer ID for the same reason it must
  406. # not be touched.
  407. #
  408. # Both the interpreter and the framework's Python.app are signed. The first
  409. # is what sys._base_executable resolves to (measured on an Apple Silicon
  410. # Homebrew install, inside and outside a venv, and named as the responsible
  411. # process in the reporter's own TCC log on Intel); the second is the separate
  412. # binary whose signature is what actually fixed his machine. Which of the two
  413. # macOS attributes could not be established from either, and signing both
  414. # costs nothing.
  415. sign_python_for_tcc() {
  416. [[ "$OS_TYPE" == "macos" ]] || return 0
  417. local python_bin base_exe framework target signed_any=0
  418. local -a targets=()
  419. python_bin="$INSTALL_PATH/venv/bin/python3"
  420. if [[ ! -x "$python_bin" ]]; then
  421. return 0
  422. fi
  423. if ! command -v codesign &>/dev/null; then
  424. log_warn "codesign not found — skipping the macOS Local Network signing step."
  425. log_info "If the printer turns out to be unreachable, install the Xcode command line"
  426. log_info "tools with 'xcode-select --install' and re-run install/update_macos.sh."
  427. return 0
  428. fi
  429. log_info "Checking the Python code signature (macOS Local Network permission)..."
  430. base_exe="$("$python_bin" -c 'import os, sys; print(os.path.realpath(getattr(sys, "_base_executable", None) or sys.executable))' 2>/dev/null)" || return 0
  431. if [[ -z "$base_exe" ]] || [[ ! -e "$base_exe" ]]; then
  432. return 0
  433. fi
  434. targets+=("$base_exe")
  435. # .../Versions/3.13/bin/python3.13 -> .../Versions/3.13/Resources/Python.app
  436. framework="${base_exe%/bin/*}"
  437. if [[ "$framework" != "$base_exe" ]] && [[ -d "$framework/Resources/Python.app" ]]; then
  438. targets+=("$framework/Resources/Python.app")
  439. fi
  440. for target in "${targets[@]}"; do
  441. if codesign -dv "$target" &>/dev/null; then
  442. continue
  443. fi
  444. if codesign --force --sign - "$target" &>/dev/null; then
  445. log_success "Ad-hoc signed $target"
  446. signed_any=1
  447. else
  448. log_warn "Could not sign $target"
  449. log_info "Bambuddy may be unable to reach the printer. Run this by hand:"
  450. log_info " codesign --force --sign - \"$target\""
  451. fi
  452. done
  453. if [[ "$signed_any" -eq 0 ]]; then
  454. log_success "Python already carries a code signature"
  455. fi
  456. return 0
  457. }
  458. check_node_version() {
  459. # Returns 0 if Node.js 20+ is available, 1 otherwise
  460. if ! command -v node &>/dev/null; then
  461. return 1
  462. fi
  463. local version
  464. version=$(node --version 2>/dev/null | sed 's/^v//')
  465. local major
  466. major=$(echo "$version" | cut -d'.' -f1)
  467. if [[ "$major" -ge 20 ]]; then
  468. log_success "Found Node.js v$version"
  469. return 0
  470. else
  471. log_warn "Found Node.js v$version (need 20+)"
  472. return 1
  473. fi
  474. }
  475. install_nodejs() {
  476. log_info "Installing Node.js 22..."
  477. case "$PKG_MANAGER" in
  478. apt)
  479. # Remove old nodejs if present
  480. sudo apt-get remove -y nodejs npm 2>/dev/null || true
  481. curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -
  482. sudo apt-get install -y nodejs
  483. ;;
  484. dnf|yum)
  485. sudo $PKG_MANAGER remove -y nodejs npm 2>/dev/null || true
  486. curl -fsSL https://rpm.nodesource.com/setup_22.x | sudo bash -
  487. sudo $PKG_MANAGER install -y nodejs
  488. ;;
  489. pacman)
  490. sudo pacman -S --noconfirm nodejs npm
  491. ;;
  492. zypper)
  493. sudo zypper install -y nodejs22
  494. ;;
  495. brew)
  496. brew install node@22
  497. brew link --overwrite node@22
  498. ;;
  499. *)
  500. log_error "Please install Node.js 20+ manually: https://nodejs.org/"
  501. exit 1
  502. ;;
  503. esac
  504. # Refresh PATH
  505. hash -r 2>/dev/null || true
  506. }
  507. build_frontend() {
  508. log_info "Building frontend..."
  509. cd "$INSTALL_PATH/frontend"
  510. # Check for Node.js 20+
  511. if ! check_node_version; then
  512. install_nodejs
  513. # Verify installation
  514. if ! check_node_version; then
  515. log_error "Failed to install Node.js 20+. Please install manually."
  516. exit 1
  517. fi
  518. fi
  519. # Frontend tree is owned by the service user, so npm must run as that user —
  520. # otherwise creating node_modules/ and writing build output fails. macOS
  521. # keeps the current-user flow since it has no service user.
  522. if [[ "$OS_TYPE" == "macos" ]]; then
  523. npm ci
  524. npm run build
  525. else
  526. sudo -H -u "$SERVICE_USER" npm ci
  527. sudo -H -u "$SERVICE_USER" npm run build
  528. fi
  529. log_success "Frontend built"
  530. }
  531. create_directories() {
  532. log_info "Creating data directories..."
  533. if [[ "$OS_TYPE" == "macos" ]]; then
  534. # Rootless: DATA_DIR/LOG_DIR default under the user-owned install path.
  535. ensure_user_owned_dir "$DATA_DIR"
  536. ensure_user_owned_dir "$LOG_DIR"
  537. else
  538. sudo mkdir -p "$DATA_DIR" "$LOG_DIR"
  539. sudo chown -R "$SERVICE_USER:$SERVICE_USER" "$DATA_DIR" "$LOG_DIR"
  540. fi
  541. log_success "Directories created"
  542. }
  543. create_env_file() {
  544. log_info "Creating environment configuration..."
  545. local env_file="$INSTALL_PATH/.env"
  546. # Note: Only include settings recognized by the app's pydantic Settings class
  547. # Other settings (PORT, BIND_ADDRESS, DATA_DIR, LOG_DIR, TZ) are set in systemd service
  548. cat > /tmp/bambuddy.env << EOF
  549. # BamBuddy Configuration
  550. # Generated by install.sh on $(date)
  551. # Debug mode (true = verbose logging)
  552. DEBUG=$DEBUG_MODE
  553. # Log level (only used when DEBUG=false)
  554. # Options: DEBUG, INFO, WARNING, ERROR
  555. LOG_LEVEL=$LOG_LEVEL
  556. # Enable file logging
  557. LOG_TO_FILE=true
  558. EOF
  559. if [[ "$OS_TYPE" == "macos" ]]; then
  560. # Rootless: install path is user-owned, so write it directly.
  561. mv /tmp/bambuddy.env "$env_file"
  562. chmod 600 "$env_file"
  563. else
  564. sudo mv /tmp/bambuddy.env "$env_file"
  565. sudo chown "$SERVICE_USER:$SERVICE_USER" "$env_file"
  566. sudo chmod 600 "$env_file"
  567. fi
  568. log_success "Environment file created at $env_file"
  569. }
  570. create_systemd_service() {
  571. if [[ "$OS_TYPE" == "macos" ]] || [[ "$SKIP_SERVICE" == "true" ]]; then
  572. return
  573. fi
  574. log_info "Creating systemd service..."
  575. # ProtectHome=true hides /home/* from the service, which breaks ExecStart
  576. # when INSTALL_PATH lives under /home (issue #1685). Loosen to read-only in
  577. # that case so the venv binary is still resolvable; ReadWritePaths below
  578. # re-grants writes for the install/data/log dirs.
  579. local protect_home="true"
  580. if [[ "$INSTALL_PATH" == /home/* ]]; then
  581. protect_home="read-only"
  582. fi
  583. # This function overwrites /etc/systemd/system/bambuddy.service outright. Any
  584. # ReadWritePaths the operator added by hand — a NAS share for Scheduled
  585. # Backups, typically — used to disappear with it, and the next backup failed
  586. # with EROFS ("Read-only file system"), which reads like a permission problem
  587. # and is not one (issue #2544). Back the old unit up and carry those paths
  588. # forward.
  589. local existing_unit="/etc/systemd/system/bambuddy.service"
  590. local extra_rw=""
  591. if [[ -f "$existing_unit" ]]; then
  592. local backup_unit="${existing_unit}.bak-$(date +%Y%m%d-%H%M%S)"
  593. sudo cp "$existing_unit" "$backup_unit"
  594. log_info "Existing service backed up to $backup_unit"
  595. local prev_rw
  596. prev_rw=$(sudo grep -hE '^ReadWritePaths=' "$existing_unit" 2>/dev/null | sed 's/^ReadWritePaths=//' || true)
  597. local p
  598. for p in $prev_rw; do
  599. case "$p" in
  600. "$DATA_DIR" | "$LOG_DIR" | "$INSTALL_PATH") continue ;;
  601. esac
  602. extra_rw+=" $p"
  603. done
  604. if [[ -n "$extra_rw" ]]; then
  605. log_info "Keeping custom writable paths from the previous service:$extra_rw"
  606. fi
  607. fi
  608. cat > /tmp/bambuddy.service << EOF
  609. [Unit]
  610. Description=BamBuddy - Bambu Lab Print Management
  611. Documentation=https://github.com/maziggy/bambuddy
  612. After=network.target
  613. [Service]
  614. Type=simple
  615. User=$SERVICE_USER
  616. Group=$SERVICE_USER
  617. WorkingDirectory=$INSTALL_PATH
  618. # App settings from .env file
  619. EnvironmentFile=$INSTALL_PATH/.env
  620. # Service settings (not in .env to avoid pydantic validation errors)
  621. Environment="DATA_DIR=$DATA_DIR"
  622. Environment="LOG_DIR=$LOG_DIR"
  623. Environment="TZ=$TIMEZONE"
  624. # --loop asyncio required: uvloop can truncate VP FTP uploads (#1896)
  625. # --timeout-graceful-shutdown required: uvicorn otherwise waits forever for
  626. # in-flight requests, and an MJPEG camera stream never completes — one open
  627. # camera tile hangs the stop until systemd SIGKILLs, skipping the WAL
  628. # checkpoint and the MQTT / virtual-printer teardown.
  629. ExecStart=$INSTALL_PATH/venv/bin/uvicorn backend.app.main:app --host $BIND_ADDRESS --port $PORT --loop asyncio --timeout-graceful-shutdown 5
  630. Restart=on-failure
  631. RestartSec=5
  632. # Backstop only — uvicorn bounds its own wait at 5s and teardown takes ~1-2s.
  633. TimeoutStopSec=30
  634. StandardOutput=journal
  635. StandardError=journal
  636. # Allow binding to privileged ports (322, 990, 2024-2026) for Virtual Printer proxy mode
  637. AmbientCapabilities=CAP_NET_BIND_SERVICE
  638. # Security hardening
  639. NoNewPrivileges=true
  640. PrivateTmp=true
  641. ProtectSystem=strict
  642. ProtectHome=$protect_home
  643. # ProtectSystem=strict makes EVERY path outside the ones below read-only for this
  644. # service — including a NAS share you mounted yourself and can write to from your
  645. # own shell. If you point Scheduled Backups at such a directory, add it here, or
  646. # better in a drop-in that survives a reinstall (#2544):
  647. #
  648. # sudo systemctl edit bambuddy
  649. # [Service]
  650. # ReadWritePaths=/mnt/your-nas-share
  651. #
  652. ReadWritePaths=$DATA_DIR $LOG_DIR $INSTALL_PATH$extra_rw
  653. [Install]
  654. WantedBy=multi-user.target
  655. EOF
  656. sudo mv /tmp/bambuddy.service /etc/systemd/system/bambuddy.service
  657. sudo systemctl daemon-reload
  658. log_success "Systemd service created"
  659. if prompt_yes_no "Enable BamBuddy to start on boot?" "y"; then
  660. sudo systemctl enable bambuddy
  661. log_success "Service enabled"
  662. fi
  663. if prompt_yes_no "Start BamBuddy now?" "y"; then
  664. sudo systemctl start bambuddy
  665. sleep 2
  666. if sudo systemctl is-active --quiet bambuddy; then
  667. log_success "BamBuddy is running"
  668. else
  669. log_warn "Service may have failed to start. Check: sudo journalctl -u bambuddy -f"
  670. fi
  671. fi
  672. }
  673. create_launchd_service() {
  674. if [[ "$OS_TYPE" != "macos" ]] || [[ "$SKIP_SERVICE" == "true" ]]; then
  675. return
  676. fi
  677. log_info "Creating launchd service..."
  678. local plist_path="$HOME/Library/LaunchAgents/com.bambuddy.app.plist"
  679. cat > "$plist_path" << EOF
  680. <?xml version="1.0" encoding="UTF-8"?>
  681. <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
  682. <plist version="1.0">
  683. <dict>
  684. <key>Label</key>
  685. <string>com.bambuddy.app</string>
  686. <key>ProgramArguments</key>
  687. <array>
  688. <string>$INSTALL_PATH/venv/bin/uvicorn</string>
  689. <string>backend.app.main:app</string>
  690. <string>--host</string>
  691. <string>$BIND_ADDRESS</string>
  692. <string>--port</string>
  693. <string>$PORT</string>
  694. <!-- the loop asyncio flag below is required: uvloop can truncate VP FTP uploads, #1896 -->
  695. <string>--loop</string>
  696. <string>asyncio</string>
  697. <!-- required: uvicorn otherwise waits forever for in-flight requests, and an
  698. MJPEG camera stream never completes — one open camera tile hangs the stop
  699. until launchd SIGKILLs, skipping the WAL checkpoint and MQTT teardown -->
  700. <string>--timeout-graceful-shutdown</string>
  701. <string>5</string>
  702. </array>
  703. <key>WorkingDirectory</key>
  704. <string>$INSTALL_PATH</string>
  705. <key>EnvironmentVariables</key>
  706. <dict>
  707. <key>DEBUG</key>
  708. <string>$DEBUG_MODE</string>
  709. <key>LOG_LEVEL</key>
  710. <string>$LOG_LEVEL</string>
  711. <key>DATA_DIR</key>
  712. <string>$DATA_DIR</string>
  713. <key>LOG_DIR</key>
  714. <string>$LOG_DIR</string>
  715. <key>TZ</key>
  716. <string>$TIMEZONE</string>
  717. </dict>
  718. <key>RunAtLoad</key>
  719. <true/>
  720. <key>KeepAlive</key>
  721. <true/>
  722. <key>StandardOutPath</key>
  723. <string>$LOG_DIR/bambuddy.log</string>
  724. <key>StandardErrorPath</key>
  725. <string>$LOG_DIR/bambuddy.error.log</string>
  726. </dict>
  727. </plist>
  728. EOF
  729. log_success "Launchd plist created at $plist_path"
  730. if prompt_yes_no "Load BamBuddy service now?" "y"; then
  731. launchctl load "$plist_path"
  732. sleep 2
  733. if launchctl list | grep -q "com.bambuddy.app"; then
  734. log_success "BamBuddy is running"
  735. else
  736. log_warn "Service may have failed to start. Check: cat $LOG_DIR/bambuddy.error.log"
  737. fi
  738. fi
  739. }
  740. # -----------------------------------------------------------------------------
  741. # Main Installation Flow
  742. # -----------------------------------------------------------------------------
  743. parse_args() {
  744. while [[ $# -gt 0 ]]; do
  745. case "$1" in
  746. --path)
  747. INSTALL_PATH="$2"
  748. shift 2
  749. ;;
  750. --port)
  751. PORT="$2"
  752. shift 2
  753. ;;
  754. --bind)
  755. BIND_ADDRESS="$2"
  756. shift 2
  757. ;;
  758. --tz)
  759. TIMEZONE="$2"
  760. shift 2
  761. ;;
  762. --data-dir)
  763. DATA_DIR="$2"
  764. shift 2
  765. ;;
  766. --log-dir)
  767. LOG_DIR="$2"
  768. shift 2
  769. ;;
  770. --debug)
  771. DEBUG_MODE="true"
  772. shift
  773. ;;
  774. --log-level)
  775. LOG_LEVEL="$2"
  776. shift 2
  777. ;;
  778. --branch)
  779. BRANCH="$2"
  780. shift 2
  781. ;;
  782. --no-service)
  783. SKIP_SERVICE="true"
  784. shift
  785. ;;
  786. --set-system-tz)
  787. SET_SYSTEM_TZ="true"
  788. shift
  789. ;;
  790. --yes|-y)
  791. NON_INTERACTIVE="true"
  792. shift
  793. ;;
  794. --help|-h)
  795. show_help
  796. ;;
  797. *)
  798. log_error "Unknown option: $1"
  799. show_help
  800. ;;
  801. esac
  802. done
  803. }
  804. gather_config() {
  805. echo ""
  806. echo -e "${BOLD}Installation Configuration${NC}"
  807. echo -e "${CYAN}─────────────────────────────────────────${NC}"
  808. echo ""
  809. # Installation path
  810. [[ -z "$INSTALL_PATH" ]] && prompt "Installation directory" "$DEFAULT_INSTALL_PATH" INSTALL_PATH
  811. # Branch
  812. [[ -z "$BRANCH" ]] && prompt "Git branch" "main" BRANCH
  813. # Port
  814. [[ -z "$PORT" ]] && prompt "Port to listen on" "$DEFAULT_PORT" PORT
  815. # Bind address
  816. if [[ -z "$BIND_ADDRESS" ]]; then
  817. echo ""
  818. echo "Network access:"
  819. echo " 0.0.0.0 - Accessible from other devices on your network (recommended)"
  820. echo " 127.0.0.1 - Only accessible from this machine"
  821. prompt "Bind address" "$DEFAULT_BIND_ADDRESS" BIND_ADDRESS
  822. fi
  823. # Timezone
  824. detect_timezone
  825. prompt "Timezone" "$TIMEZONE" TIMEZONE
  826. # Offer to set system timezone if different from current (skip if already set via --set-system-tz)
  827. if [[ -z "$SET_SYSTEM_TZ" ]]; then
  828. local current_tz
  829. current_tz=$(timedatectl show --property=Timezone --value 2>/dev/null) || true
  830. if [[ -n "$TIMEZONE" ]] && [[ "$TIMEZONE" != "$current_tz" ]]; then
  831. # Default to "n" so unattended installs don't change system TZ unless --set-system-tz is used
  832. if prompt_yes_no "Set system timezone to $TIMEZONE?" "n"; then
  833. SET_SYSTEM_TZ="true"
  834. else
  835. SET_SYSTEM_TZ="false"
  836. fi
  837. else
  838. SET_SYSTEM_TZ="false"
  839. fi
  840. fi
  841. # Data directory
  842. [[ -z "$DATA_DIR" ]] && DATA_DIR="$INSTALL_PATH/data"
  843. prompt "Data directory" "$DATA_DIR" DATA_DIR
  844. # Log directory
  845. [[ -z "$LOG_DIR" ]] && LOG_DIR="$INSTALL_PATH/logs"
  846. prompt "Log directory" "$LOG_DIR" LOG_DIR
  847. # Debug mode
  848. if [[ -z "$DEBUG_MODE" ]]; then
  849. if prompt_yes_no "Enable debug mode?" "n"; then
  850. DEBUG_MODE="true"
  851. else
  852. DEBUG_MODE="false"
  853. fi
  854. fi
  855. # Log level
  856. if [[ -z "$LOG_LEVEL" ]]; then
  857. echo ""
  858. echo "Log levels: DEBUG, INFO, WARNING, ERROR"
  859. prompt "Log level" "$DEFAULT_LOG_LEVEL" LOG_LEVEL
  860. fi
  861. # Confirm
  862. echo ""
  863. echo -e "${BOLD}Installation Summary${NC}"
  864. echo -e "${CYAN}─────────────────────────────────────────${NC}"
  865. echo -e " Install path: ${GREEN}$INSTALL_PATH${NC}"
  866. if [[ "$BRANCH" != "main" ]]; then
  867. echo -e " Branch: ${YELLOW}$BRANCH${NC} (beta)"
  868. else
  869. echo -e " Branch: ${GREEN}$BRANCH${NC}"
  870. fi
  871. echo -e " Port: ${GREEN}$PORT${NC}"
  872. echo -e " Bind address: ${GREEN}$BIND_ADDRESS${NC}"
  873. echo -e " Timezone: ${GREEN}$TIMEZONE${NC}"
  874. echo -e " Data dir: ${GREEN}$DATA_DIR${NC}"
  875. echo -e " Log dir: ${GREEN}$LOG_DIR${NC}"
  876. echo -e " Debug mode: ${GREEN}$DEBUG_MODE${NC}"
  877. echo -e " Log level: ${GREEN}$LOG_LEVEL${NC}"
  878. echo ""
  879. if ! prompt_yes_no "Proceed with installation?" "y"; then
  880. echo "Installation cancelled."
  881. exit 0
  882. fi
  883. }
  884. main() {
  885. parse_args "$@"
  886. print_banner
  887. # Check if running via pipe (curl | bash) - interactive mode won't work
  888. if [[ ! -t 0 ]] && [[ "$NON_INTERACTIVE" != "true" ]]; then
  889. log_error "Interactive mode requires a terminal."
  890. log_info "When using 'curl | bash', you must use non-interactive mode:"
  891. echo ""
  892. echo " curl -fsSL URL | bash -s -- --yes"
  893. echo ""
  894. log_info "Or download and run directly:"
  895. echo ""
  896. echo " curl -fsSL URL -o install.sh && chmod +x install.sh && ./install.sh"
  897. echo ""
  898. exit 1
  899. fi
  900. # Check for root (we need sudo for some operations)
  901. if [[ "$EUID" -eq 0 ]] && [[ "$OS_TYPE" != "macos" ]]; then
  902. log_warn "Running as root. Consider using a regular user with sudo privileges."
  903. fi
  904. # Detect system
  905. log_info "Detecting system..."
  906. detect_os
  907. log_success "Detected: $OS_TYPE (package manager: $PKG_MANAGER)"
  908. # macOS must run rootless. Homebrew hard-refuses to run as root, and a venv
  909. # / node_modules created by root can't be managed by the launchd agent (which
  910. # runs as the user). Bail early with an actionable message instead of dying
  911. # halfway through on "brew: running as root is not supported".
  912. if [[ "$OS_TYPE" == "macos" ]]; then
  913. if [[ "$EUID" -eq 0 ]]; then
  914. log_error "Don't run the macOS installer with sudo."
  915. log_info "Homebrew, the Python venv, and the launchd agent must all be created as your"
  916. log_info "normal user. Re-run without sudo (the script elevates only when it truly needs to):"
  917. echo ""
  918. echo " ./install.sh"
  919. echo ""
  920. exit 1
  921. fi
  922. # /opt requires sudo to create and would leave a root-owned tree; default
  923. # macOS installs to a user-owned location so the whole flow stays rootless.
  924. if [[ "$DEFAULT_INSTALL_PATH" == "/opt/bambuddy" ]]; then
  925. DEFAULT_INSTALL_PATH="$HOME/bambuddy"
  926. fi
  927. fi
  928. # Check/install Python
  929. if ! detect_python; then
  930. log_info "Python 3.11+ not found, will install..."
  931. fi
  932. # Gather configuration
  933. gather_config
  934. # Install steps
  935. echo ""
  936. echo -e "${BOLD}Starting Installation${NC}"
  937. echo -e "${CYAN}─────────────────────────────────────────${NC}"
  938. echo ""
  939. install_dependencies
  940. detect_python || { log_error "Failed to install Python"; exit 1; }
  941. # Set system timezone if requested
  942. if [[ "$SET_SYSTEM_TZ" == "true" ]]; then
  943. log_info "Setting system timezone to $TIMEZONE..."
  944. if [[ "$OS_TYPE" == "macos" ]]; then
  945. sudo systemsetup -settimezone "$TIMEZONE" 2>/dev/null || true
  946. else
  947. sudo timedatectl set-timezone "$TIMEZONE" 2>/dev/null || true
  948. fi
  949. log_success "System timezone set to $TIMEZONE"
  950. fi
  951. if [[ "$OS_TYPE" != "macos" ]]; then
  952. create_user
  953. else
  954. SERVICE_USER="$USER"
  955. fi
  956. download_bambuddy
  957. setup_virtualenv
  958. sign_python_for_tcc
  959. build_frontend
  960. create_directories
  961. create_env_file
  962. if [[ "$OS_TYPE" == "macos" ]]; then
  963. create_launchd_service
  964. else
  965. create_systemd_service
  966. fi
  967. # Done!
  968. echo ""
  969. echo -e "${GREEN}╔══════════════════════════════════════════════════════════════╗${NC}"
  970. echo -e "${GREEN}║ ║${NC}"
  971. echo -e "${GREEN}║ Installation Complete! ║${NC}"
  972. echo -e "${GREEN}║ ║${NC}"
  973. echo -e "${GREEN}╚══════════════════════════════════════════════════════════════╝${NC}"
  974. echo ""
  975. # Show appropriate URL based on bind address
  976. if [[ "$BIND_ADDRESS" == "0.0.0.0" ]]; then
  977. local ip_addr
  978. ip_addr=$(hostname -I 2>/dev/null | awk '{print $1}') || ip_addr="<your-ip>"
  979. echo -e " ${BOLD}Access BamBuddy:${NC} ${CYAN}http://localhost:$PORT${NC}"
  980. echo -e " ${CYAN}http://$ip_addr:$PORT${NC} (from other devices)"
  981. else
  982. echo -e " ${BOLD}Access BamBuddy:${NC} ${CYAN}http://localhost:$PORT${NC}"
  983. fi
  984. echo ""
  985. if [[ "$OS_TYPE" == "macos" ]]; then
  986. echo -e " ${BOLD}Manage service:${NC}"
  987. echo -e " Start: launchctl load ~/Library/LaunchAgents/com.bambuddy.app.plist"
  988. echo -e " Stop: launchctl unload ~/Library/LaunchAgents/com.bambuddy.app.plist"
  989. echo -e " Logs: tail -f $LOG_DIR/bambuddy.log"
  990. else
  991. echo -e " ${BOLD}Manage service:${NC}"
  992. echo -e " Status: sudo systemctl status bambuddy"
  993. echo -e " Start: sudo systemctl start bambuddy"
  994. echo -e " Stop: sudo systemctl stop bambuddy"
  995. echo -e " Logs: sudo journalctl -u bambuddy -f"
  996. fi
  997. echo ""
  998. echo -e " ${BOLD}Update BamBuddy:${NC}"
  999. echo -e " cd $INSTALL_PATH && git pull && source venv/bin/activate"
  1000. echo -e " pip install -r requirements.lock && cd frontend && npm ci && npm run build"
  1001. if [[ "$OS_TYPE" != "macos" ]]; then
  1002. echo -e " sudo systemctl restart bambuddy"
  1003. fi
  1004. echo ""
  1005. echo -e " ${BOLD}Documentation:${NC} ${CYAN}https://wiki.bambuddy.cool${NC}"
  1006. echo ""
  1007. }
  1008. main "$@"