install.sh 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756
  1. #!/usr/bin/env bash
  2. set -euo pipefail
  3. # PilotDeck one-line installer for macOS and Linux.
  4. # Usage:
  5. # curl -fsSL https://raw.githubusercontent.com/OpenBMB/PilotDeck/main/install.sh | bash
  6. REPO_URL="${PILOTDECK_REPO_URL:-https://github.com/OpenBMB/PilotDeck.git}"
  7. BRANCH="${PILOTDECK_BRANCH:-main}"
  8. INSTALL_DIR="${PILOTDECK_INSTALL_DIR:-$HOME/.pilotdeck/app}"
  9. CONFIG_FILE="${PILOTDECK_CONFIG_PATH:-$HOME/.pilotdeck/pilotdeck.yaml}"
  10. BIN_LINK="${PILOTDECK_BIN_LINK:-/usr/local/bin/pilotdeck}"
  11. MAX_PORT_TRIES="${PILOTDECK_MAX_PORT_TRIES:-20}"
  12. APT_UPDATED=0
  13. GREEN='\033[0;32m'
  14. YELLOW='\033[0;33m'
  15. RED='\033[0;31m'
  16. DIM='\033[2m'
  17. BOLD='\033[1m'
  18. RESET='\033[0m'
  19. ok() { printf " ${GREEN}✓${RESET} %s\n" "$1"; }
  20. warn() { printf " ${YELLOW}→${RESET} %s\n" "$1"; }
  21. fail() { printf " ${RED}✗${RESET} %s\n" "$1"; exit 1; }
  22. # Portable timeout: use GNU timeout if available, else fall back to a bg+kill approach.
  23. # Returns 124 on timeout (same convention as GNU timeout).
  24. run_with_timeout() {
  25. local secs="$1"; shift
  26. if command -v timeout >/dev/null 2>&1; then
  27. timeout "$secs" "$@"
  28. else
  29. "$@" &
  30. local pid=$!
  31. ( sleep "$secs" && kill "$pid" 2>/dev/null ) &
  32. local watchdog=$!
  33. if wait "$pid" 2>/dev/null; then
  34. kill "$watchdog" 2>/dev/null; wait "$watchdog" 2>/dev/null
  35. return 0
  36. else
  37. local rc=$?
  38. kill "$watchdog" 2>/dev/null; wait "$watchdog" 2>/dev/null
  39. # 143 = SIGTERM (128+15), treat as timeout
  40. if [[ $rc -eq 143 ]]; then return 124; fi
  41. return $rc
  42. fi
  43. fi
  44. }
  45. run_as_root() {
  46. if [[ "${EUID:-$(id -u)}" -eq 0 ]]; then
  47. "$@"
  48. elif command -v sudo >/dev/null 2>&1; then
  49. sudo "$@"
  50. else
  51. fail "Need root privileges to install system packages. Please install sudo or run as root."
  52. fi
  53. }
  54. install_linux_packages() {
  55. local requested=("$@")
  56. local apt_packages=()
  57. local dnf_packages=()
  58. local pacman_packages=()
  59. local zypper_packages=()
  60. local package
  61. for package in "${requested[@]}"; do
  62. case "$package" in
  63. build-tools)
  64. apt_packages+=(build-essential python3)
  65. dnf_packages+=(gcc gcc-c++ make python3)
  66. pacman_packages+=(base-devel python)
  67. zypper_packages+=(gcc gcc-c++ make python3)
  68. ;;
  69. *)
  70. apt_packages+=("$package")
  71. dnf_packages+=("$package")
  72. pacman_packages+=("$package")
  73. zypper_packages+=("$package")
  74. ;;
  75. esac
  76. done
  77. if command -v apt-get >/dev/null 2>&1; then
  78. if [[ "$APT_UPDATED" -eq 0 ]]; then
  79. run_as_root apt-get update
  80. APT_UPDATED=1
  81. fi
  82. run_as_root apt-get install -y "${apt_packages[@]}"
  83. elif command -v dnf >/dev/null 2>&1; then
  84. run_as_root dnf install -y "${dnf_packages[@]}"
  85. elif command -v yum >/dev/null 2>&1; then
  86. run_as_root yum install -y "${dnf_packages[@]}"
  87. elif command -v pacman >/dev/null 2>&1; then
  88. run_as_root pacman -Sy --needed --noconfirm "${pacman_packages[@]}"
  89. elif command -v zypper >/dev/null 2>&1; then
  90. run_as_root zypper --non-interactive install "${zypper_packages[@]}"
  91. else
  92. fail "Unsupported Linux package manager. Please install manually: ${requested[*]}"
  93. fi
  94. }
  95. install_git() {
  96. if [[ "$PLATFORM" == "linux" ]]; then
  97. install_linux_packages git
  98. else
  99. fail "git is not installed. Please install Xcode Command Line Tools: xcode-select --install"
  100. fi
  101. }
  102. install_ripgrep() {
  103. if [[ "$PLATFORM" == "macos" ]] && command -v brew >/dev/null 2>&1; then
  104. brew install ripgrep </dev/null
  105. elif [[ "$PLATFORM" == "linux" ]]; then
  106. install_linux_packages ripgrep
  107. else
  108. fail "ripgrep (rg) is required. On macOS, install Homebrew and run: brew install ripgrep"
  109. fi
  110. }
  111. install_git_lfs() {
  112. if [[ "$PLATFORM" == "macos" ]] && command -v brew >/dev/null 2>&1; then
  113. brew install git-lfs </dev/null
  114. elif [[ "$PLATFORM" == "linux" ]]; then
  115. install_linux_packages git-lfs
  116. else
  117. fail "git-lfs is required for PilotDeck assets. On macOS, install Homebrew and run: brew install git-lfs"
  118. fi
  119. }
  120. install_lsof() {
  121. if [[ "$PLATFORM" == "linux" ]]; then
  122. install_linux_packages lsof
  123. else
  124. fail "lsof is required but missing. Please install Xcode Command Line Tools: xcode-select --install"
  125. fi
  126. }
  127. has_cxx_compiler() {
  128. command -v g++ >/dev/null 2>&1 || command -v c++ >/dev/null 2>&1 || command -v clang++ >/dev/null 2>&1
  129. }
  130. ensure_native_build_tools() {
  131. if command -v python3 >/dev/null 2>&1 && command -v make >/dev/null 2>&1 && has_cxx_compiler; then
  132. ok "native build tools found"
  133. return
  134. fi
  135. if [[ "$PLATFORM" == "linux" ]]; then
  136. warn "native build tools not found. Installing build tools for node-pty/better-sqlite3..."
  137. install_linux_packages build-tools
  138. ok "native build tools installed"
  139. else
  140. fail "native build tools are missing. Please install Xcode Command Line Tools: xcode-select --install"
  141. fi
  142. }
  143. is_port_free() {
  144. local port="$1"
  145. if command -v lsof >/dev/null 2>&1; then
  146. ! lsof -nP -iTCP:"$port" -sTCP:LISTEN >/dev/null 2>&1
  147. elif command -v ss >/dev/null 2>&1; then
  148. ! ss -tlnH "sport = :$port" 2>/dev/null | grep -q .
  149. else
  150. ! (echo >/dev/tcp/127.0.0.1/"$port") 2>/dev/null
  151. fi
  152. }
  153. find_free_port() {
  154. local base="$1"
  155. local offset candidate
  156. for ((offset = 0; offset < MAX_PORT_TRIES; offset++)); do
  157. candidate=$((base + offset))
  158. if is_port_free "$candidate"; then
  159. printf "%s" "$candidate"
  160. return 0
  161. fi
  162. done
  163. return 1
  164. }
  165. resolve_runtime_ports() {
  166. local server_base="${SERVER_PORT:-3001}"
  167. local gateway_base="${PILOTDECK_GATEWAY_PORT:-18789}"
  168. SERVER_PORT="$(find_free_port "$server_base")" || \
  169. fail "Could not find a free UI port within ${MAX_PORT_TRIES} ports from ${server_base}."
  170. PILOTDECK_GATEWAY_PORT="$(find_free_port "$gateway_base")" || \
  171. fail "Could not find a free gateway port within ${MAX_PORT_TRIES} ports from ${gateway_base}."
  172. PILOTDECK_GATEWAY_URL="ws://127.0.0.1:${PILOTDECK_GATEWAY_PORT}/ws"
  173. export SERVER_PORT PILOTDECK_GATEWAY_PORT PILOTDECK_GATEWAY_URL
  174. if [[ "$SERVER_PORT" != "$server_base" ]]; then
  175. warn "UI port ${server_base} is busy; using ${SERVER_PORT} instead."
  176. fi
  177. if [[ "$PILOTDECK_GATEWAY_PORT" != "$gateway_base" ]]; then
  178. warn "Gateway port ${gateway_base} is busy; using ${PILOTDECK_GATEWAY_PORT} instead."
  179. fi
  180. }
  181. github_repo_slug() {
  182. case "$REPO_URL" in
  183. https://github.com/*.git)
  184. local slug="${REPO_URL#https://github.com/}"
  185. printf "%s" "${slug%.git}"
  186. ;;
  187. git@github.com:*.git)
  188. local slug="${REPO_URL#git@github.com:}"
  189. printf "%s" "${slug%.git}"
  190. ;;
  191. *)
  192. return 1
  193. ;;
  194. esac
  195. }
  196. normalize_github_remote() {
  197. local url="$1"
  198. case "$url" in
  199. https://github.com/*)
  200. local slug="${url#https://github.com/}"
  201. slug="${slug%.git}"
  202. printf "%s" "$slug"
  203. ;;
  204. git@github.com:*)
  205. local slug="${url#git@github.com:}"
  206. slug="${slug%.git}"
  207. printf "%s" "$slug"
  208. ;;
  209. ssh://git@github.com/*)
  210. local slug="${url#ssh://git@github.com/}"
  211. slug="${slug%.git}"
  212. printf "%s" "$slug"
  213. ;;
  214. *)
  215. printf "%s" "$url"
  216. ;;
  217. esac
  218. }
  219. clone_without_lfs_smudge() {
  220. if [[ "${PILOTDECK_INSTALL_LFS:-0}" == "1" ]]; then
  221. "$@"
  222. else
  223. GIT_LFS_SKIP_SMUDGE=1 "$@"
  224. fi
  225. }
  226. clone_repo() {
  227. local slug
  228. if slug="$(github_repo_slug)" && command -v gh >/dev/null 2>&1 && gh auth status >/dev/null 2>&1; then
  229. clone_without_lfs_smudge gh repo clone "$slug" "$INSTALL_DIR" -- --branch "$BRANCH" --depth 1 || \
  230. fail "Could not clone ${REPO_URL}. Check repository access and network connectivity."
  231. else
  232. clone_without_lfs_smudge git clone --branch "$BRANCH" --depth 1 "$REPO_URL" "$INSTALL_DIR" || \
  233. fail "Could not clone ${REPO_URL}. If this repository is private, authenticate with GitHub first."
  234. fi
  235. }
  236. repo_remote_url() {
  237. git -C "$1" remote get-url origin 2>/dev/null || true
  238. }
  239. repo_has_changes() {
  240. [[ -n "$(git -C "$1" status --porcelain 2>/dev/null)" ]]
  241. }
  242. backup_existing_installation() {
  243. local source_dir="$1"
  244. local backup_dir timestamp
  245. timestamp="$(date +%Y%m%d-%H%M%S)"
  246. backup_dir="${source_dir}.backup.${timestamp}"
  247. while [[ -e "$backup_dir" ]]; do
  248. timestamp="$(date +%Y%m%d-%H%M%S)-$RANDOM"
  249. backup_dir="${source_dir}.backup.${timestamp}"
  250. done
  251. mv "$source_dir" "$backup_dir"
  252. warn "Existing installation moved to ${backup_dir}"
  253. }
  254. checkout_existing_installation() {
  255. cd "$INSTALL_DIR"
  256. GIT_LFS_SKIP_SMUDGE=1 git fetch origin "$BRANCH"
  257. GIT_LFS_SKIP_SMUDGE=1 git checkout -B "$BRANCH" "origin/$BRANCH"
  258. }
  259. install_or_update_repo() {
  260. mkdir -p "$(dirname "$INSTALL_DIR")"
  261. if [[ -d "$INSTALL_DIR/.git" ]]; then
  262. local current_remote current_remote_normalized expected_remote_normalized
  263. current_remote="$(repo_remote_url "$INSTALL_DIR")"
  264. current_remote_normalized="$(normalize_github_remote "$current_remote")"
  265. expected_remote_normalized="$(normalize_github_remote "$REPO_URL")"
  266. if [[ "$current_remote_normalized" != "$expected_remote_normalized" ]]; then
  267. warn "Existing installation uses ${current_remote:-unknown remote}; expected ${REPO_URL}."
  268. backup_existing_installation "$INSTALL_DIR"
  269. clone_repo
  270. ok "Repository cloned"
  271. return
  272. fi
  273. if repo_has_changes "$INSTALL_DIR"; then
  274. warn "Existing installation has local changes; preserving it before reinstalling."
  275. backup_existing_installation "$INSTALL_DIR"
  276. clone_repo
  277. ok "Repository cloned"
  278. return
  279. fi
  280. warn "Existing installation found. Updating..."
  281. if checkout_existing_installation; then
  282. ok "Updated to latest ${BRANCH}"
  283. else
  284. warn "Fast update failed; preserving existing checkout before reinstalling."
  285. cd "$(dirname "$INSTALL_DIR")"
  286. backup_existing_installation "$INSTALL_DIR"
  287. clone_repo
  288. ok "Repository cloned"
  289. fi
  290. return
  291. fi
  292. if [[ -d "$INSTALL_DIR" ]]; then
  293. warn "Cleaning incomplete installation at $INSTALL_DIR"
  294. rm -rf "$INSTALL_DIR"
  295. fi
  296. clone_repo
  297. ok "Repository cloned"
  298. }
  299. ensure_lfs_assets() {
  300. if [[ "${PILOTDECK_INSTALL_LFS:-0}" != "1" ]]; then
  301. warn "Skipping Git LFS media download. Set PILOTDECK_INSTALL_LFS=1 to fetch demo images/videos."
  302. return
  303. fi
  304. if [[ "${GIT_LFS_SKIP_SMUDGE:-}" == "1" ]]; then
  305. warn "GIT_LFS_SKIP_SMUDGE=1 is set; large media assets were intentionally skipped."
  306. return
  307. fi
  308. if ! command -v git-lfs >/dev/null 2>&1 && ! git lfs version >/dev/null 2>&1; then
  309. fail "git-lfs command not found after installation."
  310. fi
  311. cd "$INSTALL_DIR"
  312. git lfs install --local >/dev/null
  313. git lfs pull
  314. local pointer_file=""
  315. for pointer_file in assets/banner.png ui/public/favicon.png ui/src/assets/pilotdeck-logo.png; do
  316. if [[ -f "$pointer_file" ]] && grep -q "version https://git-lfs.github.com/spec/v1" "$pointer_file"; then
  317. fail "Git LFS asset was not downloaded correctly: ${pointer_file}"
  318. fi
  319. done
  320. ok "Git LFS assets downloaded"
  321. }
  322. has_playwright_chrome_for_testing() {
  323. local candidate
  324. for candidate in \
  325. "$HOME/Library/Caches/ms-playwright"/mcp-chrome-for-testing-* \
  326. "$HOME/.cache/ms-playwright"/mcp-chrome-for-testing-*; do
  327. if [[ -d "$candidate" ]]; then
  328. return 0
  329. fi
  330. done
  331. return 1
  332. }
  333. echo ""
  334. echo -e "${BOLD}PilotDeck Installer${RESET}"
  335. echo "====================="
  336. echo ""
  337. echo "Checking system requirements..."
  338. case "$(uname -s)" in
  339. Darwin)
  340. PLATFORM="macos"
  341. ok "macOS detected"
  342. ;;
  343. Linux)
  344. PLATFORM="linux"
  345. ok "Linux detected"
  346. ;;
  347. *)
  348. fail "Unsupported OS: $(uname -s). This installer supports macOS and Linux."
  349. ;;
  350. esac
  351. echo ""
  352. echo "Checking Node.js..."
  353. if command -v node >/dev/null 2>&1; then
  354. NODE_VERSION="$(node --version)"
  355. NODE_MAJOR="$(echo "$NODE_VERSION" | sed 's/v//' | cut -d. -f1)"
  356. if [[ "$NODE_MAJOR" -ge 22 ]]; then
  357. ok "Node.js ${NODE_VERSION} found"
  358. else
  359. warn "Node.js ${NODE_VERSION} is too old (need >=22). Installing Node.js 22..."
  360. if command -v fnm >/dev/null 2>&1; then
  361. fnm install 22
  362. fnm use 22
  363. elif command -v nvm >/dev/null 2>&1; then
  364. nvm install 22 </dev/null
  365. nvm use 22
  366. else
  367. warn "Installing fnm (Fast Node Manager)..."
  368. curl -fsSL https://fnm.vercel.app/install | bash
  369. export PATH="$HOME/.local/share/fnm:$PATH"
  370. eval "$(fnm env)"
  371. fnm install 22 </dev/null
  372. fnm use 22
  373. fi
  374. ok "Node.js $(node --version) installed"
  375. fi
  376. else
  377. warn "Node.js not found. Installing via fnm..."
  378. curl -fsSL https://fnm.vercel.app/install | bash
  379. export PATH="$HOME/.local/share/fnm:$PATH"
  380. eval "$(fnm env)"
  381. fnm install 22 </dev/null
  382. fnm use 22
  383. ok "Node.js $(node --version) installed"
  384. fi
  385. echo ""
  386. echo "Checking git..."
  387. if ! command -v git >/dev/null 2>&1; then
  388. warn "git not found. Installing..."
  389. install_git
  390. fi
  391. ok "git found"
  392. echo ""
  393. if [[ "${PILOTDECK_INSTALL_LFS:-0}" == "1" ]]; then
  394. echo "Checking Git LFS..."
  395. if [[ "${GIT_LFS_SKIP_SMUDGE:-}" == "1" ]]; then
  396. warn "GIT_LFS_SKIP_SMUDGE=1 is set; large media assets will be skipped."
  397. elif command -v git-lfs >/dev/null 2>&1 || git lfs version >/dev/null 2>&1; then
  398. ok "Git LFS $(git lfs version | awk '{print $1}') found"
  399. else
  400. warn "Git LFS not found. Installing..."
  401. install_git_lfs
  402. ok "Git LFS installed"
  403. fi
  404. echo ""
  405. fi
  406. echo "Checking ripgrep..."
  407. if command -v rg >/dev/null 2>&1; then
  408. ok "ripgrep $(rg --version | head -1) found"
  409. else
  410. warn "ripgrep not found. Installing..."
  411. install_ripgrep
  412. ok "ripgrep installed"
  413. fi
  414. echo ""
  415. echo "Checking lsof..."
  416. if ! command -v lsof >/dev/null 2>&1; then
  417. warn "lsof not found. Installing..."
  418. install_lsof
  419. fi
  420. ok "lsof found"
  421. echo ""
  422. echo "Checking native build tools..."
  423. ensure_native_build_tools
  424. echo ""
  425. echo -e "Installing PilotDeck to ${DIM}${INSTALL_DIR}${RESET} ..."
  426. install_or_update_repo
  427. ensure_lfs_assets
  428. echo ""
  429. echo "Installing root dependencies..."
  430. cd "$INSTALL_DIR"
  431. HUSKY=0 npm install --no-audit --no-fund --loglevel=error </dev/null
  432. ok "Root dependencies installed"
  433. warn "Keeping root dev dependencies because runtime uses tsx from source."
  434. echo ""
  435. echo "Installing UI dependencies & building frontend..."
  436. cd "$INSTALL_DIR/ui"
  437. HUSKY=0 npm install --no-audit --no-fund --loglevel=error </dev/null
  438. ok "UI dependencies installed"
  439. npm run build
  440. ok "Frontend built"
  441. warn "Keeping UI dev dependencies because production start uses concurrently/vite build tooling."
  442. echo ""
  443. echo "Checking Playwright browser for browser-use plugin..."
  444. cd "$INSTALL_DIR"
  445. BROWSER_INSTALL_TIMEOUT="${PILOTDECK_BROWSER_INSTALL_TIMEOUT:-300}"
  446. if has_playwright_chrome_for_testing; then
  447. ok "Chrome for Testing already installed"
  448. elif [[ "${PILOTDECK_SKIP_BROWSER_INSTALL:-0}" == "1" ]]; then
  449. warn "Skipping Chrome for Testing install because PILOTDECK_SKIP_BROWSER_INSTALL=1"
  450. else
  451. echo " Downloading and extracting Chrome for Testing (timeout: ${BROWSER_INSTALL_TIMEOUT}s)..."
  452. echo " This may take a few minutes — the extraction step can appear to stall."
  453. if run_with_timeout "${BROWSER_INSTALL_TIMEOUT}" npx @playwright/mcp install-browser chrome-for-testing </dev/null; then
  454. ok "Chrome for Testing installed"
  455. else
  456. exit_code=$?
  457. if [[ $exit_code -eq 124 ]]; then
  458. warn "Chrome for Testing install timed out after ${BROWSER_INSTALL_TIMEOUT}s."
  459. else
  460. warn "Chrome for Testing install failed (exit code $exit_code)."
  461. fi
  462. warn "PilotDeck core features are still available."
  463. warn "To enable browser-use later, run: cd \"$INSTALL_DIR\" && npm run install:browser"
  464. warn "To increase timeout, set PILOTDECK_BROWSER_INSTALL_TIMEOUT=600 and re-run."
  465. fi
  466. fi
  467. echo ""
  468. echo "Installing ClawHub CLI..."
  469. if command -v clawhub >/dev/null 2>&1; then
  470. ok "ClawHub CLI already installed ($(clawhub --version 2>/dev/null || echo 'unknown version'))"
  471. else
  472. npm install -g clawhub --loglevel=error </dev/null && \
  473. ok "ClawHub CLI installed" || \
  474. warn "ClawHub CLI install failed (skill marketplace features may not work)"
  475. fi
  476. echo ""
  477. echo "Setting up CLI command..."
  478. WRAPPER_DIR="$INSTALL_DIR/bin"
  479. CLI_TARGET="$WRAPPER_DIR/pilotdeck"
  480. mkdir -p "$WRAPPER_DIR"
  481. cat > "$CLI_TARGET" <<'EOF'
  482. #!/usr/bin/env bash
  483. set -euo pipefail
  484. SOURCE="${BASH_SOURCE[0]}"
  485. while [[ -L "$SOURCE" ]]; do
  486. SOURCE_DIR="$(cd "$(dirname "$SOURCE")" && pwd)"
  487. LINK_TARGET="$(readlink "$SOURCE")"
  488. if [[ "$LINK_TARGET" == /* ]]; then
  489. SOURCE="$LINK_TARGET"
  490. else
  491. SOURCE="$SOURCE_DIR/$LINK_TARGET"
  492. fi
  493. done
  494. INSTALL_DIR="$(cd "$(dirname "$SOURCE")/.." && pwd)"
  495. CONFIG_FILE="${PILOTDECK_CONFIG_PATH:-$HOME/.pilotdeck/pilotdeck.yaml}"
  496. MAX_PORT_TRIES="${PILOTDECK_MAX_PORT_TRIES:-20}"
  497. fail() { printf "pilotdeck: %s\n" "$1" >&2; exit 1; }
  498. warn() { printf "pilotdeck: %s\n" "$1" >&2; }
  499. is_port_free() {
  500. local port="$1"
  501. if command -v lsof >/dev/null 2>&1; then
  502. ! lsof -nP -iTCP:"$port" -sTCP:LISTEN >/dev/null 2>&1
  503. elif command -v ss >/dev/null 2>&1; then
  504. ! ss -tlnH "sport = :$port" 2>/dev/null | grep -q .
  505. else
  506. ! (echo >/dev/tcp/127.0.0.1/"$port") 2>/dev/null
  507. fi
  508. }
  509. find_free_port() {
  510. local base="$1"
  511. local offset candidate
  512. for ((offset = 0; offset < MAX_PORT_TRIES; offset++)); do
  513. candidate=$((base + offset))
  514. if is_port_free "$candidate"; then
  515. printf "%s" "$candidate"
  516. return 0
  517. fi
  518. done
  519. return 1
  520. }
  521. git_remote_url() {
  522. git -C "$INSTALL_DIR" remote get-url origin 2>/dev/null || printf "unknown"
  523. }
  524. git_branch_name() {
  525. git -C "$INSTALL_DIR" branch --show-current 2>/dev/null || printf "unknown"
  526. }
  527. COMMAND="start"
  528. while [[ $# -gt 0 ]]; do
  529. case "$1" in
  530. start)
  531. COMMAND="start"
  532. shift
  533. ;;
  534. status|info)
  535. COMMAND="status"
  536. shift
  537. ;;
  538. help|-h|--help)
  539. COMMAND="help"
  540. shift
  541. ;;
  542. --port|-p)
  543. [[ $# -ge 2 ]] || fail "--port requires a value"
  544. SERVER_PORT="$2"
  545. shift 2
  546. ;;
  547. --port=*)
  548. SERVER_PORT="${1#--port=}"
  549. shift
  550. ;;
  551. --config)
  552. [[ $# -ge 2 ]] || fail "--config requires a value"
  553. CONFIG_FILE="$2"
  554. shift 2
  555. ;;
  556. --config=*)
  557. CONFIG_FILE="${1#--config=}"
  558. shift
  559. ;;
  560. *)
  561. fail "unknown argument: $1"
  562. ;;
  563. esac
  564. done
  565. if [[ "$COMMAND" == "help" ]]; then
  566. cat <<HELP
  567. pilotdeck - start the PilotDeck web UI
  568. Usage:
  569. pilotdeck [start] [--port <port>] [--config <path>]
  570. pilotdeck status
  571. pilotdeck help
  572. HELP
  573. exit 0
  574. fi
  575. if [[ "$COMMAND" == "status" ]]; then
  576. SERVER_BASE="${SERVER_PORT:-3001}"
  577. NEXT_SERVER_PORT="$(find_free_port "$SERVER_BASE" || printf "%s" "$SERVER_BASE")"
  578. printf "Installation: %s\n" "$INSTALL_DIR"
  579. printf "Remote: %s\n" "$(git_remote_url)"
  580. printf "Branch: %s\n" "$(git_branch_name)"
  581. printf "Config: %s\n" "$CONFIG_FILE"
  582. printf "Default URL: http://localhost:%s\n" "$SERVER_BASE"
  583. printf "Next start: http://localhost:%s\n" "$NEXT_SERVER_PORT"
  584. exit 0
  585. fi
  586. SERVER_BASE="${SERVER_PORT:-3001}"
  587. GATEWAY_BASE="${PILOTDECK_GATEWAY_PORT:-18789}"
  588. SERVER_PORT="$(find_free_port "$SERVER_BASE")" || fail "could not find a free UI port from ${SERVER_BASE}"
  589. PILOTDECK_GATEWAY_PORT="$(find_free_port "$GATEWAY_BASE")" || fail "could not find a free gateway port from ${GATEWAY_BASE}"
  590. PILOTDECK_GATEWAY_URL="ws://127.0.0.1:${PILOTDECK_GATEWAY_PORT}/ws"
  591. export PILOTDECK_CONFIG_PATH="$CONFIG_FILE"
  592. export SERVER_PORT PILOTDECK_GATEWAY_PORT PILOTDECK_GATEWAY_URL
  593. if [[ "$SERVER_PORT" != "$SERVER_BASE" ]]; then
  594. warn "UI port ${SERVER_BASE} is busy; using ${SERVER_PORT} instead."
  595. fi
  596. if [[ "$PILOTDECK_GATEWAY_PORT" != "$GATEWAY_BASE" ]]; then
  597. warn "Gateway port ${GATEWAY_BASE} is busy; using ${PILOTDECK_GATEWAY_PORT} instead."
  598. fi
  599. node "$INSTALL_DIR/scripts/bootstrap-pilotdeck-config.mjs"
  600. printf "pilotdeck: starting at http://localhost:%s\n" "$SERVER_PORT"
  601. export PILOTDECK_SKIP_DEFAULT_PROJECT=1
  602. cd "$INSTALL_DIR/ui"
  603. exec npm run start:built
  604. EOF
  605. chmod +x "$CLI_TARGET"
  606. TARGET_BIN="$BIN_LINK"
  607. if [[ -e "$BIN_LINK" || -L "$BIN_LINK" ]]; then
  608. if rm -f "$BIN_LINK" 2>/dev/null; then
  609. :
  610. elif sudo -n rm -f "$BIN_LINK" 2>/dev/null; then
  611. :
  612. else
  613. warn "Cannot update ${BIN_LINK} without sudo; falling back to user-local bin."
  614. TARGET_BIN="$HOME/.local/bin/pilotdeck"
  615. fi
  616. fi
  617. TARGET_BIN_DIR="$(dirname "$TARGET_BIN")"
  618. if [[ "$TARGET_BIN" != "$BIN_LINK" ]]; then
  619. :
  620. elif [[ ! -d "$TARGET_BIN_DIR" ]] && mkdir -p "$TARGET_BIN_DIR" 2>/dev/null; then
  621. :
  622. fi
  623. if [[ "$TARGET_BIN" == "$BIN_LINK" && -d "$TARGET_BIN_DIR" && -w "$TARGET_BIN_DIR" ]]; then
  624. ln -sf "$CLI_TARGET" "$TARGET_BIN"
  625. ok "pilotdeck command linked to ${DIM}${TARGET_BIN}${RESET}"
  626. elif sudo -n true 2>/dev/null; then
  627. sudo mkdir -p "$TARGET_BIN_DIR"
  628. sudo ln -sf "$CLI_TARGET" "$TARGET_BIN"
  629. ok "pilotdeck command linked to ${DIM}${TARGET_BIN}${RESET}"
  630. else
  631. LOCAL_BIN="$HOME/.local/bin"
  632. mkdir -p "$LOCAL_BIN"
  633. ln -sf "$CLI_TARGET" "$LOCAL_BIN/pilotdeck"
  634. ok "pilotdeck command linked to ${DIM}${LOCAL_BIN}/pilotdeck${RESET}"
  635. if [[ ":$PATH:" != *":$LOCAL_BIN:"* ]]; then
  636. PATH_LINE='export PATH="$HOME/.local/bin:$PATH"'
  637. SHELL_RC=""
  638. case "$(basename "${SHELL:-/bin/sh}")" in
  639. zsh) SHELL_RC="$HOME/.zshrc" ;;
  640. bash)
  641. if [[ -f "$HOME/.bash_profile" ]]; then
  642. SHELL_RC="$HOME/.bash_profile"
  643. else
  644. SHELL_RC="$HOME/.bashrc"
  645. fi
  646. ;;
  647. fish) SHELL_RC="$HOME/.config/fish/config.fish"; PATH_LINE='set -gx PATH $HOME/.local/bin $PATH' ;;
  648. *) SHELL_RC="$HOME/.profile" ;;
  649. esac
  650. if [[ -n "$SHELL_RC" ]]; then
  651. if [[ ! -f "$SHELL_RC" ]] || ! grep -qF '.local/bin' "$SHELL_RC" 2>/dev/null; then
  652. printf '\n# Added by PilotDeck installer\n%s\n' "$PATH_LINE" >> "$SHELL_RC"
  653. ok "PATH updated in ${DIM}${SHELL_RC}${RESET}"
  654. warn "Run ${BOLD}source ${SHELL_RC}${RESET} or open a new terminal to use the ${BOLD}pilotdeck${RESET} command"
  655. else
  656. ok "${DIM}${SHELL_RC}${RESET} already contains .local/bin PATH entry"
  657. fi
  658. export PATH="$LOCAL_BIN:$PATH"
  659. fi
  660. fi
  661. fi
  662. echo ""
  663. echo -e "${BOLD}Installation complete!${RESET}"
  664. echo ""
  665. echo -e " App location: ${DIM}${INSTALL_DIR}${RESET}"
  666. echo -e " Config file: ${DIM}${CONFIG_FILE}${RESET}"
  667. echo -e " CLI command: ${DIM}${TARGET_BIN}${RESET}"
  668. echo ""
  669. echo "Starting PilotDeck..."
  670. echo ""
  671. export PILOTDECK_CONFIG_PATH="$CONFIG_FILE"
  672. resolve_runtime_ports
  673. node "$INSTALL_DIR/scripts/bootstrap-pilotdeck-config.mjs"
  674. echo -e " UI: ${DIM}http://localhost:${SERVER_PORT}${RESET}"
  675. echo -e " Gateway: ${DIM}${PILOTDECK_GATEWAY_URL}${RESET}"
  676. echo ""
  677. export PILOTDECK_SKIP_DEFAULT_PROJECT=1
  678. cd "$INSTALL_DIR/ui"
  679. exec npm run start:built