diff --git a/.github/workflows/test-install.yml b/.github/workflows/test-install.yml new file mode 100644 index 0000000000..5d74c92f70 --- /dev/null +++ b/.github/workflows/test-install.yml @@ -0,0 +1,33 @@ +name: Test Install + +on: + push: + branches: [main, release/**] + paths: + - install.sh + - test_install.rb + pull_request: + paths: + - install.sh + - test_install.rb + +concurrency: + group: + ${{ github.workflow }}-${{ github.ref_protected == 'true' && github.sha || + github.ref }} + cancel-in-progress: true + +defaults: + run: + shell: bash + +jobs: + test-install: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: ruby/setup-ruby@09a7688d3b55cf0e976497ff046b70949eeaccfd # v1.288.0 + with: + ruby-version: ruby + - name: Run install tests + run: ruby test_install.rb diff --git a/README.md b/README.md index 7537169da5..5516c0f731 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,15 @@ For installation options, run the script with `--help`: curl -fsSL https://github.com/stellar/stellar-cli/raw/main/install.sh | sh -s -- --help ``` +Install with dependency setup (`--install-deps`) to attempt installing: + +- Linux development dependencies (`libdbus`/`libudev` development packages; Linux only, not required on macOS) +- Rust toolchain and `wasm32v1-none` target (for contract build workflows) + +```sh +curl -fsSL https://github.com/stellar/stellar-cli/raw/main/install.sh | sh -s -- --install-deps +``` + Install with Homebrew (macOS, Linux): ```sh diff --git a/install.sh b/install.sh index 880b94f0f6..17b09a4495 100755 --- a/install.sh +++ b/install.sh @@ -8,18 +8,613 @@ set -e REPO="stellar/stellar-cli" BINARY_NAME="stellar" +command_exists() { + command -v "$1" >/dev/null 2>&1 +} + +run_as_root() { + if [ "$(id -u)" = "0" ]; then + "$@" + return + fi + if command_exists sudo; then + sudo "$@" + return + fi + echo "Warning: '$*' requires root privileges, but neither root access nor sudo is available." >&2 + return 1 +} + +detect_linux_distro() { + if [ -r /etc/os-release ]; then + # shellcheck disable=SC1091 + . /etc/os-release + if [ -n "$ID" ]; then + echo "$ID" + return + fi + fi + echo "unknown" +} + +detect_linux_libc() { + if command_exists getconf; then + if getconf GNU_LIBC_VERSION >/dev/null 2>&1; then + echo "glibc" + return + fi + fi + + if command_exists ldd; then + if ldd --version 2>&1 | grep -qi musl; then + echo "musl" + return + fi + if ldd --version 2>&1 | grep -qi "gnu libc"; then + echo "glibc" + return + fi + fi + + if [ -e /lib/ld-musl-x86_64.so.1 ] || [ -e /lib/ld-musl-aarch64.so.1 ]; then + echo "musl" + return + fi + + echo "unknown" +} + +suggest_dependency_install() { + missing_cmds="$1" + # Map missing command names to their actual package names + pkgs="" + for cmd in $missing_cmds; do + case "$cmd" in + sed) [ "$OS" = "macos" ] && pkgs="$pkgs gnu-sed" || pkgs="$pkgs sed" ;; + tar) [ "$OS" = "macos" ] && pkgs="$pkgs gnu-tar" || pkgs="$pkgs tar" ;; + mktemp) pkgs="$pkgs coreutils" ;; + *) pkgs="$pkgs $cmd" ;; + esac + done + pkgs="${pkgs# }" + + if [ "$OS" = "macos" ]; then + cat </dev/null; then + return 0 + fi + if [ -f /usr/include/dbus-1.0/dbus/dbus.h ]; then + return 0 + fi + ;; + udev) + if command_exists pkg-config && pkg-config --exists libudev 2>/dev/null; then + return 0 + fi + if [ -f /usr/include/libudev.h ]; then + return 0 + fi + ;; + esac + return 1 +} + +attempt_runtime_deps_setup() { + if [ "$OS" != "linux" ]; then + return + fi + + pkg_dbus_dev="" + pkg_udev_dev="" + manager="" + missing_pkgs="" + + if command_exists apt-get; then + manager="apt-get" + pkg_dbus_dev="libdbus-1-dev" + pkg_udev_dev="libudev-dev" + elif command_exists dnf; then + manager="dnf" + pkg_dbus_dev="dbus-devel" + pkg_udev_dev="systemd-devel" + elif command_exists yum; then + manager="yum" + pkg_dbus_dev="dbus-devel" + pkg_udev_dev="systemd-devel" + elif command_exists pacman; then + manager="pacman" + pkg_dbus_dev="dbus" + pkg_udev_dev="systemd" + elif command_exists zypper; then + manager="zypper" + pkg_dbus_dev="dbus-1-devel" + pkg_udev_dev="systemd-devel" + else + echo "Warning: Could not detect a supported package manager to install development dependencies." + return + fi + + if [ -n "$pkg_dbus_dev" ] && ! has_dev_dependency dbus; then + missing_pkgs="$missing_pkgs $pkg_dbus_dev" + fi + if [ -n "$pkg_udev_dev" ] && ! has_dev_dependency udev; then + missing_pkgs="$missing_pkgs $pkg_udev_dev" + fi + + missing_pkgs="${missing_pkgs# }" + if [ -z "$missing_pkgs" ]; then + return + fi + + echo "" + echo "Attempting development dependency setup (--install-deps): $missing_pkgs" + + case "$manager" in + apt-get) + if ! run_as_root apt-get update; then + echo "Warning: apt-get update failed." + fi + # shellcheck disable=SC2086 + if ! run_as_root apt-get install -y $missing_pkgs; then + echo "Warning: Failed to install development dependencies via apt-get." + fi + ;; + dnf) + # shellcheck disable=SC2086 + if ! run_as_root dnf install -y $missing_pkgs; then + echo "Warning: Failed to install development dependencies via dnf." + fi + ;; + yum) + # shellcheck disable=SC2086 + if ! run_as_root yum install -y $missing_pkgs; then + echo "Warning: Failed to install development dependencies via yum." + fi + ;; + pacman) + # shellcheck disable=SC2086 + if ! run_as_root pacman -S --needed --noconfirm $missing_pkgs; then + echo "Warning: Failed to install development dependencies via pacman." + fi + ;; + zypper) + # shellcheck disable=SC2086 + if ! run_as_root zypper install -y $missing_pkgs; then + echo "Warning: Failed to install development dependencies via zypper." + fi + ;; + esac +} + +suggest_rust_install() { + if [ "$OS" = "macos" ]; then + cat </dev/null | grep -q '^wasm32v1-none$'; then + attempt_rust_setup + fi + fi + fi + + echo "" + echo "Smart contract Rust environment summary:" + + has_rustup="false" + has_cargo="false" + has_rustc="false" + has_wasm_target="false" + + if command_exists rustup; then + has_rustup="true" + echo " [OK] rustup" + else + echo " [MISSING] rustup" + fi + + if command_exists cargo; then + has_cargo="true" + echo " [OK] cargo" + else + echo " [MISSING] cargo" + fi + + if command_exists rustc; then + has_rustc="true" + echo " [OK] rustc" + else + echo " [MISSING] rustc" + fi + + if [ "$has_rustup" = "true" ]; then + if rustup target list --installed 2>/dev/null | grep -q '^wasm32v1-none$'; then + has_wasm_target="true" + echo " [OK] wasm32v1-none target" + else + echo " [MISSING] wasm32v1-none target" + fi + else + echo " [MISSING] wasm32v1-none target (requires rustup)" + fi + + if [ "$has_rustup" = "false" ] || [ "$has_cargo" = "false" ] || [ "$has_rustc" = "false" ]; then + echo "" + if [ "$INSTALL_DEPS" != true ]; then + suggest_rust_install + echo "" + fi + echo "Then run:" + echo " rustup default stable" + echo " rustup target add wasm32v1-none" + return + fi + + if [ "$has_wasm_target" = "false" ]; then + echo "" + echo "Install missing target with:" + echo " rustup target add wasm32v1-none" + return + fi + + echo " Ready for smart contract builds." +} + +check_runtime_libraries() { + installed_binary="$1" + + if [ "$OS" != "linux" ]; then + return 0 + fi + + if ! command_exists ldconfig; then + return 0 + fi + + ldconfig_output="$(ldconfig -p 2>/dev/null)" + missing_libs="" + + for lib in libdbus-1 libudev; do + if ! printf '%s\n' "$ldconfig_output" | grep -q "$lib"; then + missing_libs="$missing_libs $lib" + fi + done + + missing_libs="${missing_libs# }" + if [ -z "$missing_libs" ]; then + return 0 + fi + + echo "" + echo "Warning: $BINARY_NAME was installed, but runtime shared libraries are missing:" + for lib in $missing_libs; do + printf ' %s\n' "$lib" + echo "" + suggest_runtime_library_install "$lib" + done + echo "" + echo "After installing the runtime dependencies, run:" + printf ' %s --version\n' "$installed_binary" + return 1 +} + +post_install_check() { + installed_binary="$1" + + check_runtime_libraries "$installed_binary" + runtime_ok=$? + + version_output="" + if version_output="$("$installed_binary" --version 2>&1)"; then + return "$runtime_ok" + fi + + echo "" + echo "Warning: post-install check failed:" + printf '%s\n' "$version_output" + + return 1 +} + # Check if user has sudo privileges has_sudo() { if command -v sudo >/dev/null 2>&1; then - # Try sudo -n true to check if we can run sudo without password prompt - # or if user is in sudoers + # Non-interactive check only; avoid prompting during auto-detection. if sudo -n true 2>/dev/null; then return 0 fi - # If sudo requires password, prompt for it once to check - if sudo -v 2>/dev/null; then - return 0 - fi fi return 1 } @@ -27,17 +622,19 @@ has_sudo() { # Parse arguments and set defaults INSTALL_DIR="" USER_INSTALL=false +INSTALL_DEPS=false for arg in "$@"; do case $arg in --user) USER_INSTALL=true INSTALL_DIR="${HOME}/.local/bin" - shift ;; --dir=*) INSTALL_DIR="${arg#*=}" - shift + ;; + --install-deps) + INSTALL_DEPS=true ;; --help) echo "Stellar CLI installer" @@ -47,15 +644,28 @@ for arg in "$@"; do echo "Options:" echo " --user Install to ~/.local/bin (no sudo required)" echo " --dir=PATH Install to custom directory" + echo " --install-deps Attempt to install missing libdbus/libudev dev dependencies and Rust toolchain" echo " --help Show this help message" exit 0 ;; + *) + echo "Error: Unknown option: $arg" + echo "Run with --help to see available options." + exit 1 + ;; esac done +# Determine sudo prefix for suggested commands (empty if already root or sudo unavailable) +if [ "$(id -u)" = "0" ] || ! command_exists sudo; then + SUDO="" +else + SUDO="sudo " +fi + # Set default install directory if not specified if [ -z "$INSTALL_DIR" ]; then - if has_sudo; then + if [ "$(id -u)" = "0" ] || has_sudo; then INSTALL_DIR="/usr/local/bin" else INSTALL_DIR="${HOME}/.local/bin" @@ -79,6 +689,9 @@ case "$OS" in ;; esac +# Validate basic installer dependencies before other checks use them +check_dependencies + # Detect architecture ARCH="$(uname -m)" case "$ARCH" in @@ -96,6 +709,22 @@ esac # Construct target triple if [ "$OS" = "linux" ]; then + LIBC="$(detect_linux_libc)" + if [ "$LIBC" = "musl" ]; then + echo "Error: Detected Linux distro '$(detect_linux_distro)' using musl libc." + echo "This installer downloads a prebuilt Stellar CLI release that targets glibc (GNU/Linux) and is not supported on musl systems (e.g. Alpine)." + if [ -f "${INSTALL_DIR}/${BINARY_NAME}" ]; then + echo "" + echo "Note: Found an existing '$BINARY_NAME' binary at ${INSTALL_DIR}/${BINARY_NAME}." + echo "This binary is built for glibc and will not run correctly on musl-based systems." + fi + echo "" + echo "Recommended next steps:" + echo " 1) Use a glibc-based image (Debian/Ubuntu/Fedora)." + echo " Example: docker run -it --rm debian:bookworm-slim" + echo " 2) If you must stay on Alpine, build stellar-cli from source on Alpine." + exit 1 + fi TARGET="${ARCH}-unknown-linux-gnu" elif [ "$OS" = "macos" ]; then TARGET="${ARCH}-apple-darwin" @@ -103,9 +732,29 @@ fi echo "Detected platform: $OS ($TARGET)" +# Create temporary directory (used for both release response and binary extraction) +TMP_DIR="$(mktemp -d)" +trap 'rm -rf "$TMP_DIR"' EXIT INT TERM HUP + # Get latest release version echo "Fetching latest release..." -LATEST_RELEASE=$(curl -fsSL "https://api.github.com/repos/${REPO}/releases/latest" | grep '"tag_name":' | sed -E 's/.*"([^"]+)".*/\1/') +RELEASE_RESPONSE_FILE="$TMP_DIR/release.json" +if ! RELEASE_HTTP_STATUS="$(curl -sSL -o "$RELEASE_RESPONSE_FILE" -w "%{http_code}" "https://api.github.com/repos/${REPO}/releases/latest")"; then + echo "Error: Could not reach GitHub API to fetch latest release" + exit 1 +fi + +if [ "$RELEASE_HTTP_STATUS" != "200" ]; then + if [ "$RELEASE_HTTP_STATUS" = "403" ] && grep -qi "rate limit" "$RELEASE_RESPONSE_FILE"; then + echo "Error: GitHub API rate limit exceeded while fetching latest release." + echo "Try again later." + else + echo "Error: Could not fetch latest release (GitHub API HTTP $RELEASE_HTTP_STATUS)" + fi + exit 1 +fi + +LATEST_RELEASE="$(grep '"tag_name":' "$RELEASE_RESPONSE_FILE" | sed -E 's/.*"([^"]+)".*/\1/')" if [ -z "$LATEST_RELEASE" ]; then echo "Error: Could not fetch latest release" @@ -121,10 +770,6 @@ DOWNLOAD_URL="https://github.com/${REPO}/releases/download/${LATEST_RELEASE}/${A echo "Downloading from: $DOWNLOAD_URL" -# Create temporary directory -TMP_DIR="$(mktemp -d)" -trap 'rm -rf "$TMP_DIR"' EXIT - # Download and extract cd "$TMP_DIR" if ! curl -fsSL "$DOWNLOAD_URL" -o "$ARCHIVE_NAME"; then @@ -151,8 +796,21 @@ else sudo chmod +x "$INSTALL_DIR/$BINARY_NAME" fi +if [ "$INSTALL_DEPS" = true ]; then + attempt_runtime_deps_setup +fi + +POST_INSTALL_OK=true +if ! post_install_check "$INSTALL_DIR/$BINARY_NAME"; then + POST_INSTALL_OK=false +fi + echo "" -echo "✓ Stellar CLI installed successfully!" +if [ "$POST_INSTALL_OK" = true ]; then + echo "[OK] Stellar CLI installed successfully!" +else + echo "Warning: Stellar CLI binary was installed, but failed post-install verification." +fi echo "" echo "Location: $INSTALL_DIR/$BINARY_NAME" echo "Version: $VERSION" @@ -171,3 +829,9 @@ case ":$PATH:" in echo "" ;; esac + +print_contract_tooling_summary + +if [ "$POST_INSTALL_OK" != true ]; then + exit 1 +fi diff --git a/test_install.rb b/test_install.rb new file mode 100644 index 0000000000..4ca9713269 --- /dev/null +++ b/test_install.rb @@ -0,0 +1,228 @@ +require "open3" +require "shellwords" + +class Matcher + def initialize(string, expected) + @string = string + @expected = expected + end + + def match?(out) + out.include?(@string) == @expected + end + + def inspect + @expected ? "match #{@string.inspect}" : "not match #{@string.inspect}" + end +end + +$EXPECTED_EXIT = 0 + +DISTROS = { + debian: "debian:bookworm-slim", + ubuntu: "ubuntu:latest", + fedora: "fedora:latest" +} + +def run_cmd(*cmd, exit: 0) + formatted_cmd = cmd.map { Shellwords.escape(it) }.join(" ") + out, err, status = Open3.capture3(*cmd) + + if status.exitstatus != exit + fail "Expected status to be #{exit}; " \ + "got #{status.exitstatus}\n\n" \ + "== command ==\n#{formatted_cmd}\n\n" \ + "== stdout ==\n#{format_output(out)}\n\n" \ + "== stderr ==\n#{format_output(err)}" + end + + [out, err] +end + +def run_docker(distro, cmd, exit: 0) + run_cmd( + "docker", + "run", + "--rm", + "-v", + "#{__dir__}:/source:ro", + DISTROS.fetch(distro), + "sh", + "-c", + cmd, + exit: + ) +end + +def match(matcher) + Matcher.new(matcher, true) +end + +def doesnt_match(matcher) + Matcher.new(matcher, false) +end + +def format_output(output) + output = "\\n" if output == "\n" + output = "" if output.empty? + output +end + +def run_test(title, distro:, cmd:, exit: 0, out: nil, err: nil) + title = "\e[34m#{title}\e[0m" + print "\e[33m[ACTING]\e[0m #{title}" + + stdout, stderr = run_docker(distro, cmd, exit:) + outputs = "\n\n== stdout ==\n#{format_output(stdout)}\n\n" + outputs = "#{outputs}== stderr ==\n#{format_output(stderr)}" + + Array(out).each do |item| + if item + unless item.match?(stdout) + fail "Expected output to #{item.inspect}#{outputs}" + end + end + end + + Array(err).each do |item| + if item + unless item.match?(stderr) + fail "Expected output to #{item.inspect}#{outputs}" + end + end + end + + puts "\r\e[32m[PASSED] #{title}\e[0m" +rescue RuntimeError => error + puts "\r\e[31m[FAILED]\e[0m #{title}\nError: #{error.message}\n\n" + $EXPECTED_EXIT = 1 +end + +run_test "sanity: output capture works", + distro: :debian, + cmd: "echo hello_from_container", + out: match("hello_from_container") + +run_test "debian: exits when curl is missing", + distro: :debian, + cmd: "sh /source/install.sh", + exit: 1, + out: match("Error: Missing required command(s): curl") + +run_test "debian: no sudo in suggestions when running as root", + distro: :debian, + cmd: "apt-get update && apt-get -y install curl; sh /source/install.sh", + exit: 1, + out: [doesnt_match("sudo apt-get"), doesnt_match("sudo dnf")] + +run_test "fedora: no sudo in suggestions when running as root", + distro: :fedora, + cmd: "dnf -y install curl; sh /source/install.sh", + exit: 1, + out: [doesnt_match("sudo apt-get"), doesnt_match("sudo dnf")] + +run_test "debian: sudo in suggestions when running as user", + distro: :debian, + cmd: <<~SH, + apt-get update && apt-get -y install curl sudo + adduser --disabled-password --gecos "" test + su -c "sh /source/install.sh" test + SH + exit: 1, + out: match("sudo apt-get") + +run_test "fedora: sudo in suggestions when running as user", + distro: :fedora, + cmd: <<~SH, + dnf -y install curl util-linux + adduser test + runuser -u test -- sh /source/install.sh + SH + exit: 1, + out: match("sudo dnf") + +run_test "debian: warns about missing runtime libraries", + distro: :debian, + cmd: <<~SH, + apt-get update && apt-get -y install curl + apt-get -y --allow-remove-essential remove util-linux libudev1 libapt-pkg6.0 apt + sh /source/install.sh + SH + exit: 1, + out: [ + match("runtime shared libraries are missing"), + match("libdbus-1"), + match("libudev") + ] + +run_test "fedora: warns about missing runtime libraries", + distro: :fedora, + cmd: "dnf -y install curl; rpm -e --nodeps systemd-libs; sh /source/install.sh", + exit: 1, + out: [ + match("runtime shared libraries are missing"), + match("libdbus-1"), + match("libudev") + ] + +run_test "debian: successful install", + distro: :debian, + cmd: "apt-get update && apt-get -y install curl libdbus-1-3 libudev1; sh /source/install.sh", + out: [ + match("Stellar CLI installed successfully"), + doesnt_match("runtime shared libraries are missing") + ] + +run_test "ubuntu: successful install", + distro: :ubuntu, + cmd: "apt-get update && apt-get -y install curl libdbus-1-3 libudev1; sh /source/install.sh", + out: [ + match("Stellar CLI installed successfully"), + doesnt_match("runtime shared libraries are missing") + ] + +run_test "fedora: successful install", + distro: :fedora, + cmd: "dnf -y install curl dbus-libs systemd-libs; sh /source/install.sh", + out: [ + match("Stellar CLI installed successfully"), + doesnt_match("runtime shared libraries are missing") + ] + +run_test "debian: install missing dependencies", + distro: :debian, + cmd: "apt-get update && apt-get -y install curl; sh /source/install.sh --install-deps", + out: [ + match("Stellar CLI installed successfully"), + match("[OK] rustup"), + match("[OK] cargo"), + match("[OK] rustc"), + match("[OK] wasm32v1-none target"), + doesnt_match("runtime shared libraries are missing") + ] + +run_test "ubuntu: install missing dependencies", + distro: :ubuntu, + cmd: "apt-get update && apt-get -y install curl; sh /source/install.sh --install-deps", + out: [ + match("Stellar CLI installed successfully"), + match("[OK] rustup"), + match("[OK] cargo"), + match("[OK] rustc"), + match("[OK] wasm32v1-none target"), + doesnt_match("runtime shared libraries are missing") + ] + +run_test "fedora: install missing dependencies", + distro: :fedora, + cmd: "dnf -y install curl; sh /source/install.sh --install-deps", + out: [ + match("Stellar CLI installed successfully"), + match("[OK] rustup"), + match("[OK] cargo"), + match("[OK] rustc"), + match("[OK] wasm32v1-none target"), + doesnt_match("runtime shared libraries are missing") + ] + +exit $EXPECTED_EXIT