install.sh
Rama main · réplica de lectura del repositorio original.
#!/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 "<sha256> <file>" 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/tty || ANS="n"
fi
case "$ANS" in
''|y|Y|yes|YES|Yes) ;;
*)
printf '\n'
info "Skipped setup. Start Brokoli yourself with:"
printf ' %sbrokoli serve%s\n' "$BOLD" "$RESET"
exit 0
;;
esac
# Check port availability before starting.
PORT="${BROKOLI_PORT:-8080}"
if command -v ss >/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/tty || ADMIN_USER=""
[ -n "$ADMIN_USER" ] || ADMIN_USER="$DEFAULT_USER"
# Password must be 10+ chars (validated by the server).
while :; do
printf ' admin password (10+ chars): '
stty -echo 2>/dev/null || true
read -r ADMIN_PASS </dev/tty || ADMIN_PASS=""
stty echo 2>/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