#!/bin/sh # ============================================================================= # Broodle CLI installer (Linux / macOS / BSD / WSL) # # curl -fsSL https://broodle.host/broodle-cli | bash # curl -fsSL https://broodle.host/broodle-cli | sh # # Downloads a prebuilt, checksum-verified bundle from broodle.host and puts # `broodle-cli` on your PATH. No git, no npm install, no compile step, no sudo. # # PORTABILITY RULES FOR THIS FILE - please keep them: # # 1. ASCII ONLY. Not a single non-ASCII byte, anywhere, including comments. # macOS ships bash 3.2, which in a non-UTF-8 locale folds the bytes of a # character like an ellipsis into the *name* of a preceding expansion: # written as "$VERSION" it looked up VERSION, found # nothing, and `set -u` aborted the install. That is exactly how this # script broke in the field. Pretty output is not worth a dead installer. # 2. POSIX sh only. No arrays, no `local`, no `[[`, no `pipefail`, no # process substitution. It must run under sh, dash, ash/busybox, zsh and # bash 3.2 alike, because we do not control which one is on the far end # of the pipe. # 3. No GNU-only flags. BSD/macOS userland has no `xargs -r`, no `stat -c`, # no `readlink -f`. # # Versions install side by side under ~/.broodle-cli/versions/ and a # `current` pointer is flipped only after the new build passes a smoke test, # so a failed or partial download can never replace a working install. # ============================================================================= set -eu BASE_URL="${BROODLE_CLI_BASE:-https://broodle.host/cli}" MANIFEST_URL="${BROODLE_CLI_MANIFEST:-}" INSTALL_DIR="${BROODLE_CLI_HOME:-$HOME/.broodle-cli}" BIN_DIR="${BROODLE_CLI_BIN:-}" WANT_VERSION="${BROODLE_CLI_VERSION:-}" ACTION="install" FORCE="false" NODE_MAJOR_MIN=22 LAUNCHER="broodle-cli" LAUNCHER_MARK="broodle-cli-launcher" # Every marker this installer has ever written, plus the names it has ever # installed. An installer that does not recognise its own older output tells # the user their file is "not created by this installer" and refuses to # proceed - which is exactly what happened after the marker was renamed. # Anything added here must never be removed. LEGACY_MARKS="broodle-studio-cli-launcher" LEGACY_NAMES="broodle" REQUESTED_BIN="" [ -n "$MANIFEST_URL" ] || MANIFEST_URL="$BASE_URL/version.json" PLATFORM_LABEL="unknown" SHELL_LABEL="sh" NODE_VERSION="" # Colour only when stdout is a terminal that can show it; piped output and # dumb terminals get clean ASCII. if [ -t 1 ] && [ "${TERM:-dumb}" != "dumb" ] && [ -z "${NO_COLOR:-}" ]; then C_RED=$(printf '\033[0;31m'); C_GRN=$(printf '\033[0;32m') C_YEL=$(printf '\033[1;33m'); C_BLU=$(printf '\033[0;34m') C_DIM=$(printf '\033[2m'); C_OFF=$(printf '\033[0m') else C_RED=''; C_GRN=''; C_YEL=''; C_BLU=''; C_DIM=''; C_OFF='' fi ok() { printf '%s[ ok ]%s %s\n' "$C_GRN" "$C_OFF" "$1"; } warn() { printf '%s[warn]%s %s\n' "$C_YEL" "$C_OFF" "$1"; } info() { printf '%s[ .. ]%s %s\n' "$C_BLU" "$C_OFF" "$1"; } dim() { printf '%s%s%s\n' "$C_DIM" "$1" "$C_OFF"; } err() { printf '%s[fail]%s %s\n' "$C_RED" "$C_OFF" "$1" >&2 printf '\n Diagnostics: platform %s | node %s | shell %s\n' \ "$PLATFORM_LABEL" "${NODE_VERSION:-none}" "$SHELL_LABEL" >&2 printf ' Stuck? Send this whole output to https://broodle.host/contact\n\n' >&2 exit 1 } usage() { cat <<'USAGE' Broodle CLI installer curl -fsSL https://broodle.host/broodle-cli | bash curl -fsSL https://broodle.host/broodle-cli | bash -s -- --version 0.21.8 Options: --version install a specific published build (env BROODLE_CLI_VERSION) --dir install root (env BROODLE_CLI_HOME) --bin directory for the launcher (env BROODLE_CLI_BIN) --force reinstall even if already current, and replace a broodle-cli on your PATH that this installer did not write --uninstall remove the install and the launcher --help show this help Exit codes: 0 success, 1 error, 130 interrupted. USAGE exit 0 } while [ $# -gt 0 ]; do case "$1" in --version) [ $# -ge 2 ] || { printf 'error: --version needs a value\n' >&2; exit 1; } WANT_VERSION="$2"; shift 2 ;; --dir) [ $# -ge 2 ] || { printf 'error: --dir needs a value\n' >&2; exit 1; } INSTALL_DIR="$2"; shift 2 ;; --bin) [ $# -ge 2 ] || { printf 'error: --bin needs a value\n' >&2; exit 1; } BIN_DIR="$2"; shift 2 ;; --force) FORCE="true"; shift ;; --uninstall) ACTION="uninstall"; shift ;; -h|--help) usage ;; *) printf 'error: unknown option: %s (try --help)\n' "$1" >&2; exit 1 ;; esac done # --- environment probe ------------------------------------------------------ UNAME_S="$(uname -s 2>/dev/null || echo unknown)" UNAME_M="$(uname -m 2>/dev/null || echo unknown)" case "$UNAME_S" in Darwin) OS_NAME="macOS" ;; Linux) if [ -f /proc/version ] && grep -qi microsoft /proc/version 2>/dev/null; then OS_NAME="WSL" elif [ -f /etc/alpine-release ]; then OS_NAME="Alpine" else OS_NAME="Linux" fi ;; FreeBSD|OpenBSD|NetBSD) OS_NAME="$UNAME_S" ;; MINGW*|MSYS*|CYGWIN*) OS_NAME="Windows-POSIX" ;; *) OS_NAME="$UNAME_S" ;; esac PLATFORM_LABEL="$OS_NAME/$UNAME_M" SHELL_LABEL="$(basename "${SHELL:-sh}" 2>/dev/null || echo sh)" on_path() { case ":${PATH}:" in *":$1:"*) return 0 ;; *) return 1 ;; esac } # Is this file something we wrote (any version, any name)? Checked before we # ever overwrite or delete anything on the user's PATH. is_our_launcher() { [ -f "$1" ] || return 1 if grep -q "$LAUNCHER_MARK" "$1" 2>/dev/null; then return 0; fi for mark in $LEGACY_MARKS; do if grep -q "$mark" "$1" 2>/dev/null; then return 0; fi done # Belt and braces: an older launcher that lost its marker is still ours if # it points into a Broodle install and execs the CLI entry point. if grep -q "cli-entry.js" "$1" 2>/dev/null && grep -q "broodle" "$1" 2>/dev/null; then return 0 fi return 1 } # Every directory we might reasonably install the launcher into, best first. # Reads REQUESTED_BIN, not BIN_DIR: the selection loop below clears BIN_DIR # before it starts, and reading it here would silently discard an explicit # --bin. bin_dir_candidates() { if [ -n "$REQUESTED_BIN" ]; then printf '%s\n' "$REQUESTED_BIN"; return; fi for candidate in "$HOME/.local/bin" "$HOME/bin"; do if [ -d "$candidate" ] && [ -w "$candidate" ] && on_path "$candidate"; then printf '%s\n' "$candidate" fi done if [ -d /usr/local/bin ] && [ -w /usr/local/bin ]; then printf '%s\n' /usr/local/bin; fi # Always offer these, even if they do not exist yet - we can create them. printf '%s\n' "$HOME/.local/bin" printf '%s\n' "$HOME/bin" } # Prefer a user-owned directory already on PATH so we never need sudo. pick_bin_dir() { if [ -n "$BIN_DIR" ]; then printf '%s' "$BIN_DIR"; return; fi for candidate in "$HOME/.local/bin" "$HOME/bin"; do if [ -d "$candidate" ] && [ -w "$candidate" ] && on_path "$candidate"; then printf '%s' "$candidate"; return fi done if [ -d /usr/local/bin ] && [ -w /usr/local/bin ]; then printf '%s' /usr/local/bin; return; fi printf '%s' "$HOME/.local/bin" } # Which rc file to mention when the bin dir is not on PATH. rc_file_hint() { case "$SHELL_LABEL" in zsh) printf '%s' "$HOME/.zshrc" ;; bash) if [ "$OS_NAME" = "macOS" ]; then printf '%s' "$HOME/.bash_profile" else printf '%s' "$HOME/.bashrc"; fi ;; fish) printf '%s' "$HOME/.config/fish/config.fish" ;; *) printf '%s' "$HOME/.profile" ;; esac } # --- uninstall -------------------------------------------------------------- if [ "$ACTION" = "uninstall" ]; then REMOVED=0 for dir in "$(pick_bin_dir)" "$HOME/.local/bin" "$HOME/bin" /usr/local/bin; do # Current and legacy names alike, but only ever files we wrote. for name in "$LAUNCHER" $LEGACY_NAMES; do target="$dir/$name" if is_our_launcher "$target"; then rm -f "$target" && ok "Removed $target" && REMOVED=1 fi done done if [ -d "$INSTALL_DIR" ]; then rm -rf "$INSTALL_DIR" ok "Removed $INSTALL_DIR" REMOVED=1 fi [ "$REMOVED" -eq 1 ] || warn "Nothing to remove (looked in $INSTALL_DIR)" dim " Your settings in ~/.broodle were left untouched." exit 0 fi printf '\n %sBroodle CLI%s installer (%s)\n\n' "$C_BLU" "$C_OFF" "$PLATFORM_LABEL" # --- prerequisites ---------------------------------------------------------- if ! command -v node >/dev/null 2>&1; then printf '%s[fail]%s Node.js %s+ is required and was not found on PATH.\n\n' \ "$C_RED" "$C_OFF" "$NODE_MAJOR_MIN" >&2 case "$OS_NAME" in macOS) printf ' Install it with: brew install node\n' >&2 ;; Alpine) printf ' Install it with: apk add nodejs\n' >&2 ;; WSL|Linux) printf ' Install it with your package manager, or from nodejs.org\n' >&2 ;; *) printf ' Get it from https://nodejs.org/en/download\n' >&2 ;; esac printf ' Downloads: https://nodejs.org/en/download\n\n' >&2 exit 1 fi NODE_VERSION="$(node -v 2>/dev/null | sed 's/^v//')" NODE_MAJOR="$(printf '%s' "$NODE_VERSION" | cut -d. -f1)" case "$NODE_MAJOR" in ''|*[!0-9]*) err "Could not read a version number from 'node -v' (got '$NODE_VERSION')." ;; esac if [ "$NODE_MAJOR" -lt "$NODE_MAJOR_MIN" ]; then err "Node.js $NODE_MAJOR_MIN or newer is required, but this is $NODE_VERSION. See https://nodejs.org/en/download" fi ok "Node.js $NODE_VERSION" HAVE_CURL=0 HAVE_WGET=0 if command -v curl >/dev/null 2>&1; then HAVE_CURL=1; fi if command -v wget >/dev/null 2>&1; then HAVE_WGET=1; fi if [ "$HAVE_CURL" -eq 0 ] && [ "$HAVE_WGET" -eq 0 ]; then err "Either curl or wget is required to download the bundle." fi command -v tar >/dev/null 2>&1 || err "tar is required to unpack the bundle." # --- workspace -------------------------------------------------------------- TMPDIR_INSTALL="$(mktemp -d 2>/dev/null || mktemp -d -t broodle-cli 2>/dev/null || echo '')" if [ -z "$TMPDIR_INSTALL" ] || [ ! -d "$TMPDIR_INSTALL" ]; then err "Could not create a temporary directory (is TMPDIR writable?)." fi cleanup() { rm -rf "$TMPDIR_INSTALL" 2>/dev/null || true; } trap cleanup EXIT trap 'cleanup; exit 130' INT trap 'cleanup; exit 143' TERM fetch() { # fetch if [ "$HAVE_CURL" -eq 1 ]; then curl -fsSL --retry 3 --retry-delay 1 --connect-timeout 20 -o "$2" "$1" else wget -q --tries=3 --timeout=20 -O "$2" "$1" fi } # --- resolve which build to install ---------------------------------------- info "Resolving the current build" fetch "$MANIFEST_URL" "$TMPDIR_INSTALL/version.json" \ || err "Could not reach $MANIFEST_URL - check your connection, proxy or firewall and try again." # node is already a hard requirement, so it parses the manifest. That avoids a # jq dependency and avoids hand-rolling JSON parsing in shell. MANIFEST_OUT="$( node -e ' var fs = require("fs"); var raw, m; try { raw = fs.readFileSync(process.argv[1], "utf8"); } catch (e) { console.error("manifest could not be read: " + e.message); process.exit(1); } try { m = JSON.parse(raw); } catch (e) { console.error("manifest is not valid JSON: " + e.message); process.exit(1); } var want = process.argv[2] || ""; var build = m; if (want) { build = (m.versions || []).filter(function (v) { return v && v.version === want; })[0]; if (!build) { var known = (m.versions || []).map(function (v) { return v.version; }).join(", "); console.error("version " + want + " is not published. Available: " + (known || "none")); process.exit(1); } } if (!build || !build.version || !build.tarball) { console.error("manifest is missing a version or tarball"); process.exit(1); } process.stdout.write([build.version, build.tarball, build.sha256 || ""].join("\n")); ' "$TMPDIR_INSTALL/version.json" "$WANT_VERSION" 2>"$TMPDIR_INSTALL/manifest.err" )" || err "Could not resolve a build: $(cat "$TMPDIR_INSTALL/manifest.err" 2>/dev/null)" VERSION="$(printf '%s\n' "$MANIFEST_OUT" | sed -n '1p')" TARBALL_PATH="$(printf '%s\n' "$MANIFEST_OUT" | sed -n '2p')" EXPECTED_SHA="$(printf '%s\n' "$MANIFEST_OUT" | sed -n '3p')" [ -n "$VERSION" ] || err "The manifest did not name a version." case "$TARBALL_PATH" in http://*|https://*) TARBALL_URL="$TARBALL_PATH" ;; /*) TARBALL_URL="$(printf '%s' "$BASE_URL" | sed 's#/cli$##')$TARBALL_PATH" ;; *) TARBALL_URL="$BASE_URL/$TARBALL_PATH" ;; esac VERSION_DIR="$INSTALL_DIR/versions/$VERSION" if [ -f "$VERSION_DIR/dist/cli.js" ] && [ "$FORCE" != "true" ]; then ok "Broodle CLI $VERSION is already installed" else info "Downloading Broodle CLI $VERSION" fetch "$TARBALL_URL" "$TMPDIR_INSTALL/cli.tar.gz" || err "Download failed: $TARBALL_URL" [ -s "$TMPDIR_INSTALL/cli.tar.gz" ] || err "The downloaded bundle is empty." # --- verify --------------------------------------------------------------- if [ -n "$EXPECTED_SHA" ]; then ACTUAL_SHA="" if command -v sha256sum >/dev/null 2>&1; then ACTUAL_SHA="$(sha256sum "$TMPDIR_INSTALL/cli.tar.gz" | awk '{print $1}')" elif command -v shasum >/dev/null 2>&1; then ACTUAL_SHA="$(shasum -a 256 "$TMPDIR_INSTALL/cli.tar.gz" | awk '{print $1}')" elif command -v openssl >/dev/null 2>&1; then ACTUAL_SHA="$(openssl dgst -sha256 "$TMPDIR_INSTALL/cli.tar.gz" | awk '{print $NF}')" else # node is guaranteed present, so there is always a way to verify. ACTUAL_SHA="$(node -e ' var c = require("crypto"), fs = require("fs"); var h = c.createHash("sha256"); h.update(fs.readFileSync(process.argv[1])); process.stdout.write(h.digest("hex")); ' "$TMPDIR_INSTALL/cli.tar.gz" 2>/dev/null || echo '')" fi [ -n "$ACTUAL_SHA" ] || err "Could not compute a checksum - refusing to install unverified code." if [ "$ACTUAL_SHA" != "$EXPECTED_SHA" ]; then err "Checksum mismatch - refusing to install. expected $EXPECTED_SHA actual $ACTUAL_SHA Re-run the installer. If it keeps failing, the download is being truncated or tampered with." fi ok "Checksum verified" else warn "The manifest carries no checksum for this build." fi # --- unpack into staging, then move into place ---------------------------- mkdir -p "$TMPDIR_INSTALL/unpack" || err "Could not create a staging directory." tar -xzf "$TMPDIR_INSTALL/cli.tar.gz" -C "$TMPDIR_INSTALL/unpack" \ || err "Could not unpack the bundle (corrupt download, or tar without gzip support)." # Accept either a wrapped (broodle-cli-/dist) or a flat (dist) tarball. SRC="$TMPDIR_INSTALL/unpack" if [ ! -f "$SRC/dist/cli.js" ]; then # No `xargs -r` here: BSD/macOS xargs does not have it. FOUND="$(find "$TMPDIR_INSTALL/unpack" -maxdepth 3 -type f -name cli.js -print 2>/dev/null | head -n 1)" if [ -n "$FOUND" ]; then SRC="$(dirname "$(dirname "$FOUND")")" fi fi [ -f "$SRC/dist/cli.js" ] || err "The bundle did not contain dist/cli.js." [ -f "$SRC/scripts/cli-entry.js" ] || err "The bundle did not contain scripts/cli-entry.js." # Smoke-test the new build BEFORE it becomes the current one, so a bad # bundle can never take down a working install. if ! node "$SRC/scripts/cli-entry.js" --version >/dev/null 2>&1; then err "The downloaded build did not start on this machine, so it was not installed. Any existing install is untouched." fi mkdir -p "$INSTALL_DIR/versions" || err "Could not create $INSTALL_DIR/versions - check permissions." rm -rf "$VERSION_DIR.partial" 2>/dev/null || true mv "$SRC" "$VERSION_DIR.partial" || err "Could not stage the new version into $INSTALL_DIR." rm -rf "$VERSION_DIR" 2>/dev/null || true mv "$VERSION_DIR.partial" "$VERSION_DIR" || err "Could not move the new version into place." ok "Installed $VERSION to $VERSION_DIR" fi # `current` points at the active build so the launcher never needs rewriting. # Symlinks are unavailable on a few filesystems, so fall back to a plain file # holding the path, which the launcher also understands. rm -f "$INSTALL_DIR/current.path" 2>/dev/null || true if ln -sfn "$VERSION_DIR" "$INSTALL_DIR/current" 2>/dev/null; then : elif ln -sf "$VERSION_DIR" "$INSTALL_DIR/current" 2>/dev/null; then : else rm -rf "$INSTALL_DIR/current" 2>/dev/null || true printf '%s\n' "$VERSION_DIR" > "$INSTALL_DIR/current.path" \ || err "Could not record the active version in $INSTALL_DIR." fi # Keep the two most recent builds so a rollback needs no download. if [ -d "$INSTALL_DIR/versions" ]; then ls -1t "$INSTALL_DIR/versions" 2>/dev/null | tail -n +3 | while IFS= read -r old; do [ -n "$old" ] || continue if [ "$old" != "$VERSION" ]; then rm -rf "$INSTALL_DIR/versions/$old" 2>/dev/null || true fi done fi # --- launcher --------------------------------------------------------------- # Pick the first candidate we can actually write the launcher into. A # directory is unusable only if something that is NOT ours sits on the name; # in that case we move to the next candidate rather than giving up, because # refusing to install over a name we do not own is right, but refusing to # install at all is not. REQUESTED_BIN="$BIN_DIR" BIN_DIR="" BLOCKED_BY="" for candidate in $(bin_dir_candidates); do [ -n "$candidate" ] || continue mkdir -p "$candidate" 2>/dev/null || continue [ -w "$candidate" ] || continue if [ -e "$candidate/$LAUNCHER" ] && ! is_our_launcher "$candidate/$LAUNCHER"; then if [ "$FORCE" = "true" ]; then BIN_DIR="$candidate"; break fi [ -n "$BLOCKED_BY" ] || BLOCKED_BY="$candidate/$LAUNCHER" continue fi BIN_DIR="$candidate" break done if [ -z "$BIN_DIR" ]; then if [ -n "$BLOCKED_BY" ]; then err "$BLOCKED_BY already exists and was not created by this installer, and no other directory on your PATH is writable. Choose one of: - install elsewhere: curl -fsSL https://broodle.host/broodle-cli | bash -s -- --bin \$HOME/bin - replace that file: curl -fsSL https://broodle.host/broodle-cli | bash -s -- --force - or move it aside yourself and re-run." fi err "Could not find a writable directory for the launcher. Pass --bin to choose one." fi TARGET="$BIN_DIR/$LAUNCHER" NODE_BIN="$(command -v node)" cat > "$TARGET" <&2 echo "Reinstall: curl -fsSL https://broodle.host/broodle-cli | bash" >&2 exit 1 fi exec "$NODE_BIN" "\$BROODLE_TARGET/scripts/cli-entry.js" "\$@" LAUNCHER_EOF chmod +x "$TARGET" || err "Could not make $TARGET executable." ok "Installed $TARGET" # Retire launchers from older installers. An earlier version also installed a # `broodle` alias pointing into a layout that no longer exists, so leaving it # behind means a command on the user's PATH that is broken forever. for dir in "$BIN_DIR" "$HOME/.local/bin" "$HOME/bin" /usr/local/bin; do for name in $LEGACY_NAMES; do stale="$dir/$name" [ "$stale" = "$TARGET" ] && continue if is_our_launcher "$stale"; then rm -f "$stale" 2>/dev/null && warn "Removed the old '$name' launcher at $stale (the command is now 'broodle-cli')" fi done done # Final proof: the thing we just installed actually runs, through the launcher. INSTALLED_VERSION="$("$TARGET" --version 2>/dev/null | tr -d '\r' | tail -n 1 || echo '')" [ -n "$INSTALLED_VERSION" ] || err "The launcher was written but did not run. Try: $TARGET --version" printf '\n' ok "Broodle CLI $INSTALLED_VERSION is ready" if ! on_path "$BIN_DIR"; then RC="$(rc_file_hint)" printf '\n' warn "$BIN_DIR is not on your PATH yet. Add it:" if [ "$SHELL_LABEL" = "fish" ]; then dim " fish_add_path $BIN_DIR" else dim " echo 'export PATH=\"$BIN_DIR:\$PATH\"' >> $RC" dim " export PATH=\"$BIN_DIR:\$PATH\"" fi fi printf '\n Next:\n' dim " broodle-cli start an interactive session" dim " broodle-cli -p \"...\" one-shot / headless" dim " /auth paste your bk_live_ API key" printf '\n Get a key at https://broodle.host/console/studio/api-keys\n' printf ' Docs at https://broodle.host/cli\n\n'