#!/bin/sh # # Brokoli one-liner installer. # # Usage: # curl -fsSL https://brokoli.orkestri.site/install.sh | sh # # Environment variables: # BROKOLI_VERSION Install a specific release tag (default: latest). # BROKOLI_INSTALL_DIR Override the install directory (default: /usr/local/bin # if writable, else $HOME/.local/bin). # BROKOLI_NO_SETUP=1 Skip the interactive admin-user / start-server prompt. # BROKOLI_YES=1 Answer yes to every prompt non-interactively. # # The downloaded archive is checked against the release's checksums.txt # before anything is extracted. Any failure to verify stops the install. # # This script is POSIX-sh (no bashisms) and works with either curl or wget. # set -eu # ───────────────── output helpers ───────────────── RED='' GREEN='' YELLOW='' BOLD='' DIM='' RESET='' if [ -t 1 ] && command -v tput >/dev/null 2>&1 && [ "$(tput colors 2>/dev/null || echo 0)" -ge 8 ]; then RED=$(tput setaf 1); GREEN=$(tput setaf 2); YELLOW=$(tput setaf 3) BOLD=$(tput bold); DIM=$(tput dim); RESET=$(tput sgr0) fi info() { printf '%s==>%s %s\n' "$GREEN" "$RESET" "$1"; } warn() { printf '%s!! %s%s\n' "$YELLOW" "$1" "$RESET" >&2; } die() { printf '%serror:%s %s\n' "$RED" "$RESET" "$1" >&2; exit 1; } step() { printf ' %s%s%s\n' "$DIM" "$1" "$RESET"; } # Node is an optional, resolved runtime for TypeScript code nodes. Report its # status without installing or requiring it: the single-binary install remains # useful for Python and declarative pipelines on hosts that have no Node. report_node_runtime() { if ! command -v node >/dev/null 2>&1; then info "Node.js not found (optional; install Node.js >= 20 only for TypeScript code nodes)" return fi NODE_VERSION=$(node --version 2>/dev/null || true) NODE_MAJOR=$(printf '%s\n' "$NODE_VERSION" | sed -n 's/^v\([0-9][0-9]*\).*/\1/p') if [ -n "$NODE_MAJOR" ] && [ "$NODE_MAJOR" -ge 20 ]; then info "Node.js $NODE_VERSION found (available for TypeScript code nodes)" elif [ -n "$NODE_VERSION" ]; then warn "Node.js $NODE_VERSION found; TypeScript code nodes require Node.js >= 20 (optional)" else warn "Node.js was found but its version could not be read; TypeScript code nodes require Node.js >= 20" fi } # sha256_of prints a file's SHA-256 in lower case, using whichever tool the # platform has: sha256sum on Linux, shasum on macOS, openssl as a fallback. # It returns non-zero when none of them exists. sha256_of() { if command -v sha256sum >/dev/null 2>&1; then _sum=$(sha256sum "$1" | awk '{print $1}') elif command -v shasum >/dev/null 2>&1; then _sum=$(shasum -a 256 "$1" | awk '{print $1}') elif command -v openssl >/dev/null 2>&1; then _sum=$(openssl dgst -sha256 "$1" | awk '{print $NF}') else return 1 fi printf '%s\n' "$_sum" | tr 'A-F' 'a-f' } # ───────────────── preflight ───────────────── REPO="Tnsor-Labs/brokoli" VERSION="${BROKOLI_VERSION:-latest}" # Require curl OR wget. if command -v curl >/dev/null 2>&1; then HTTP_GET='curl -fsSL' HTTP_HEAD='curl -fsSLI' elif command -v wget >/dev/null 2>&1; then HTTP_GET='wget -qO-' HTTP_HEAD='wget -q --spider -S' else die "neither curl nor wget found — install one of them and retry." fi command -v tar >/dev/null 2>&1 || die "tar not found — install it and retry." # ───────────────── platform detection ───────────────── OS=$(uname -s) ARCH=$(uname -m) case "$OS" in Linux) OS_ID="Linux" ;; Darwin) OS_ID="Darwin" ;; *) die "unsupported OS: $OS (Brokoli supports Linux and macOS; Windows users should use WSL)" ;; esac case "$ARCH" in x86_64|amd64) ARCH_ID="x86_64" ;; arm64|aarch64) ARCH_ID="arm64" ;; *) die "unsupported architecture: $ARCH" ;; esac info "Detected platform: ${OS_ID}_${ARCH_ID}" # ───────────────── resolve version ───────────────── if [ "$VERSION" = "latest" ]; then step "Looking up latest release…" # /releases/latest redirects to /releases/tag/vX.Y.Z — grab the final URL. if command -v curl >/dev/null 2>&1; then RESOLVED=$(curl -fsSLI -o /dev/null -w '%{url_effective}' \ "https://github.com/${REPO}/releases/latest") else # No --max-redirect: busybox wget rejects it, and both GNU and # busybox wget follow redirects anyway. The Location header is # indented in busybox's -S output and unindented in GNU's # "[following]" line, so match the first field, not the line start. RESOLVED=$(wget --spider -S \ "https://github.com/${REPO}/releases/latest" 2>&1 \ | awk 'tolower($1) == "location:" {print $2}' | tail -1) fi VERSION=$(printf '%s\n' "$RESOLVED" | sed -E 's|.*/tag/(v[^/]+).*|\1|') [ -n "$VERSION" ] || die "could not resolve latest version (check network / GitHub status)" fi info "Installing Brokoli $VERSION" # ───────────────── pick install dir ───────────────── if [ -n "${BROKOLI_INSTALL_DIR:-}" ]; then INSTALL_DIR="$BROKOLI_INSTALL_DIR" elif [ -w /usr/local/bin ] 2>/dev/null; then INSTALL_DIR="/usr/local/bin" else INSTALL_DIR="$HOME/.local/bin" fi # Always ensure the install dir exists — applies to both the # BROKOLI_INSTALL_DIR override and the $HOME/.local/bin fallback. mkdir -p "$INSTALL_DIR" 2>/dev/null || die "could not create $INSTALL_DIR" case ":$PATH:" in *":$INSTALL_DIR:"*) PATH_HINT="" ;; *) PATH_HINT=" (not on \$PATH — add 'export PATH=\"$INSTALL_DIR:\$PATH\"' to your shell config)" ;; esac # ───────────────── download + extract ───────────────── ASSET="brokoli_${OS_ID}_${ARCH_ID}.tar.gz" URL="https://github.com/${REPO}/releases/download/${VERSION}/${ASSET}" TMP=$(mktemp -d 2>/dev/null || mktemp -d -t brokoli-install) trap 'rm -rf "$TMP"' EXIT INT TERM step "Downloading $ASSET…" if command -v curl >/dev/null 2>&1; then curl -fSL --progress-bar "$URL" -o "$TMP/$ASSET" \ || die "download failed: $URL" else # --show-progress exists only in GNU wget; busybox wget, the usual # wget on Alpine, rejects it and the download fails. Pass it only when # this wget advertises it. `wget --help` exits non-zero on busybox. WGET_PROGRESS='' case "$(wget --help 2>&1 || true)" in *--show-progress*) WGET_PROGRESS='--show-progress' ;; esac # shellcheck disable=SC2086 # empty on purpose when unsupported wget -q $WGET_PROGRESS "$URL" -O "$TMP/$ASSET" \ || die "download failed: $URL" fi # ───────────────── verify ───────────────── # Every release publishes checksums.txt, one " " line per # archive. The archive is checked against it before it is extracted or # anything is installed, and every way of failing to verify stops here: # no checksums file, no entry for this archive, no hash tool, or a # mismatch. None of them falls back to installing unverified. # # What this proves: the archive is byte-for-byte the one the release build # produced, so a truncated download, a corrupting proxy or an altered # mirror is caught. What it does not prove: that the release itself is # genuine. checksums.txt is published next to the archive, so anyone able # to replace one could replace both. That needs a signature. SUMS_URL="https://github.com/${REPO}/releases/download/${VERSION}/checksums.txt" step "Verifying checksum…" if command -v curl >/dev/null 2>&1; then curl -fsSL "$SUMS_URL" -o "$TMP/checksums.txt" \ || die "could not download $SUMS_URL; refusing to install a binary that cannot be verified" else wget -q "$SUMS_URL" -O "$TMP/checksums.txt" \ || die "could not download $SUMS_URL; refusing to install a binary that cannot be verified" fi EXPECTED=$(awk -v f="$ASSET" '$2 == f {print $1}' "$TMP/checksums.txt" | tr 'A-F' 'a-f') [ -n "$EXPECTED" ] \ || die "checksums.txt for $VERSION has no entry for $ASSET; refusing to install a binary that cannot be verified" ACTUAL=$(sha256_of "$TMP/$ASSET") \ || die "no SHA-256 tool found (need sha256sum, shasum or openssl); refusing to install a binary that cannot be verified" [ "$ACTUAL" = "$EXPECTED" ] \ || die "checksum mismatch for $ASSET (expected $EXPECTED, got $ACTUAL). The download is corrupt or has been altered. Nothing was installed." step "Checksum OK ($ACTUAL)" step "Extracting…" tar -xzf "$TMP/$ASSET" -C "$TMP" || die "extract failed" [ -f "$TMP/brokoli" ] || die "archive did not contain a 'brokoli' binary" chmod +x "$TMP/brokoli" step "Installing to $INSTALL_DIR/brokoli" mv "$TMP/brokoli" "$INSTALL_DIR/brokoli" \ || die "could not write to $INSTALL_DIR — set BROKOLI_INSTALL_DIR or run with sudo" INSTALLED_VERSION=$("$INSTALL_DIR/brokoli" --version 2>/dev/null || echo "$VERSION") info "Brokoli $INSTALLED_VERSION installed${PATH_HINT}" report_node_runtime # ───────────────── interactive first-run setup ───────────────── # # curl | sh pipes the script to stdin, so `read` from stdin fails. We read # from /dev/tty instead, which is the real user terminal even when stdin is # a pipe. This is the same trick rustup / bun / homebrew use. # setup_interactively() { if [ "${BROKOLI_NO_SETUP:-}" = "1" ]; then return 1; fi [ -e /dev/tty ] || return 1 return 0 } if setup_interactively; then printf '\n' printf '%sWould you like to start Brokoli now and create an admin user? [Y/n] %s' "$BOLD" "$RESET" if [ "${BROKOLI_YES:-}" = "1" ]; then ANS="y"; printf 'y\n' else read -r ANS /dev/null 2>&1 && ss -tln 2>/dev/null | grep -q ":${PORT} "; then warn "port ${PORT} is already in use — skipping auto-start" warn "start manually on a different port: brokoli serve --port 9090" exit 0 fi # Collect admin credentials. DEFAULT_USER="admin" printf ' admin username [%s]: ' "$DEFAULT_USER" read -r ADMIN_USER /dev/null || true read -r ADMIN_PASS /dev/null || true printf '\n' [ ${#ADMIN_PASS} -ge 10 ] && break warn "password too short, try again" done # Start the server in the background. LOG="$HOME/.brokoli-install.log" info "Starting Brokoli on http://localhost:${PORT}…" nohup "$INSTALL_DIR/brokoli" serve --port "$PORT" >"$LOG" 2>&1 & SERVER_PID=$! # Wait up to 20s for the health endpoint. i=0 while [ $i -lt 40 ]; do if $HTTP_GET "http://localhost:${PORT}/health" >/dev/null 2>&1; then break fi i=$((i + 1)) sleep 0.5 done if [ $i -ge 40 ]; then warn "server did not come up in 20s — check $LOG" exit 1 fi # Create the first user via the unauthenticated setup endpoint. step "Creating admin user '$ADMIN_USER'…" PAYLOAD=$(printf '{"username":"%s","password":"%s","role":"admin"}' \ "$ADMIN_USER" "$ADMIN_PASS") if command -v curl >/dev/null 2>&1; then RESP=$(curl -fsS -X POST "http://localhost:${PORT}/api/auth/users" \ -H 'Content-Type: application/json' \ -d "$PAYLOAD" 2>&1) || { warn "admin creation failed: $RESP"; exit 1; } else RESP=$(wget -qO- --post-data="$PAYLOAD" \ --header='Content-Type: application/json' \ "http://localhost:${PORT}/api/auth/users" 2>&1) \ || { warn "admin creation failed: $RESP"; exit 1; } fi printf '\n' info "Ready to go" printf ' %sURL:%s http://localhost:%s\n' "$BOLD" "$RESET" "$PORT" printf ' %susername:%s %s\n' "$BOLD" "$RESET" "$ADMIN_USER" printf ' %spassword:%s (the one you just set)\n' "$BOLD" "$RESET" printf '\n' printf ' %sserver log:%s %s\n' "$DIM" "$RESET" "$LOG" printf ' %sstop with:%s kill %s\n' "$DIM" "$RESET" "$SERVER_PID" printf '\n' printf ' Next: open the URL and trigger your first pipeline from the\n' printf ' sample templates in the Pipelines tab.\n' else printf '\n' info "Installed. Next steps:" printf ' %sbrokoli serve%s # start the server on :8080\n' "$BOLD" "$RESET" printf ' open http://localhost:8080 # open the UI\n' printf '\n' printf '%sDocs:%s https://docs-brokoli.orkestri.site\n' "$DIM" "$RESET" fi