#!/bin/sh # Helmlane agent installer for Linux and Windows (WSL 2). # # curl -fsSL https://helmlane.com/downloads/install.sh | sh # curl -fsSL https://helmlane.com/downloads/install.sh | sh -s -- --token hl_XXXXXXXXXXXXXXXXXXXXXXXXXX --origin https://HOST # # Downloads from Helmlane's download server (scripts/linux/release.sh # --publish): the newest version named by linux/latest.json, or --version X, # from linux//helmlane-agent-linux-.tar.gz, checksum verified. # # Installs a self-contained runtime (Node included — nothing else to install) # for the current user, without root: # # ~/.local/share/helmlane/runtime// the runtime; `current` → active # ~/.local/bin/helmlane-agent the command # ~/.config/systemd/user/helmlane-*.service agent + sentinel (when systemd) # ~/.local/share/helmlane/agent/ state: journal, credential # ~/.local/state/helmlane/logs/ logs # # Re-running it upgrades in place. `--uninstall` removes it again. # # POSIX sh on purpose: this runs as `curl | sh` on distributions whose sh is # dash or busybox, before anything else is installed. set -eu DOWNLOADS="https://helmlane.com/downloads/linux" DOWNLOAD_BASE="${HELMLANE_DOWNLOAD_BASE:-}" VERSION="${HELMLANE_VERSION:-}" TOKEN="${HELMLANE_ENROLLMENT_TOKEN:-}" ORIGIN="${HELMLANE_ORIGIN:-}" BUNDLE="${HELMLANE_BUNDLE:-}" FOLDERS="" MODE=install PURGE=0 YES=0 QUIET=0 NO_START=0 ALLOW_ROOT=0 FORCE=0 # ------------------------------------------------------------------ output if [ -t 1 ] && [ -z "${NO_COLOR:-}" ] && [ "${TERM:-}" != dumb ]; then BOLD="$(printf '\033[1m')"; DIM="$(printf '\033[2m')"; RED="$(printf '\033[31m')" GREEN="$(printf '\033[32m')"; YELLOW="$(printf '\033[33m')"; CYAN="$(printf '\033[36m')" RESET="$(printf '\033[0m')" OK="✓"; WARN="!"; STEP="→" else BOLD=""; DIM=""; RED=""; GREEN=""; YELLOW=""; CYAN=""; RESET="" OK="[ok]"; WARN="[!]"; STEP="->" fi say() { [ "$QUIET" = 1 ] || printf '%s\n' "$*"; } ok() { say " ${GREEN}${OK}${RESET} $*"; } warn() { printf ' %s%s%s %s\n' "$YELLOW" "$WARN" "$RESET" "$*" >&2; } step() { say " ${CYAN}${STEP}${RESET} $*"; } hint() { say " ${DIM}$*${RESET}"; } fail() { printf '%serror:%s %s\n' "$RED$BOLD" "$RESET" "$1" >&2 shift for line in "$@"; do printf ' %s\n' "$line" >&2; done exit 1 } usage() { cat <<'EOF' Helmlane agent installer Usage: install.sh [options] --token TOKEN enroll right away with a token minted in the app --origin URL your Helmlane server (default https://api.helmlane.com) --folder PATH share a project folder after enrolling (repeatable) --version X install a specific release instead of the latest --bundle FILE install from a local helmlane-agent-linux-.tar.gz --no-start install and enroll, but do not start the service --upgrade upgrade an existing install (what `helmlane-agent update` runs) --uninstall remove Helmlane (add --purge to erase its state too) --allow-root install for root (not recommended; see docs/linux.md) --quiet print only warnings and errors EOF } # ------------------------------------------------------------------ arguments while [ $# -gt 0 ]; do case "$1" in --token) TOKEN="${2:-}"; shift 2 ;; --token=*) TOKEN="${1#*=}"; shift ;; --origin) ORIGIN="${2:-}"; shift 2 ;; --origin=*) ORIGIN="${1#*=}"; shift ;; --folder) FOLDERS="$FOLDERS ${2:-}"; shift 2 ;; --folder=*) FOLDERS="$FOLDERS ${1#*=}"; shift ;; --version) VERSION="${2:-}"; shift 2 ;; --version=*) VERSION="${1#*=}"; shift ;; --bundle) BUNDLE="${2:-}"; shift 2 ;; --no-start) NO_START=1; shift ;; --upgrade) MODE=upgrade; shift ;; --uninstall) MODE=uninstall; shift ;; --purge) PURGE=1; shift ;; --yes|-y) YES=1; shift ;; --allow-root) ALLOW_ROOT=1; shift ;; --force) FORCE=1; shift ;; --quiet|-q) QUIET=1; shift ;; -h|--help) usage; exit 0 ;; *) usage >&2; fail "unknown option: $1" ;; esac done case "$VERSION" in ''|[0-9]*) ;; v[0-9]*) VERSION="${VERSION#v}" ;; *) fail "--version takes a release number such as 0.3.0" ;; esac case "$VERSION" in *[!0-9A-Za-z._+-]*) fail "--version takes a release number such as 0.3.0" ;; esac # ------------------------------------------------------------------ platform [ "$(uname -s)" = Linux ] || fail "This installer is for Linux and WSL." \ "macOS: install the Helmlane app. Windows: run this inside WSL 2 (Ubuntu)." [ -n "${HOME:-}" ] && [ -d "$HOME" ] || fail "HOME is not set to a directory." if [ "$(id -u)" = 0 ] && [ "$ALLOW_ROOT" != 1 ]; then fail "Refusing to install for root." \ "The agent runs coding agents that execute code; give it an ordinary account:" \ " sudo adduser helmlane && sudo loginctl enable-linger helmlane" \ " sudo -iu helmlane # then re-run this installer" \ "To install for root anyway, add --allow-root." fi case "$(uname -m)" in x86_64|amd64) ARCH=x64 ;; aarch64|arm64) ARCH=arm64 ;; *) fail "Unsupported processor: $(uname -m). Helmlane ships for x86_64 and arm64." ;; esac if [ -f /etc/alpine-release ] || { command -v ldd >/dev/null 2>&1 && ldd --version 2>&1 | grep -qi musl; }; then fail "musl-based systems (such as Alpine) cannot run the prebuilt runtime." \ "Use the container image instead: docker run ghcr.io/relicdragon/helmlane-agent (see docs/linux.md)." fi # The bundled Node needs glibc 2.28 or newer (Debian 10, Ubuntu 20.04, RHEL 8). glibc="$(getconf GNU_LIBC_VERSION 2>/dev/null | awk '{print $2}' || true)" if [ -n "$glibc" ]; then major="${glibc%%.*}"; minor="${glibc#*.}"; minor="${minor%%.*}" if [ "$major" -lt 2 ] || { [ "$major" -eq 2 ] && [ "$minor" -lt 28 ]; }; then fail "glibc $glibc is too old; Helmlane needs 2.28 or newer (Ubuntu 20.04+, Debian 10+, RHEL 8+)." fi fi WSL=0 if [ -n "${WSL_DISTRO_NAME:-}" ] || grep -qi microsoft /proc/version 2>/dev/null; then WSL=1; fi DISTRO="$( (. /etc/os-release 2>/dev/null && printf '%s' "${PRETTY_NAME:-Linux}") || printf 'Linux')" DATA_HOME="${XDG_DATA_HOME:-$HOME/.local/share}" RUNTIME_BASE="$DATA_HOME/helmlane/runtime" BIN_DIR="$HOME/.local/bin" WRAPPER="$BIN_DIR/helmlane-agent" UNIT_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user" if command -v curl >/dev/null 2>&1; then fetch() { curl -fsSL --retry 3 --proto '=https' --tlsv1.2 -o "$2" "$1"; } elif command -v wget >/dev/null 2>&1; then fetch() { wget -q --https-only -O "$2" "$1"; } else fetch() { fail "curl or wget is required to download Helmlane."; } fi if command -v sha256sum >/dev/null 2>&1; then digest() { sha256sum "$1" | awk '{print $1}'; } elif command -v shasum >/dev/null 2>&1; then digest() { shasum -a 256 "$1" | awk '{print $1}'; } else digest() { fail "sha256sum is required to verify the download."; } fi command -v tar >/dev/null 2>&1 || fail "tar is required." command -v gzip >/dev/null 2>&1 || fail "gzip is required." ours() { [ -f "$1" ] && grep -q HELMLANE_RUNTIME_ROOT "$1" 2>/dev/null; } # ------------------------------------------------------------------ uninstall if [ "$MODE" = uninstall ]; then say "" say "${BOLD}Removing Helmlane${RESET}" if ours "$WRAPPER" && "$WRAPPER" version >/dev/null 2>&1; then # --purge on the installer's command line is the confirmation; there is # no terminal to ask on when it runs as `curl | sh`. set -- uninstall [ "$PURGE" = 1 ] && set -- "$@" --purge --yes "$WRAPPER" "$@" else # The runtime is broken or half-installed; remove what the installer wrote. if command -v systemctl >/dev/null 2>&1; then systemctl --user disable --now helmlane-agent.service helmlane-sentinel.service helmlane-logrotate.timer >/dev/null 2>&1 || true fi rm -f "$UNIT_DIR/helmlane-agent.service" "$UNIT_DIR/helmlane-sentinel.service" \ "$UNIT_DIR/helmlane-logrotate.service" "$UNIT_DIR/helmlane-logrotate.timer" command -v systemctl >/dev/null 2>&1 && systemctl --user daemon-reload >/dev/null 2>&1 || true rm -rf "$RUNTIME_BASE" ours "$WRAPPER" && rm -f "$WRAPPER" if [ "$PURGE" = 1 ]; then rm -rf "$DATA_HOME/helmlane" "${XDG_STATE_HOME:-$HOME/.local/state}/helmlane" ok "Erased Helmlane's state." fi ok "Removed Helmlane." fi exit 0 fi # ------------------------------------------------------------------ download say "" where="$DISTRO · $ARCH" [ "$WSL" = 1 ] && where="$where · WSL" say "${BOLD}Installing the Helmlane agent${RESET} ${DIM}$where${RESET}" say "" mkdir -p "$RUNTIME_BASE" chmod 700 "$DATA_HOME/helmlane" 2>/dev/null || true INCOMING="$(mktemp -d "$RUNTIME_BASE/.incoming.XXXXXX")" cleanup() { rm -rf "$INCOMING"; } trap cleanup EXIT INT TERM ASSET="helmlane-agent-linux-$ARCH.tar.gz" if [ -n "$BUNDLE" ]; then [ -f "$BUNDLE" ] || fail "No bundle at $BUNDLE" cp "$BUNDLE" "$INCOMING/$ASSET" [ -f "$BUNDLE.sha256" ] && cp "$BUNDLE.sha256" "$INCOMING/$ASSET.sha256" step "Using local bundle $BUNDLE" else if [ -n "$DOWNLOAD_BASE" ]; then base="${DOWNLOAD_BASE%/}" else if [ -z "$VERSION" ]; then fetch "$DOWNLOADS/latest.json" "$INCOMING/latest.json" \ || fail "Could not reach $DOWNLOADS/latest.json" "Check the network and try again." VERSION="$(sed -n 's/^ *"version": *"\([0-9][0-9A-Za-z._+-]*\)",\{0,1\} *$/\1/p' "$INCOMING/latest.json" | head -n 1)" [ -n "$VERSION" ] || fail "The release manifest at $DOWNLOADS/latest.json names no version." fi base="$DOWNLOADS/$VERSION" fi step "Downloading ${VERSION:-the latest release}…" fetch "$base/$ASSET" "$INCOMING/$ASSET" || fail "Download failed: $base/$ASSET" \ "Check the network, or pass --version with a published release." fetch "$base/$ASSET.sha256" "$INCOMING/$ASSET.sha256" || fail "Could not download the checksum for $ASSET." fi if [ -f "$INCOMING/$ASSET.sha256" ]; then expected="$(awk '{print $1}' "$INCOMING/$ASSET.sha256")" [ "$(digest "$INCOMING/$ASSET")" = "$expected" ] || fail "Checksum mismatch: the download is corrupt or was tampered with. Nothing was installed." ok "Verified checksum" elif [ -z "$BUNDLE" ]; then fail "No checksum for $ASSET; refusing to install an unverified download." fi mkdir -p "$INCOMING/unpacked" # Bundles packed on macOS without --no-xattrs carry LIBARCHIVE.xattr headers; GNU tar # warns about each one. Busybox tar has no --warning, so only pass it to GNU. if tar --version 2>/dev/null | grep -q 'GNU tar'; then tar --warning=no-unknown-keyword -xzf "$INCOMING/$ASSET" -C "$INCOMING/unpacked" else tar -xzf "$INCOMING/$ASSET" -C "$INCOMING/unpacked" fi NEW="$INCOMING/unpacked/helmlane-agent" [ -f "$NEW/manifest.json" ] && [ -x "$NEW/bin/helmlane-agent" ] || fail "The bundle is not a Helmlane runtime." manifest_field() { sed -n "s/^ *\"$1\": *\"\{0,1\}\([^\",]*\)\"\{0,1\},\{0,1\}\$/\1/p" "$NEW/manifest.json" | head -n 1; } NEW_VERSION="$(manifest_field version)" NEW_BUILD="$(manifest_field build)" NEW_ARCH="$(manifest_field arch)" [ "$NEW_ARCH" = "$ARCH" ] || fail "That bundle is for $NEW_ARCH; this machine is $ARCH." RUNTIME_ID="$NEW_VERSION+$NEW_BUILD" case "$RUNTIME_ID" in *[!0-9A-Za-z._+-]*|+*) fail "The bundle has an invalid version." ;; esac # The runtime must actually run here before anything points at it. HELMLANE_RUNTIME_ROOT="$NEW" "$NEW/bin/helmlane-agent" version >/dev/null 2>&1 \ || fail "The downloaded runtime does not run on this system ($DISTRO)." \ "Please report this with the output of: $NEW/bin/helmlane-agent version" PREVIOUS="$(readlink "$RUNTIME_BASE/current" 2>/dev/null || true)" if [ -d "$RUNTIME_BASE/$RUNTIME_ID" ] && [ "$FORCE" != 1 ]; then ok "Helmlane $NEW_VERSION (build $NEW_BUILD) is already installed" else rm -rf "$RUNTIME_BASE/$RUNTIME_ID" mv "$NEW" "$RUNTIME_BASE/$RUNTIME_ID" ok "Installed Helmlane $NEW_VERSION (build $NEW_BUILD)" fi # Flip `current` in one rename where the platform allows it. ln -sfn "$RUNTIME_ID" "$RUNTIME_BASE/current.next" mv -Tf "$RUNTIME_BASE/current.next" "$RUNTIME_BASE/current" 2>/dev/null \ || { rm -f "$RUNTIME_BASE/current.next"; ln -sfn "$RUNTIME_ID" "$RUNTIME_BASE/current"; } # Keep the active and the previous runtime (for a manual rollback); drop the rest. for dir in "$RUNTIME_BASE"/*; do name="${dir##*/}" [ -d "$dir" ] || continue [ "$name" = current ] && continue [ "$name" = "$RUNTIME_ID" ] && continue [ -n "$PREVIOUS" ] && [ "$name" = "$PREVIOUS" ] && continue rm -rf "$dir" done mkdir -p "$BIN_DIR" { printf '#!/bin/sh\n' printf '# Installed by the Helmlane installer; launches the current runtime.\n' printf "HELMLANE_RUNTIME_ROOT='%s/current'\n" "$RUNTIME_BASE" printf 'export HELMLANE_RUNTIME_ROOT\n' printf 'exec "$HELMLANE_RUNTIME_ROOT/bin/helmlane-agent" "$@"\n' } > "$WRAPPER.tmp" chmod 0755 "$WRAPPER.tmp" mv -f "$WRAPPER.tmp" "$WRAPPER" # ------------------------------------------------------------------ services if [ "$MODE" = upgrade ]; then "$WRAPPER" service install --restart --quiet || warn "The services could not be restarted; run: helmlane-agent restart" ok "Upgraded to $NEW_VERSION" exit 0 fi step "Setting up the background service…" "$WRAPPER" service install || warn "The service could not be set up yet; see the notes above." if ! command -v git >/dev/null 2>&1; then warn "git is not installed. Coding agents and Helmlane's change view need it:" hint "sudo apt install git # or dnf/pacman/zypper" fi if ! command -v expect >/dev/null 2>&1; then say " ${DIM}•${RESET} Optional: install ${BOLD}expect${RESET} for a full interactive terminal from the app" hint "sudo apt install expect # without it the terminal runs in line mode" fi # ------------------------------------------------------------------ enroll enrolled=0 if [ -n "$TOKEN" ]; then say "" set -- enroll --stdin [ -n "$ORIGIN" ] && set -- "$@" --origin "$ORIGIN" [ "$NO_START" = 1 ] && set -- "$@" --no-start # Split on newlines only, with globbing off, so a path with spaces or a # `*` in it arrives as exactly one argument. old_ifs="$IFS"; IFS=' ' set -f for folder in $FOLDERS; do [ -n "$folder" ] && set -- "$@" --folder "$folder" done set +f IFS="$old_ifs" # Through stdin: the token never appears in the agent CLI's argv. printf '%s' "$TOKEN" | "$WRAPPER" "$@" && enrolled=1 || true fi case ":$PATH:" in *":$BIN_DIR:"*) COMMAND=helmlane-agent ;; *) COMMAND="$WRAPPER" say "" warn "$BIN_DIR is not on your PATH. Add it, then open a new shell:" hint "echo 'export PATH=\"\$HOME/.local/bin:\$PATH\"' >> ~/.profile" ;; esac say "" if [ "$enrolled" = 1 ]; then say "${BOLD}Done.${RESET} Useful commands:" say " ${CYAN}$COMMAND status${RESET} is it online?" say " ${CYAN}$COMMAND folders add PATH${RESET} share a project with the app" say " ${CYAN}$COMMAND logs -f${RESET} follow the logs" else say "${BOLD}Next:${RESET} connect this computer to your account." say " 1. In the Helmlane app: ${BOLD}Computers → Add computer → Linux${RESET}" say " 2. Run the command it shows, or:" say " ${CYAN}$COMMAND enroll --token hl_…${RESET}" fi say ""