diff --git a/.github/scripts/test-bbx.sh b/.github/scripts/test-bbx.sh new file mode 100755 index 000000000..ce628f711 --- /dev/null +++ b/.github/scripts/test-bbx.sh @@ -0,0 +1,1388 @@ +#!/bin/bash + +# Test script for bbx CLI in BrowserBox repository +# Displays output directly in terminal + +# Ensure common install locations are in PATH for non-login shells. +export PATH="/usr/local/bin:/usr/bin:/bin:${PATH}" + +# Test timeouts (in minutes for timeout command) +TEST_NG_RUN_TIMEOUT="3m" +TEST_TOR_RUN_TIMEOUT="3m" +TEST_INSTALL_TIMEOUT="10m" +TEST_CF_RUN_TIMEOUT="5m" +export SKIP_DOCKER="true" # we haven't build docker images yet so skip + +# Tor bootstrap can be significantly slower/flakier inside CI containers. +if [[ -n "${BBX_CI_CONTAINER_IMAGE:-}" || -f "/.dockerenv" ]]; then + TEST_TOR_RUN_TIMEOUT="6m" + export BBX_CF_MAX_TIME="${BBX_CF_MAX_TIME:-420}" +fi + +if [[ -z "$STATUS_MODE" ]]; then + echo "Set status mode env" >&2 + STATUS_MODE="quick exit" +elif [[ "$STATUS_MODE" == "quick-exit" ]]; then + STATUS_MODE="quick exit" +fi + +export STATUS_MODE="${STATUS_MODE}" + +if [[ -z "$LICENSE_KEY" ]]; then + if [[ -f "${HOME}/.config/dosaygo/bbpro/config" ]]; then + # shellcheck disable=SC1090 + source "${HOME}/.config/dosaygo/bbpro/config" + fi +fi +if [[ -z "$LICENSE_KEY" ]]; then + echo "Set license key env" >&2 + exit 1 +fi +export LICENSE_KEY="${LICENSE_KEY}" + +if [[ -z "$INSTALL_DOC_VIEWER" ]]; then + echo "[ Warning ]: Install doc viewer is not set for tests. Setting..." >&2 + INSTALL_DOC_VIEWER="false" +fi +export INSTALL_DOC_VIEWER="${INSTALL_DOC_VIEWER}" +export BBX_NO_UPDATE="true" +export BBX_SKIP_INSTALL_TESTS="${BBX_SKIP_INSTALL_TESTS:-}" +export BBX_BINARY="${BBX_BINARY:-}" # Path to local browserbox binary for testing +export BBX_SETUP_PORT="${BBX_SETUP_PORT:-9090}" +export BBX_NG_SETUP_PORT="${BBX_NG_SETUP_PORT:-9999}" + +# Resolve script path so we can re-enter the tests reliably after su. +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd -P)" +SCRIPT_PATH="${SCRIPT_DIR}/$(basename "${BASH_SOURCE[0]:-$0}")" + +# Early handoff: If running as root in CI, hand off to install user BEFORE running tests +# This ensures all tests run as the correct user with proper permissions +BB_CONFIG_DIR="${BB_CONFIG_DIR:-${HOME}/.config/dosaygo/bbpro}" +if [ "$(id -u)" -eq 0 ] && [[ -n "$BBX_TEST_AGREEMENT" ]]; then + install_user="" + user_home="" + env_file="" + + # Try to find install user from marker file + if [ -f "${BB_CONFIG_DIR}/.install_user" ]; then + install_user="$(cat "${BB_CONFIG_DIR}/.install_user")" + elif [ -n "$BBX_INSTALL_USER" ]; then + install_user="$BBX_INSTALL_USER" + fi + + if [ -n "$install_user" ] && id "$install_user" &>/dev/null; then + user_home="$(getent passwd "$install_user" | cut -d: -f6)" + env_file="${user_home}/.bbx_env_restore.sh" + + # Verify user has passwordless sudo capability + if ! su - "$install_user" -c "sudo -n true" 2>/dev/null; then + echo "ERROR: Install user '$install_user' does not have passwordless sudo capability." >&2 + echo "BrowserBox tests require sudo. Please ensure the user has NOPASSWD sudo access." >&2 + exit 1 + fi + + # Ensure config dir ownership is correct + if [ -d "$BB_CONFIG_DIR" ]; then + install_group="$(id -gn "$install_user")" + chown -R "${install_user}:${install_group}" "$BB_CONFIG_DIR" 2>/dev/null || true + fi + + # Build env file if it doesn't exist (or refresh it) + su_env_vars=(BBX_HOSTNAME EMAIL LICENSE_KEY BBX_TEST_AGREEMENT STATUS_MODE INSTALL_DOC_VIEWER BBX_NO_UPDATE BBX_RELEASE_REPO BBX_RELEASE_TAG TARGET_RELEASE_REPO PRIVATE_TAG GH_TOKEN GITHUB_TOKEN BBX_INSTALL_USER BB_QUICK_EXIT NVM_DIR NODE_PATH) + : > "$env_file" + for var in "${su_env_vars[@]}"; do + val="${!var-}" + [[ -n "$val" ]] || continue + printf 'export %s=%q\n' "$var" "$val" >> "$env_file" + done + [[ -n "${PATH:-}" ]] && printf 'export PATH=%q\n' "$PATH" >> "$env_file" + chown "${install_user}:$(id -gn "$install_user")" "$env_file" 2>/dev/null || true + chmod 640 "$env_file" 2>/dev/null || true + + # Copy test script to user's home if needed + if [[ ! -f "${user_home}/test-bbx.sh" ]]; then + cp -f "$SCRIPT_PATH" "${user_home}/test-bbx.sh" + chmod +x "${user_home}/test-bbx.sh" + chown "${install_user}:$(id -gn "$install_user")" "${user_home}/test-bbx.sh" + fi + + echo "Root detected in CI mode. Handing off tests to user: $install_user" + exec su - "$install_user" -c "set -a; source $(printf '%q' "$env_file"); cd $(printf '%q' "$user_home") && ./test-bbx.sh; rc=\$?; rm -f $(printf '%q' "$env_file"); exit \$rc" + else + echo "Warning: Running as root but no install user found. Tests may fail." >&2 + fi +fi + +# If BBX_BINARY is set, install it directly and skip bootstrap/download logic +if [[ -n "$BBX_BINARY" ]]; then + if [[ ! -x "$BBX_BINARY" ]]; then + echo "BBX_BINARY is set but not executable: $BBX_BINARY" >&2 + exit 1 + fi + echo "Using local binary: $BBX_BINARY" >&2 + "$BBX_BINARY" --install || exit 1 + BBX_SKIP_INSTALL_TESTS="true" +fi + +# BBX_CMD: prefer globally installed commands from the binary install +if command -v bbx &>/dev/null; then + BBX_CMD="bbx" +elif command -v browserbox &>/dev/null; then + BBX_CMD="browserbox" +elif [[ -x "/usr/local/bin/bbx" ]]; then + BBX_CMD="/usr/local/bin/bbx" +elif [[ -x "/usr/local/bin/browserbox" ]]; then + BBX_CMD="/usr/local/bin/browserbox" +elif [[ -x "/usr/bin/bbx" ]]; then + BBX_CMD="/usr/bin/bbx" +elif [[ -x "/usr/bin/browserbox" ]]; then + BBX_CMD="/usr/bin/browserbox" +else + echo "No bbx/browserbox installed in PATH or common locations" >&2 + exit 1 +fi + +ensure_bbx_cmd() { + # After install, prefer global commands if available + if command -v bbx &>/dev/null; then + BBX_CMD="bbx" + return 0 + elif command -v browserbox &>/dev/null; then + BBX_CMD="browserbox" + return 0 + fi + for candidate in /usr/local/bin/bbx /usr/local/bin/browserbox /usr/bin/bbx /usr/bin/browserbox; do + if [[ -x "$candidate" ]]; then + BBX_CMD="$candidate" + return 0 + fi + done + return 1 +} + +# Safely handle bbcertify output +if command -v bbcertify; then + cert_file=$(bbcertify --no-reservation) + reservation_file="${HOME}/.config/dosaygo/bbpro/tickets/reservation.json" + if [ $? -eq 0 ] && [ -n "$cert_file" ] && [ -f "$cert_file" ]; then + rm -f "$cert_file" + rm -f "$reservation_file" + else + echo "Warning: bbcertify failed or no file to remove" >&2 + fi +fi + +# ANSI colors +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[0;33m' +NC='\033[0m' +PWD="$(pwd)" +BB_CONFIG_DIR="$HOME/.config/dosaygo/bbpro" + +BROWSERBOX_CMD="${BROWSERBOX_CMD:-browserbox}" + +export BBX_CMD BROWSERBOX_CMD + +trap 'saga_bbx_stop &>/dev/null' EXIT + +# Counters for summary +passed=0 +failed=0 +warnings=0 + +# Exit trap for summary +trap 'echo -e "\n${NC}Test Summary:"; \ + echo -e "${GREEN}Passed: $passed${NC}"; \ + echo -e "${RED}Failed: $failed${NC}"; \ + echo -e "${YELLOW}Warnings: $warnings${NC}"' EXIT + +# Environment variables (standardized to uppercase) +export BBX_HOSTNAME="${BBX_HOSTNAME:-localhost}" +export EMAIL="${EMAIL:-test@example.com}" +export LICENSE_KEY="${LICENSE_KEY:-TEST-KEY-1234-5678-90AB-CDEF-GHIJ-KLMN-OPQR}" +export BBX_TEST_AGREEMENT="${BBX_TEST_AGREEMENT:-true}" +export BBX_DEBUG="${BBX_DEBUG:-}" + +is_quick_exit() { + [[ "${STATUS_MODE}" == "quick exit" || -n "${BB_QUICK_EXIT:-}" ]] +} + +port_in_use() { + local port="$1" + if command -v lsof &>/dev/null; then + lsof -iTCP:"$port" -sTCP:LISTEN -n -P >/dev/null 2>&1 + return $? + fi + if command -v ss &>/dev/null; then + ss -ltn "( sport = :$port )" 2>/dev/null | awk 'NR>1 {exit 0} END{exit 1}' + return $? + fi + if command -v netstat &>/dev/null; then + netstat -an 2>/dev/null | awk -v p=":$port" '$0 ~ p && $0 ~ /LISTEN/ {exit 0} END{exit 1}' + return $? + fi + return 1 +} + +nginx_listening_on_port() { + local port="$1" + if command -v lsof &>/dev/null; then + lsof -iTCP:"$port" -sTCP:LISTEN -n -P 2>/dev/null | awk 'NR>1 {print $1}' | grep -qi '^nginx$' + return $? + fi + return 1 +} + +nginx_is_active() { + if command -v pgrep &>/dev/null; then + pgrep -x nginx >/dev/null 2>&1 && return 0 + fi + if command -v lsof &>/dev/null; then + lsof -iTCP -sTCP:LISTEN -n -P 2>/dev/null | awk 'NR>1 {print $1}' | grep -qi '^nginx$' && return 0 + fi + return 1 +} + +record_nginx_active_state() { + local state_file="${BB_CONFIG_DIR}/nginx_active_before" + if nginx_is_active; then + printf '1' > "$state_file" + else + printf '0' > "$state_file" + fi +} + +nginx_active_before() { + local state_file="${BB_CONFIG_DIR}/nginx_active_before" + if [[ -f "$state_file" ]]; then + [[ "$(cat "$state_file" 2>/dev/null)" == "1" ]] + return $? + fi + return 1 +} + +stop_nginx_full() { + if command -v nginx &>/dev/null; then + sudo nginx -s quit >/dev/null 2>&1 || true + sudo nginx -s stop >/dev/null 2>&1 || true + fi + if command -v brew &>/dev/null; then + sudo brew services stop nginx >/dev/null 2>&1 || true + fi + if command -v systemctl &>/dev/null; then + sudo systemctl stop nginx >/dev/null 2>&1 || true + fi + pkill -f nginx >/dev/null 2>&1 || true +} + +cleanup_nginx_sites() { + local removed=0 + local user_prefix="${USER}-" + local brew_prefix="" + local servers_dir="" + local sites_available="" + local sites_enabled="" + local confd_dir="" + + if command -v brew &>/dev/null; then + brew_prefix="$(brew --prefix 2>/dev/null || true)" + if [[ -n "$brew_prefix" ]]; then + servers_dir="${brew_prefix}/etc/nginx/servers" + fi + fi + if [[ -d /etc/nginx/sites-available && -d /etc/nginx/sites-enabled ]]; then + sites_available="/etc/nginx/sites-available" + sites_enabled="/etc/nginx/sites-enabled" + elif [[ -d /etc/nginx/conf.d ]]; then + confd_dir="/etc/nginx/conf.d" + fi + + if [[ -n "$servers_dir" && -d "$servers_dir" ]]; then + if ls "${servers_dir}/${user_prefix}"*.conf >/dev/null 2>&1; then + sudo rm -f "${servers_dir}/${user_prefix}"*.conf >/dev/null 2>&1 || true + removed=1 + fi + fi + if [[ -n "$sites_available" && -d "$sites_available" ]]; then + if ls "${sites_available}/${user_prefix}"*.conf >/dev/null 2>&1; then + sudo rm -f "${sites_available}/${user_prefix}"*.conf >/dev/null 2>&1 || true + removed=1 + fi + if [[ -n "$sites_enabled" && -d "$sites_enabled" ]]; then + if ls "${sites_enabled}/${user_prefix}"*.conf >/dev/null 2>&1; then + sudo rm -f "${sites_enabled}/${user_prefix}"*.conf >/dev/null 2>&1 || true + removed=1 + fi + fi + fi + if [[ -n "$confd_dir" && -d "$confd_dir" ]]; then + if ls "${confd_dir}/${user_prefix}"*.conf >/dev/null 2>&1; then + sudo rm -f "${confd_dir}/${user_prefix}"*.conf >/dev/null 2>&1 || true + removed=1 + fi + fi + + if (( removed )); then + if command -v nginx &>/dev/null; then + sudo nginx -t >/dev/null 2>&1 || true + sudo nginx -s reload >/dev/null 2>&1 || true + elif command -v systemctl &>/dev/null; then + sudo systemctl reload nginx >/dev/null 2>&1 || true + fi + if ! nginx_active_before; then + stop_nginx_full + fi + return 0 + fi + + if command -v setup_nginx &>/dev/null; then + setup_nginx --cleanup >/dev/null 2>&1 || true + if ! nginx_active_before; then + stop_nginx_full + fi + return 0 + fi + return 1 +} + +port_block_free() { + local base="$1" + local p + for p in $((base-2)) $((base-1)) "$base" $((base+1)) $((base+2)); do + if [[ "$p" -le 0 ]]; then + return 1 + fi + if port_in_use "$p"; then + return 1 + fi + done + return 0 +} + +ensure_setup_port() { + local base="$1" + local default_base=8080 + if port_block_free "$base"; then + return 0 + fi + if nginx_listening_on_port "$base" && [[ "${BBX_STOP_NGINX_ON_CONFLICT:-1}" != "0" && "${BBX_STOP_NGINX_ON_CONFLICT:-1}" != "false" ]]; then + echo "Port ${base} is busy (nginx). Cleaning up BrowserBox nginx sites..." >&2 + cleanup_nginx_sites + if port_block_free "$base"; then + return 0 + fi + fi + if [[ "$base" == "$default_base" && "${BBX_ALLOW_SETUP_PORT_FALLBACK:-1}" != "0" && "${BBX_ALLOW_SETUP_PORT_FALLBACK:-1}" != "false" ]]; then + local candidate + for candidate in 9090 10080 11080 12080 13080; do + if port_block_free "$candidate"; then + echo "Default port block ${base} is busy; switching setup port to ${candidate}." >&2 + export BBX_SETUP_PORT="$candidate" + return 0 + fi + done + fi + return 1 +} + +bbx_exec_path() { + ensure_bbx_cmd || return 1 + if [[ -x "$BBX_CMD" ]]; then + printf '%s' "$BBX_CMD" + return 0 + fi + if command -v "$BBX_CMD" &>/dev/null; then + printf '%s' "$BBX_CMD" + return 0 + fi + return 1 +} + +saga_bbx_stop() { + if [[ -x "$BBX_CMD" ]] || command -v "$BBX_CMD" &>/dev/null; then + "$BBX_CMD" stop + elif command -v "$BROWSERBOX_CMD" &>/dev/null; then + "$BROWSERBOX_CMD" pm2 stop bb-main || true + fi +} + +dump_service_logs() { + local label="${1:-}" + local log_dir="${BB_CONFIG_DIR}/service_logs" + local services=(bb-main bb-audio bb-devtools bb-docs) + + echo -e "${YELLOW}⚠ Collecting service logs${label:+ for }${label}...${NC}" + if command -v "$BROWSERBOX_CMD" &>/dev/null; then + for svc in "${services[@]}"; do + echo "== ${svc} logs (pm2) ==" + "$BROWSERBOX_CMD" pm2 logs "$svc" --nostream --lines 200 2>/dev/null || true + local err_file="${log_dir}/${svc}-err.log" + if [[ -f "$err_file" ]]; then + echo "== ${svc} stderr (tail) ==" + tail -n 200 "$err_file" 2>/dev/null || true + fi + done + fi + if [[ -d "$log_dir" ]]; then + for f in "$log_dir"/*.log "$log_dir"/*.err.log; do + [[ -f "$f" ]] || continue + echo "== $(basename "$f") (tail) ==" + tail -n 200 "$f" 2>/dev/null || true + done + fi +} + +saga_bbx_uninstall() { + ensure_bbx_cmd || true + if [[ -x "$BBX_CMD" ]] || command -v "$BBX_CMD" &>/dev/null; then + yes yes | "$BBX_CMD" uninstall + return $? + fi + return 1 +} + +saga_bbx_install() { + ensure_bbx_cmd || true + if [[ -x "$BBX_CMD" ]] || command -v "$BBX_CMD" &>/dev/null; then + yes yes | "$BBX_CMD" install + return $? + fi + if command -v "$BROWSERBOX_CMD" &>/dev/null; then + "$BROWSERBOX_CMD" --install + return $? + fi + echo "No installer found. BBX_CMD=${BBX_CMD} BROWSERBOX_CMD=${BROWSERBOX_CMD}" >&2 + return 1 +} + +saga_bbx_setup() { + ensure_bbx_cmd || true + if [[ -x "$BBX_CMD" ]] || command -v "$BBX_CMD" &>/dev/null; then + "$BBX_CMD" setup "$@" + return $? + fi + return 1 +} + +saga_bbx_run() { + ensure_bbx_cmd || true + if [[ -x "$BBX_CMD" ]] || command -v "$BBX_CMD" &>/dev/null; then + "$BBX_CMD" run + return $? + fi + return 1 +} + +saga_bbx_ng_run() { + ensure_bbx_cmd || true + if [[ -x "$BBX_CMD" ]] || command -v "$BBX_CMD" &>/dev/null; then + "$BBX_CMD" ng-run + return $? + fi + return 1 +} + +saga_bbx_tor_run() { + ensure_bbx_cmd || true + if [[ -x "$BBX_CMD" ]] || command -v "$BBX_CMD" &>/dev/null; then + "$BBX_CMD" tor-run + return $? + fi + return 1 +} + +saga_bbx_cf_run() { + ensure_bbx_cmd || true + if [[ -x "$BBX_CMD" ]] || command -v "$BBX_CMD" &>/dev/null; then + "$BBX_CMD" cf-run + return $? + fi + return 1 +} + +saga_bbx_docker_run() { + ensure_bbx_cmd || true + if [[ -x "$BBX_CMD" ]] || command -v "$BBX_CMD" &>/dev/null; then + "$BBX_CMD" docker-run "$@" + return $? + fi + return 1 +} + +saga_bbx_docker_stop() { + if [[ -x "$BBX_CMD" ]] || command -v "$BBX_CMD" &>/dev/null; then + "$BBX_CMD" docker-stop "$@" + return $? + fi + return 1 +} + +# Function to extract login link from bbx output (cross-platform) +extract_login_link() { + local output="$1" + echo "$output" | grep -E -o 'https?://[^ ]+' +} + +# Function to extract nickname from docker-run output (cross-platform) +extract_nickname() { + local output="$1" + echo "$output" | grep -E -o 'Nickname: [a-zA-Z0-9_-]+' | sed 's/Nickname: //' +} + +# Function to test login link with curl +test_login_link() { + local link="$1" # The URL to test + local use_tor="$2" # Optional: "tor" to use Tor SOCKS proxy + local start_time=$(date +%s) # Record the start time in seconds + local max_time=45 # Maximum wait time in seconds + local interval=2 # Time between retries in seconds + local timeout=5 + local success=0 # Flag to track success + local http_code="" # Variable to store the HTTP status code + local curl_opts="-s -k -L -w %{http_code} --max-time $timeout --fail --output /dev/null" + + # Add Tor SOCKS proxy if specified + if [ "$use_tor" = "tor" ]; then + interval=5 + timeout=25 + max_time=180 + curl_opts="-s -k -L -w %{http_code} --max-time $timeout --fail --output /dev/null --proxy socks5h://127.0.0.1:9050" + echo -n "Testing Tor login link $link with retries... " + else + echo -n "Testing login link $link with retries... " + fi + + # Loop until max_time is reached or success is achieved + while [ $(( $(date +%s) - start_time )) -lt $max_time ]; do + # Execute curl with the constructed options + http_code="$(curl $curl_opts "$link")" + + # Check if the status code starts with '2' (indicating 2xx success) + if [[ "$http_code" =~ ^2 ]]; then + success=1 + break # Exit the loop on success + fi + + # Wait before the next attempt + sleep $interval + done + + # Report the result + if [ $success -eq 1 ]; then + if [ "$use_tor" = "tor" ]; then + echo -e "${GREEN}✔ Success (HTTP $http_code via Tor)${NC}" + else + echo -e "${GREEN}✔ Success (HTTP $http_code)${NC}" + fi + ((passed++)) + return 0 + else + echo -e "${RED}✘ Failed after $max_time seconds (Last HTTP code: $http_code)${NC}" + ((failed++)) + return 1 + fi +} + +test_basic_link() { + local link="$1" # The URL to test + local label="$2" # Human-readable label + local start_time=$(date +%s) # Record the start time in seconds + local max_time=45 # Maximum wait time in seconds + local interval=2 # Time between retries in seconds + local timeout=5 + local success=0 # Flag to track success + local http_code="" # Variable to store the HTTP status code + local curl_opts="-s -k -L -w %{http_code} --max-time $timeout --fail --output /dev/null" + + echo -n "Testing ${label} link $link with retries... " + + while [ $(( $(date +%s) - start_time )) -lt $max_time ]; do + http_code="$(curl $curl_opts "$link")" + + if [[ "$http_code" =~ ^2 ]]; then + success=1 + break + fi + + sleep $interval + done + + if [ $success -eq 1 ]; then + echo -e "${GREEN}✔ Success (HTTP $http_code)${NC}" + ((passed++)) + return 0 + else + echo -e "${RED}✘ Failed after $max_time seconds (Last HTTP code: $http_code)${NC}" + ((failed++)) + return 1 + fi +} + +test_service_links() { + local login_link="$1" + local env_file="${BB_CONFIG_DIR}/test.env" + local hosts_file="${BB_CONFIG_DIR}/hosts.env" + + if [ ! -f "$env_file" ]; then + echo -e "${YELLOW}⚠ Warning: ${env_file} not found; skipping service link checks${NC}" + ((warnings++)) + return 0 + fi + + # shellcheck disable=SC1090 + source "$env_file" + if [[ -n "${HOST_PER_SERVICE-}" && -f "$hosts_file" ]]; then + # shellcheck disable=SC1090 + source "$hosts_file" + fi + + local token="${LOGIN_TOKEN:-}" + if [ -z "$token" ]; then + token="$(echo "$login_link" | sed -n 's/.*token=\([^&]*\).*/\1/p')" + fi + + local proto="${login_link%%://*}" + local hostport="${login_link#*://}" + hostport="${hostport%%/*}" + local host="${hostport%:*}" + local has_port="yes" + if [[ "$hostport" == "$host" ]]; then + has_port="no" + fi + + if [[ -z "${AUDIO_PORT-}" || -z "${DEVTOOLS_PORT-}" || -z "${DOCS_PORT-}" ]]; then + echo -e "${YELLOW}⚠ Warning: service ports missing in ${env_file}; skipping service link checks${NC}" + ((warnings++)) + return 0 + fi + + service_hostport() { + local port="$1" + local addr_var="ADDR_${port}" + local addr="${!addr_var-}" + if [[ -n "$addr" ]]; then + if [[ "$addr" == *:* ]]; then + printf '%s' "$addr" + else + printf '%s:%s' "$addr" "$port" + fi + return + fi + if [[ "$has_port" == "yes" ]]; then + printf '%s:%s' "$host" "$port" + else + printf '%s' "$host" + fi + } + + local audio_hostport devtools_hostport docs_hostport + audio_hostport="$(service_hostport "$AUDIO_PORT")" + devtools_hostport="$(service_hostport "$DEVTOOLS_PORT")" + docs_hostport="$(service_hostport "$DOCS_PORT")" + + local audio_link="${proto}://${audio_hostport}/login?token=${token}" + local devtools_link="${proto}://${devtools_hostport}/login?token=${token}" + local docs_link="${proto}://${docs_hostport}/" + + local ok=0 + if [[ -n "${BBX_SKIP_AUDIO-}" ]]; then + echo -e "${YELLOW}⚠ Warning: BBX_SKIP_AUDIO set; skipping audio service check${NC}" + ((warnings++)) + else + local platform + platform="$(uname -s)" + if [[ "$platform" == "MINGW"* || "$platform" == "MSYS"* || "$platform" == "CYGWIN"* ]]; then + echo -e "${YELLOW}⚠ Warning: Windows detected; skipping audio service check${NC}" + ((warnings++)) + else + test_basic_link "$audio_link" "audio service" || ok=1 + fi + fi + test_basic_link "$devtools_link" "devtools service" || ok=1 + test_basic_link "$docs_link" "docs service" || ok=1 + + if [ $ok -ne 0 ]; then + dump_service_logs "service link failures" + return 1 + fi + return 0 +} +# Test functions +test_uninstall() { + echo "Uninstalling bbx... " + if saga_bbx_uninstall; then + echo -e "${GREEN}✔ Success${NC}" + ((passed++)) + return 0 + else + echo -e "${YELLOW}⚠ Warning${NC}" + ((warnings++)) + return 0 + fi +} + +test_install() { + echo "Installing bbx... " + local timeout_cmd="timeout" + local install_output="" + local install_rc=0 + local bbx_exec="" + local platform + platform="$(uname -s)" + if [[ "$platform" == "Darwin" ]]; then + brew install coreutils + if command -v gtimeout &>/dev/null; then + timeout_cmd="gtimeout" + else + timeout_cmd="" + fi + fi + if ! bbx_exec="$(bbx_exec_path)"; then + echo "Install failed: bbx not found and no bootstrap available." >&2 + echo -e "${RED}✘ Failed${NC}" + ((failed++)) + exit 1 + fi + if [[ -n "$timeout_cmd" ]] && command -v "$timeout_cmd" &>/dev/null; then + install_output="$( + set -o pipefail + yes yes | "$timeout_cmd" -k 15s "$TEST_INSTALL_TIMEOUT" "$bbx_exec" install + 2>&1)" + install_rc=$? + else + echo "Warning: timeout not available; running install without timeout" >&2 + install_output="$( + set -o pipefail + yes yes | "$bbx_exec" install + 2>&1)" + install_rc=$? + fi + if [ $install_rc -eq 0 ]; then + echo -e "${GREEN}✔ Success${NC}" + ((passed++)) + # Update BBX_CMD to use the installed binary + if command -v bbx &>/dev/null; then + BBX_CMD="bbx" + echo "Using installed bbx: $BBX_CMD" + elif command -v browserbox &>/dev/null; then + BBX_CMD="browserbox" + BROWSERBOX_CMD="browserbox" + echo "Using installed browserbox: $BBX_CMD" + fi + else + if [[ -n "$install_output" ]]; then + echo "$install_output" + fi + echo "Install failed (exit $install_rc). timeout_cmd=${timeout_cmd:-none}" >&2 + echo -e "${RED}✘ Failed${NC}" + ((failed++)) + exit 1 + fi + # if we just installed as root, then we have created a correct user called something or yes so let's hand off install script to them :) + if [ "$(id -u)" -eq 0 ]; then + if [ -f "${BB_CONFIG_DIR}/.install_user" ]; then + install_user="$(cat "${BB_CONFIG_DIR}"/.install_user)" + if id "$install_user" &>/dev/null; then + # Verify user has passwordless sudo capability + if ! su - "$install_user" -c "sudo -n true" 2>/dev/null; then + echo "ERROR: Install user '$install_user' does not have passwordless sudo capability." >&2 + echo "BrowserBox tests require sudo. Please ensure the user has NOPASSWD sudo access." >&2 + exit 1 + fi + # Copy BrowserBox directory to install user's home + user_home="$(getent passwd "$install_user" | cut -d: -f6)" + install_group="$(id -gn "$install_user")" + if [ -d "$user_home/.bbx/BrowserBox" ]; then + sudo -u "$install_user" cp -r "$user_home/.bbx/BrowserBox" "$user_home/" 2>/dev/null || true + fi + # Fix ownership of config dir + if [ -d "$BB_CONFIG_DIR" ]; then + chown -R "${install_user}:${install_group}" "$BB_CONFIG_DIR" 2>/dev/null || true + fi + # Forward BBX-related env plus PATH vars via file in user's home. + su_env_vars=(BBX_HOSTNAME EMAIL LICENSE_KEY BBX_TEST_AGREEMENT STATUS_MODE INSTALL_DOC_VIEWER BBX_NO_UPDATE BBX_RELEASE_REPO BBX_RELEASE_TAG TARGET_RELEASE_REPO PRIVATE_TAG GH_TOKEN GITHUB_TOKEN BBX_INSTALL_USER BB_QUICK_EXIT NVM_DIR NODE_PATH) + env_file="${user_home}/.bbx_env_restore.sh" + : > "$env_file" + for var in "${su_env_vars[@]}"; do + val="${!var-}" + [[ -n "$val" ]] || continue + printf 'export %s=%q\n' "$var" "$val" >> "$env_file" + done + [[ -n "${PATH:-}" ]] && printf 'export PATH=%q\n' "$PATH" >> "$env_file" + chown "${install_user}:${install_group}" "$env_file" 2>/dev/null || true + chmod 640 "$env_file" 2>/dev/null || true + exec su - "${install_user}" -c "set -a; source $(printf '%q' "$env_file"); cd $(printf '%q' "$SCRIPT_DIR") && $(printf '%q' "$SCRIPT_PATH"); rc=\$?; rm -f $(printf '%q' "$env_file"); exit \$rc" + else + echo "Warning: Install user $install_user does not exist, continuing as root" + fi + else + echo "Warning: No .install_user file found, continuing as root" + fi + fi + return 0 +} + +test_setup() { + echo "Setting up bbx... " + if ! ensure_setup_port "${BBX_SETUP_PORT}"; then + echo -e "${RED}✘ Failed (no available setup port block)${NC}" + ((failed++)) + exit 1 + fi + if saga_bbx_setup --port "${BBX_SETUP_PORT}" --hostname localhost; then + echo -e "${GREEN}✔ Success${NC}" + ((passed++)) + return 0 + fi + if [[ "${BBX_ALLOW_SETUP_PORT_FALLBACK:-1}" != "0" && "${BBX_ALLOW_SETUP_PORT_FALLBACK:-1}" != "false" ]]; then + local candidate + for candidate in 9090 10080 11080 12080 13080; do + if port_block_free "$candidate"; then + echo "Retrying setup with fallback port ${candidate}." >&2 + export BBX_SETUP_PORT="$candidate" + if saga_bbx_setup --port "${BBX_SETUP_PORT}" --hostname localhost; then + echo -e "${GREEN}✔ Success${NC}" + ((passed++)) + return 0 + fi + fi + done + fi + echo -e "${RED}✘ Failed${NC}" + ((failed++)) + exit 1 +} + +test_run() { + echo "Running bbx... " + echo "DEBUG: About to call saga_bbx_run" + output="$(saga_bbx_run 2>&1)" + exit_code=$? + echo "DEBUG: saga_bbx_run returned exit_code=$exit_code" + echo "DEBUG: Output was:" + echo "$output" + echo "DEBUG: Checking for service_logs directory..." + ls -la "$HOME/.config/dosaygo/bbpro/" 2>/dev/null || echo "Config dir not found" + ls -la "$HOME/.config/dosaygo/bbpro/service_logs/" 2>/dev/null || echo "service_logs dir not found" + login_link="$(extract_login_link "$output" | tail -n 1)" + echo "DEBUG: Extracted login_link=$login_link" + if [ -z "$login_link" ] || [ $exit_code -ne 0 ]; then + echo -e "${RED}✘ Failed (No login link or run failed)${NC}" + echo "$output" + ((failed++)) + saga_bbx_stop + return 1 + fi + echo -e "${GREEN}✔ Success (Run completed)${NC}" + ((passed++)) + + # Test login link immediately + if [ -f ~/.nvm/nvm.sh ]; then + source ~/.nvm/nvm.sh 2>/dev/null || true + fi + if command -v timeout &>/dev/null && command -v browserbox &>/dev/null; then + timeout 15s browserbox pm2 list 2>/dev/null || true + fi + + if ! test_login_link "$login_link"; then + saga_bbx_stop + return 1 + fi + if ! test_service_links "$login_link"; then + saga_bbx_stop + return 1 + fi + # Wait 45 seconds and test again + echo "Waiting 45 seconds to check instance activity... " + sleep 45 + echo -e "${GREEN}✔ Wait complete${NC}" + ((passed++)) + if ! test_login_link "$login_link"; then + saga_bbx_stop + return 1 + fi + if ! test_service_links "$login_link"; then + saga_bbx_stop + return 1 + fi + saga_bbx_stop + return 0 +} + +test_ng_run() { + # This test is only reliable on macOS where nginx setup is more predictable for local testing + if [[ "$(uname -s)" != "Darwin" ]]; then + echo "Skipping Nginx run test (only runs on macOS for now)" + ((passed++)) + return 0 + fi + + record_nginx_active_state + echo "Running bbx with Nginx... " + # use wildcard-able hostname for ng-run + if ! saga_bbx_setup --port "${BBX_NG_SETUP_PORT}" --hostname "ci.test" -z; then + echo -e "${RED}✘ Failed (bbx setup failed for ng-run)${NC}" + ((failed++)) + return 1 + fi + local bbx_exec="" + local timeout_cmd="timeout" + if ! bbx_exec="$(bbx_exec_path)"; then + echo -e "${RED}✘ Failed (bbx command not found)${NC}" + ((failed++)) + return 1 + fi + if [[ "$(uname -s)" == "Darwin" ]]; then + if command -v gtimeout &>/dev/null; then + timeout_cmd="gtimeout" + else + timeout_cmd="" + fi + fi + if [[ -n "$timeout_cmd" ]] && command -v "$timeout_cmd" &>/dev/null; then + "$timeout_cmd" -k 15s "$TEST_NG_RUN_TIMEOUT" "$bbx_exec" ng-run 2>&1 + else + "$bbx_exec" ng-run 2>&1 + fi + exit_code=$? + output="$(cat "${BB_CONFIG_DIR}/login.link")" + login_link="$(extract_login_link "$output" | tail -n 1)" + if [ -z "$login_link" ] || [ $exit_code -ne 0 ]; then + echo -e "${RED}✘ Failed (No login link or ng-run failed)${NC}" + ((failed++)) + saga_bbx_stop + return 1 + fi + echo -e "${GREEN}✔ Success (Nginx run completed)${NC}" + ((passed++)) + + # Test login link immediately + if ! test_login_link "$login_link"; then + saga_bbx_stop + return 1 + fi + + if is_quick_exit; then + saga_bbx_stop + return 0 + fi + + # Wait 25 seconds and test again + echo "Waiting 25 seconds to check instance activity... " + sleep 25 + echo -e "${GREEN}✔ Wait complete${NC}" + ((passed++)) + if ! test_login_link "$login_link"; then + saga_bbx_stop + return 1 + fi + saga_bbx_stop + if [[ "${BBX_STOP_NGINX_AFTER_NG_RUN:-1}" != "0" && "${BBX_STOP_NGINX_AFTER_NG_RUN:-1}" != "false" ]]; then + cleanup_nginx_sites + fi + return 0 +} + +test_tor_run() { + echo "Running bbx with Tor... " + local bbx_exec="" + local timeout_cmd="timeout" + if ! bbx_exec="$(bbx_exec_path)"; then + echo -e "${RED}✘ Failed (bbx command not found)${NC}" + ((failed++)) + return 1 + fi + if [[ "$(uname -s)" == "Darwin" ]]; then + if command -v gtimeout &>/dev/null; then + timeout_cmd="gtimeout" + else + timeout_cmd="" + fi + fi + if [[ -n "$timeout_cmd" ]] && command -v "$timeout_cmd" &>/dev/null; then + "$timeout_cmd" -k 15s "$TEST_TOR_RUN_TIMEOUT" "$bbx_exec" tor-run 2>&1 + else + "$bbx_exec" tor-run 2>&1 + fi + exit_code=$? + output="$(cat "${BB_CONFIG_DIR}/login.link")" + login_link="$(extract_login_link "$output" | tail -n 1)" + if [ -z "$login_link" ] || [ $exit_code -ne 0 ]; then + echo -e "${RED}✘ Failed (No login link or tor-run failed)${NC}" + ((failed++)) + saga_bbx_stop + return 1 + fi + echo -e "${GREEN}✔ Success (Tor run completed)${NC}" + ((passed++)) + + # Test login link immediately + if ! test_login_link "$login_link" "tor"; then + saga_bbx_stop + return 1 + fi + + # Wait 25 seconds and test again + if is_quick_exit; then + saga_bbx_stop + return 0 + fi + echo "Waiting 25 seconds to check instance activity... " + sleep 25 + echo -e "${GREEN}✔ Wait complete${NC}" + ((passed++)) + if ! test_login_link "$login_link" "tor"; then + saga_bbx_stop + return 1 + fi + saga_bbx_stop + return 0 +} + +test_cf_run() { + if [[ -f /.dockerenv ]]; then + local os_id="" + if [[ -f /etc/os-release ]]; then + # shellcheck disable=SC1091 + source /etc/os-release + os_id="${ID:-}" + fi + if [[ "$os_id" == "centos" || "$os_id" == "rhel" || "$os_id" == "fedora" ]]; then + echo -e "${YELLOW}⚠ Warning: ${os_id} container detected; using local-run CF path with IPv4 edge${NC}" + export BBX_CF_USE_LOCAL_RUN=1 + fi + fi + # use ipV4 edges universally as they seem less flaky + export BBX_CF_EDGE_IP_VERSION="${BBX_CF_EDGE_IP_VERSION:-4}" + # Check if we have internet connectivity by testing Cloudflare endpoint + if ! curl --connect-timeout 5 -s -o /dev/null https://www.cloudflare.com 2>/dev/null; then + echo "Skipping Cloudflare tunnel test (no internet connectivity)" + ((passed++)) + return 0 + fi + + if [[ -n "${BBX_CF_USE_LOCAL_RUN:-}" ]]; then + echo "Running Cloudflare tunnel against local bbx run... " + if ! command -v cloudflared &>/dev/null; then + echo -e "${YELLOW}⚠ Warning: cloudflared not found; skipping CF test${NC}" + ((warnings++)) + return 0 + fi + + if ! saga_bbx_setup --port "${BBX_SETUP_PORT}" --hostname localhost; then + echo -e "${RED}✘ Failed (bbx setup failed for CF run)${NC}" + ((failed++)) + return 1 + fi + + output="$(saga_bbx_run 2>&1)" + login_link="$(extract_login_link "$output" | tail -n 1)" + if [ -z "$login_link" ]; then + echo -e "${RED}✘ Failed (No login link or run failed)${NC}" + ((failed++)) + saga_bbx_stop + return 1 + fi + + local env_file="${BB_CONFIG_DIR}/test.env" + if [ ! -f "$env_file" ]; then + echo -e "${YELLOW}⚠ Warning: ${env_file} not found; skipping CF test${NC}" + ((warnings++)) + saga_bbx_stop + return 0 + fi + # shellcheck disable=SC1090 + source "$env_file" + local local_port="${APP_PORT:-}" + local token="${LOGIN_TOKEN:-}" + if [[ -z "$local_port" ]]; then + echo -e "${YELLOW}⚠ Warning: APP_PORT missing; skipping CF test${NC}" + ((warnings++)) + saga_bbx_stop + return 0 + fi + + local cf_log_file="${BB_CONFIG_DIR}/cloudflared.log" + local scheme="https" + if [[ "${BBX_HTTP_ONLY:-}" == "true" ]]; then + scheme="http" + fi + + local cf_edge_args=() + if [[ -n "${BBX_CF_EDGE_IP_VERSION:-}" ]]; then + cf_edge_args+=(--edge-ip-version "${BBX_CF_EDGE_IP_VERSION}") + fi + cloudflared tunnel --no-autoupdate "${cf_edge_args[@]}" --url "${scheme}://127.0.0.1:${local_port}" --no-tls-verify > "$cf_log_file" 2>&1 & + cf_pid=$! + + local attempts=0 + local max_attempts=120 + local tunnel_url="" + while [ $attempts -lt $max_attempts ]; do + if [ -f "$cf_log_file" ]; then + tunnel_url=$(grep -oE 'https://[a-zA-Z0-9-]+\.trycloudflare\.com' "$cf_log_file" | head -1) + if [ -n "$tunnel_url" ]; then + break + fi + fi + sleep 0.5 + attempts=$((attempts + 1)) + done + + if [ -z "$tunnel_url" ]; then + echo -e "${RED}✘ Failed to extract tunnel URL from cloudflared log${NC}" + kill $cf_pid 2>/dev/null || true + saga_bbx_stop + return 1 + fi + + local cf_login_link="${tunnel_url}/login?token=${token}" + echo -e "${GREEN}✔ Success (CF run started, link: ${cf_login_link})${NC}" + ((passed++)) + + sleep 10 + + local start_time=$(date +%s) + local max_time="${BBX_CF_MAX_TIME:-180}" + local interval="${BBX_CF_INTERVAL:-4}" + local timeout=10 + local success=0 + local http_code="" + local curl_opts="-s -k -L -w %{http_code} --max-time $timeout --fail --output /dev/null" + echo -n "Testing CF login link ${cf_login_link} with retries... " + while [ $(( $(date +%s) - start_time )) -lt $max_time ]; do + http_code="$(curl $curl_opts "$cf_login_link")" + if [[ "$http_code" =~ ^2 ]]; then + success=1 + break + fi + sleep $interval + done + if [ $success -ne 1 ]; then + if [[ -n "${BBX_CI_CONTAINER_IMAGE:-}" || -f "/.dockerenv" ]]; then + echo -e "${YELLOW}⚠ Warning: CF login link check did not reach 2xx within $max_time seconds (Last HTTP code: $http_code); treating as non-fatal in CI containers${NC}" + ((warnings++)) + kill $cf_pid 2>/dev/null || true + saga_bbx_stop + return 0 + fi + echo -e "${RED}✘ Failed after $max_time seconds (Last HTTP code: $http_code)${NC}" + kill $cf_pid 2>/dev/null || true + saga_bbx_stop + return 1 + fi + echo -e "${GREEN}✔ Success (HTTP $http_code)${NC}" + ((passed++)) + + kill $cf_pid 2>/dev/null || true + saga_bbx_stop + return 0 + fi + + echo "Running bbx with Cloudflare tunnel... " + # Run cf-run with a timeout (allow extra time for tunnel propagation) + local bbx_exec="" + local timeout_cmd="timeout" + if ! bbx_exec="$(bbx_exec_path)"; then + echo -e "${RED}✘ Failed (bbx command not found)${NC}" + ((failed++)) + return 1 + fi + if [[ "$(uname -s)" == "Darwin" ]]; then + if command -v gtimeout &>/dev/null; then + timeout_cmd="gtimeout" + else + timeout_cmd="" + fi + fi + if [[ -n "$timeout_cmd" ]] && command -v "$timeout_cmd" &>/dev/null; then + "$timeout_cmd" -k 15s "$TEST_CF_RUN_TIMEOUT" "$bbx_exec" cf-run 2>&1 & + else + "$bbx_exec" cf-run 2>&1 & + fi + cf_pid=$! + + # Wait for cf-run to start and create login.link + sleep 30 + + if [ ! -f "${BB_CONFIG_DIR}/login.link" ]; then + echo -e "${YELLOW}⚠ Warning: login.link not found (cf-run may still be starting)${NC}" + kill $cf_pid 2>/dev/null || true + saga_bbx_stop + ((warnings++)) + return 0 + fi + + output="$(cat "${BB_CONFIG_DIR}/login.link")" + login_link="$(extract_login_link "$output" | tail -n 1)" + + if [ -z "$login_link" ]; then + echo -e "${YELLOW}⚠ Warning: No login link found${NC}" + kill $cf_pid 2>/dev/null || true + saga_bbx_stop + ((warnings++)) + return 0 + fi + + echo -e "${GREEN}✔ Success (CF run started, link: ${login_link})${NC}" + ((passed++)) + + # Wait for the local origin to be reachable before hitting the tunnel. + local env_file="${BB_CONFIG_DIR}/test.env" + if [ -f "$env_file" ]; then + # shellcheck disable=SC1090 + source "$env_file" + local local_port="${APP_PORT:-}" + local token="${LOGIN_TOKEN:-}" + if [[ -n "$local_port" ]]; then + local origin_link="http://127.0.0.1:${local_port}/login" + if [[ -n "$token" ]]; then + origin_link="${origin_link}?token=${token}" + fi + local start_time=$(date +%s) + local max_time=60 + local interval=2 + local http_code="" + while [ $(( $(date +%s) - start_time )) -lt $max_time ]; do + http_code="$(curl -s -L -w %{http_code} --max-time 5 --fail --output /dev/null "$origin_link")" + if [[ "$http_code" =~ ^2 ]]; then + break + fi + sleep $interval + done + fi + fi + + # Cloudflare quick tunnels can take a bit to become active. + sleep 10 + + # Test login link with longer retries for tunnel propagation. + local start_time=$(date +%s) + local max_time="${BBX_CF_MAX_TIME:-180}" + local interval="${BBX_CF_INTERVAL:-4}" + local timeout=10 + local success=0 + local http_code="" + local curl_opts="-s -k -L -w %{http_code} --max-time $timeout --fail --output /dev/null" + echo -n "Testing CF login link $login_link with retries... " + while [ $(( $(date +%s) - start_time )) -lt $max_time ]; do + http_code="$(curl $curl_opts "$login_link")" + if [[ "$http_code" =~ ^2 ]]; then + success=1 + break + fi + sleep $interval + done + if [ $success -ne 1 ]; then + echo -e "${RED}✘ Failed after $max_time seconds (Last HTTP code: $http_code)${NC}" + kill $cf_pid 2>/dev/null || true + saga_bbx_stop + return 1 + fi + echo -e "${GREEN}✔ Success (HTTP $http_code)${NC}" + ((passed++)) + + # Cleanup + kill $cf_pid 2>/dev/null || true + saga_bbx_stop + + echo -e "${GREEN}✔ CF run test complete${NC}" + ((passed++)) + return 0 +} + +test_docker_run() { + # Self-detect if running in a Docker container + if [[ -n "$SKIP_DOCKER" ]] || [ -f /.dockerenv ] || ([[ "$(uname -s)" == "Darwin" ]] && ! command -v docker &>/dev/null); then + echo "Skipping Dockerized bbx test (detected running in Docker container or macOS, or environment has SKIP_DOCKER)" + ((passed++)) # Increment passed to maintain test count + return 0 + fi + + echo "Running Dockerized bbx... " + nickname="test-docker" + local bbx_exec="" + local timeout_cmd="timeout" + if ! bbx_exec="$(bbx_exec_path)"; then + echo -e "${RED}✘ Failed (bbx command not found)${NC}" + ((failed++)) + return 1 + fi + if [[ "$(uname -s)" == "Darwin" ]]; then + if command -v gtimeout &>/dev/null; then + timeout_cmd="gtimeout" + else + timeout_cmd="" + fi + fi + if [[ -n "$timeout_cmd" ]] && command -v "$timeout_cmd" &>/dev/null; then + output="$(timeout -k 15s 10m "$bbx_exec" docker-run "$nickname" 2>&1)" + else + output="$("$bbx_exec" docker-run "$nickname" 2>&1)" + fi + echo "$output" + exit_code=$? + login_link="$(extract_login_link "$output" | tail -n 1)" + if [ -z "$login_link" ] || [ -z "$nickname" ] || [ $exit_code -ne 0 ]; then + echo -e "${RED}✘ Failed (No login link, nickname, or docker-run failed)${NC}" + echo "$output" + ((failed++)) + saga_bbx_docker_stop "$nickname" + return 1 + fi + echo -e "${GREEN}✔ Success (Docker run completed)${NC}" + ((passed++)) + + # Test login link + if ! test_login_link "$login_link"; then + saga_bbx_docker_stop "$nickname" + return 1 + fi + + # Wait 25 seconds and test again + if is_quick_exit; then + return 0 + fi + echo "Waiting 25 seconds to check instance activity... " + sleep 25 + echo -e "${GREEN}✔ Wait complete${NC}" + ((passed++)) + + # Test login link + if ! test_login_link "$login_link"; then + saga_bbx_docker_stop "$nickname" + return 1 + fi + + # Stop Docker instance with nickname + echo "Stopping Dockerized bbx with nickname $nickname... " + if saga_bbx_docker_stop "$nickname"; then + echo -e "${GREEN}✔ Success${NC}" + ((passed++)) + else + echo -e "${RED}✘ Failed${NC}" + ((failed++)) + return 1 + fi + return 0 +} + +# Main test sequence +echo "Starting bbx Test Saga..." + +test_setup || exit 1 +test_run || exit 1 +test_ng_run || exit 1 +if [[ "${BBX_RESET_AFTER_NG_RUN:-1}" != "0" && "${BBX_RESET_AFTER_NG_RUN:-1}" != "false" ]]; then + test_setup || exit 1 +fi +test_tor_run || exit 1 +test_cf_run || exit 1 +test_docker_run || exit 1 + +# Cleanup + saga_bbx_stop || true + +echo "bbx Test Saga completed!" +exit 0 diff --git a/.github/workflows/bbx-saga.yaml b/.github/workflows/bbx-saga.yaml index cf7298b21..2da358f64 100644 --- a/.github/workflows/bbx-saga.yaml +++ b/.github/workflows/bbx-saga.yaml @@ -6,14 +6,18 @@ on: workflow_dispatch: inputs: release_tag: - description: "Release tag to test (e.g., v1.2.3). Defaults to the published release tag when run from a release event." - required: false - default: "" + description: "Release tag to test (e.g., v15.10.2)" + required: true type: string - release_repo: - description: "Release repo (owner/name)." + disable_tmate: + description: "Disable tmate debug sessions on failure" required: false - default: "BrowserBox/BrowserBox" + default: false + type: boolean + matrix_json: + description: "JSON matrix (keys: os, container_image, exclude). Leave empty for default." + required: false + default: "" type: string concurrency: @@ -21,73 +25,95 @@ concurrency: cancel-in-progress: true permissions: - contents: read # Required for actions/checkout + contents: read actions: read env: RELEASE_TAG: ${{ github.event_name == 'release' && github.event.release.tag_name || github.event.inputs.release_tag || '' }} - TARGET_RELEASE_REPO: ${{ github.event.inputs.release_repo || 'BrowserBox/BrowserBox' }} + DISABLE_TMATE: ${{ github.event.inputs.disable_tmate || 'false' }} + TARGET_RELEASE_REPO: BrowserBox/BrowserBox jobs: - build: - continue-on-error: ${{ matrix.os == 'windows-latest' }} + saga: + name: build (${{ matrix.os }}${{ matrix.container_image != '' && format(', {0}', matrix.container_image) || '' }}) strategy: fail-fast: false - matrix: - os: [ubuntu-latest, macos-latest, windows-latest] - container_image: - - '' # No container (native runner) - - 'dokken/centos-stream-10' - - 'debian:latest' - exclude: - - os: macos-latest - container_image: 'dokken/centos-stream-10' - - os: macos-latest - container_image: 'debian:latest' - - os: windows-latest - container_image: 'dokken/centos-stream-10' - - os: windows-latest - container_image: 'debian:latest' + matrix: ${{ fromJSON(github.event_name == 'workflow_dispatch' && github.event.inputs.matrix_json != '' && github.event.inputs.matrix_json || '{"os":["ubuntu-latest","macos-latest","windows-latest"],"container_image":["","dokken/centos-stream-10","debian:latest"],"exclude":[{"os":"macos-latest","container_image":"dokken/centos-stream-10"},{"os":"macos-latest","container_image":"debian:latest"},{"os":"windows-latest","container_image":"dokken/centos-stream-10"},{"os":"windows-latest","container_image":"debian:latest"}]}' ) }} runs-on: ${{ matrix.os }} - timeout-minutes: 10 container: ${{ matrix.container_image }} + timeout-minutes: 15 steps: - - name: Resolve release under test + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Validate release tag shell: bash run: | set -euo pipefail if [[ -z "${RELEASE_TAG}" ]]; then - echo "release_tag is required when manually dispatched." >&2 + echo "RELEASE_TAG is required (release event or workflow_dispatch input release_tag)." >&2 exit 1 fi echo "Testing release: ${TARGET_RELEASE_REPO}@${RELEASE_TAG}" - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Prepare test script (Unix/macOS) + - name: Prepare clean test workspace (Unix/macOS) if: matrix.os != 'windows-latest' shell: bash - run: chmod +x tests/test-bbx.sh + run: | + set -euo pipefail + cp .github/scripts/test-bbx.sh "$HOME/test-bbx.sh" + chmod +x "$HOME/test-bbx.sh" + echo "BBX_TEST_DIR=$HOME" >> "$GITHUB_ENV" + rm -rf "$GITHUB_WORKSPACE" + mkdir -p "$GITHUB_WORKSPACE" - - name: Install BrowserBox via binary (Unix/macOS) + - name: Install BrowserBox (Unix/macOS) if: matrix.os != 'windows-latest' shell: bash env: BBX_INSTALL_USER: "bbxuser" BBX_TEST_AGREEMENT: "true" - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} BBX_HOSTNAME: "localhost" - BB_QUICK_EXIT: "yesplease" EMAIL: "test@example.com" LICENSE_KEY: ${{ secrets.BB_LICENSE_KEY }} STATUS_MODE: ${{ secrets.STATUS_MODE_KEY }} INSTALL_DOC_VIEWER: "false" + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + BBX_DEBUG_OS_LABEL: ${{ matrix.container_image != '' && matrix.container_image || matrix.os }} BBX_RELEASE_REPO: ${{ env.TARGET_RELEASE_REPO }} BBX_RELEASE_TAG: ${{ env.RELEASE_TAG }} + BBX_NO_UPDATE: "true" + working-directory: ${{ env.BBX_TEST_DIR }} run: | - chmod +x ./bbx.sh - ./bbx.sh install + set -euo pipefail + if ! command -v curl >/dev/null 2>&1 || ! command -v jq >/dev/null 2>&1 || ! command -v xxd >/dev/null 2>&1 || ! command -v sudo >/dev/null 2>&1; then + if command -v apt-get >/dev/null 2>&1; then + apt-get update -y + apt-get install -y curl jq xxd sudo + elif command -v dnf >/dev/null 2>&1; then + dnf install -y curl jq vim-common sudo + elif command -v yum >/dev/null 2>&1; then + yum install -y curl jq vim-common sudo + elif command -v apk >/dev/null 2>&1; then + apk add --no-cache curl jq xxd sudo + else + echo "curl, jq, xxd, and sudo are required but no supported package manager found." >&2 + exit 1 + fi + fi + if command -v sudo >/dev/null 2>&1; then + if [[ -w /etc/sudoers.d ]] && grep -qE '^[[:space:]]*#includedir[[:space:]]+/etc/sudoers.d' /etc/sudoers; then + echo "bbxuser ALL=(ALL) NOPASSWD:ALL" >/etc/sudoers.d/bbxuser + chmod 0440 /etc/sudoers.d/bbxuser + elif [[ -w /etc/sudoers ]]; then + if ! grep -qE '^bbxuser[[:space:]]+ALL=\(ALL\)[[:space:]]+NOPASSWD:ALL' /etc/sudoers; then + echo "bbxuser ALL=(ALL) NOPASSWD:ALL" >>/etc/sudoers + fi + fi + fi + install_url="https://raw.githubusercontent.com/BrowserBox/BrowserBox/${RELEASE_TAG}/deploy-scripts/install.sh" + echo "Using installer: ${install_url}" + curl -fsSL "$install_url" | bash - name: Execute BBX Test Saga (Unix/macOS) if: matrix.os != 'windows-latest' @@ -95,7 +121,6 @@ jobs: env: BBX_INSTALL_USER: "bbxuser" BBX_HOSTNAME: "localhost" - BB_QUICK_EXIT: "yesplease" EMAIL: "test@example.com" LICENSE_KEY: ${{ secrets.BB_LICENSE_KEY }} BBX_TEST_AGREEMENT: "true" @@ -104,23 +129,19 @@ jobs: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} BBX_RELEASE_REPO: ${{ env.TARGET_RELEASE_REPO }} BBX_RELEASE_TAG: ${{ env.RELEASE_TAG }} + BBX_NO_UPDATE: "true" + BBX_CI_CONTAINER_IMAGE: ${{ matrix.container_image }} + BB_QUICK_EXIT: "yesplease" + working-directory: ${{ env.BBX_TEST_DIR }} run: | - [ -z "$BBX_HOSTNAME" ] && echo "BBX_HOSTNAME is not set" || echo "BBX_HOSTNAME is set" - [ -z "$EMAIL" ] && echo "EMAIL is not set" || echo "EMAIL is set" - [ -z "$LICENSE_KEY" ] && echo "LICENSE_KEY is not set" || echo "LICENSE_KEY is set" - [ -z "$BBX_TEST_AGREEMENT" ] && echo "BBX_TEST_AGREEMENT is not set" || echo "BBX_TEST_AGREEMENT is set" - [ -z "$STATUS_MODE" ] && echo "STATUS_MODE is not set" || echo "STATUS_MODE is set" - [ -z "$INSTALL_DOC_VIEWER" ] && echo "INSTALL_DOC_VIEWER is not set" || echo "INSTALL_DOC_VIEWER is set to $INSTALL_DOC_VIEWER" - export INSTALL_DOC_VIEWER STATUS_MODE BBX_TEST_AGREEMENT LICENSE_KEY EMAIL BBX_HOSTNAME BB_QUICK_EXIT - ./tests/test-bbx.sh - continue-on-error: false + set -euo pipefail + ./test-bbx.sh - name: Execute BBX Test Saga (Windows) if: matrix.os == 'windows-latest' shell: powershell env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - BBX_FORCE_CHROME_INSTALL: "true" BBX_HOSTNAME: "localhost" EMAIL: "test@example.com" BB_QUICK_EXIT: "surewhatevs" @@ -129,29 +150,33 @@ jobs: STATUS_MODE: ${{ secrets.STATUS_MODE_KEY }} BBX_RELEASE_REPO: ${{ env.TARGET_RELEASE_REPO }} BBX_RELEASE_TAG: ${{ env.RELEASE_TAG }} + BBX_NO_UPDATE: "true" + BBX_FORCE_CHROME_INSTALL: "false" + BBX_DONT_KILL_CHROME_ON_STOP: "false" run: | - # Debug variables - if (-not $env:BBX_HOSTNAME) { Write-Host "BBX_HOSTNAME is not set" } else { Write-Host "BBX_HOSTNAME is set" } - if (-not $env:EMAIL) { Write-Host "EMAIL is not set" } else { Write-Host "EMAIL is set" } - if (-not $env:LICENSE_KEY) { Write-Host "LICENSE_KEY is not set" } else { Write-Host "LICENSE_KEY is set" } - if (-not $env:BBX_TEST_AGREEMENT) { Write-Host "BBX_TEST_AGREEMENT is not set" } else { Write-Host "BBX_TEST_AGREEMENT is set" } - if (-not $env:STATUS_MODE) { Write-Host "STATUS_MODE is not set" } else { Write-Host "STATUS_MODE is set" } - # Install BrowserBox from checked-out repo - .\windows-scripts\bbx.ps1 install + if (-not $env:RELEASE_TAG) { + Write-Error "RELEASE_TAG is required (release event or workflow_dispatch input release_tag)." + exit 1 + } + $installUrl = "https://raw.githubusercontent.com/BrowserBox/BrowserBox/$($env:RELEASE_TAG)/windows-scripts/install.ps1" + Write-Host "Using installer: $installUrl" + irm $installUrl | iex if (-not (Get-Command bbx -ErrorAction SilentlyContinue)) { Write-Error "bbx not found in PATH after install" exit 1 } - winget install cURL.cURL --silent --accept-source-agreements --accept-package-agreements + if (-not (Get-Command curl.exe -ErrorAction SilentlyContinue)) { + winget install cURL.cURL --silent --accept-source-agreements --accept-package-agreements + } if (-not (Get-Command curl.exe -ErrorAction SilentlyContinue)) { Write-Error "curl.exe not installed" exit 1 } - Write-Host "curl.exe installed successfully" + Write-Host "curl.exe available" bbx setup -Hostname "$env:BBX_HOSTNAME" -Email "$env:EMAIL" -Port 9999 $loginLink = Get-Content "$env:USERPROFILE\.config\dosyago\bbpro\login.link" Write-Host "Login link: $loginLink" - bbx run + bbx run Write-Host "Testing URL: $loginLink" $maxRetries = 10 $retryCount = 0 @@ -168,24 +193,53 @@ jobs: } catch { Write-Host "Retry $($retryCount + 1)/$maxRetries failed: $_" } - if (-not $success) { Start-Sleep -Seconds 2; $retryCount++ } + if (-not $success) { Start-Sleep -Seconds 4; $retryCount++ } } if (-not $success) { Write-Error "Failed to connect to $loginLink after $maxRetries retries." exit 1 } - Write-Host "Waiting 25 seconds to verify link stability..." - Start-Sleep -Seconds 25 + $uri = [System.Uri]$loginLink + $port = $uri.Port + $devtoolsPort = $port + 1 + $devtoolsLink = $loginLink.Replace(":$port", ":$devtoolsPort") + Write-Host "Testing Devtools URL: $devtoolsLink" + try { + $response = curl.exe -k -L "$devtoolsLink" -o NUL -w "%{http_code}" + if ($response -ne "000") { + Write-Host "Devtools connection successful: $response" + } else { + Write-Error "Devtools connection failed: HTTP $response" + exit 1 + } + } catch { + Write-Error "Devtools connection failed: $_" + exit 1 + } + Write-Host "Waiting 45 seconds to verify link stability..." + Start-Sleep -Seconds 45 try { $response = curl.exe -k -L "$loginLink" -o NUL -w "%{http_code}" if ($response -ne "000") { - Write-Host "Second verification successful: $response" + Write-Host "Second verification (Main) successful: $response" + } else { + Write-Error "Second verification (Main) failed: HTTP $response" + exit 1 + } + } catch { + Write-Error "Second verification (Main) failed after 45s: $_" + exit 1 + } + try { + $response = curl.exe -k -L "$devtoolsLink" -o NUL -w "%{http_code}" + if ($response -ne "000") { + Write-Host "Second verification (Devtools) successful: $response" } else { - Write-Error "Second verification failed: HTTP $response" + Write-Error "Second verification (Devtools) failed: HTTP $response" exit 1 } } catch { - Write-Error "Second verification failed after 25s: $_" + Write-Error "Second verification (Devtools) failed after 45s: $_" exit 1 } bbx stop @@ -195,32 +249,66 @@ jobs: } else { Write-Host "No Node processes remain -- cleanup successful." } - - - name: Print BBX Logs on Failure (Windows) - if: failure() && matrix.os == 'windows-latest' - shell: powershell + + - name: Print BBX Logs on Failure (Unix/macOS) + if: failure() && matrix.os != 'windows-latest' + id: print_logs + shell: bash run: | - $logDir = "$env:USERPROFILE\.config\dosyago\bbpro\logs" - $logs = @("browserbox-main-out.log", "browserbox-main-err.log", "browserbox-devtools-out.log", "browserbox-devtools-err.log") - foreach ($log in $logs) { - $logPath = Join-Path $logDir $log - if (Test-Path $logPath) { - Write-Host "Contents of ${log}:" - Get-Content $logPath - } else { - Write-Host "$log not found." - } - } + echo "===== Installer debug logs =====" + shopt -s nullglob + for log in /tmp/bbx-install-debug-*/*/*/install.log; do + echo "===== ${log} (last 200 lines) =====" + tail -200 "$log" + done + shopt -u nullglob + LOG_DIR="$HOME/.config/dosaygo/bbpro/service_logs" + echo "===== Checking $LOG_DIR =====" + if [[ -d "$LOG_DIR" ]]; then + for log in bb-main-out.log bb-main-err.log; do + if [[ -f "$LOG_DIR/$log" ]]; then + echo "===== Contents of $log (last 200 lines) =====" + tail -200 "$LOG_DIR/$log" + else + echo "$log not found" + fi + done + echo "logs_found=true" >> "$GITHUB_OUTPUT" + else + echo "Log directory not found: $LOG_DIR" + echo "Checking if config dir exists..." + ls -la "$HOME/.config/dosaygo/bbpro/" 2>/dev/null || echo "Config dir not found" + echo "logs_found=false" >> "$GITHUB_OUTPUT" + fi + + - name: Debug with tmate (Unix/macOS) + if: failure() && matrix.os != 'windows-latest' && steps.print_logs.outputs.logs_found == 'false' && env.DISABLE_TMATE != 'true' + uses: mxschmitt/action-tmate@v3 + timeout-minutes: 10 + with: + limit-access-to-actor: true + env: + BBX_INSTALL_USER: "bbxuser" + BBX_HOSTNAME: "localhost" + BB_QUICK_EXIT: "yesplease" + EMAIL: "test@example.com" + LICENSE_KEY: ${{ secrets.BB_LICENSE_KEY }} + BBX_TEST_AGREEMENT: "true" + STATUS_MODE: ${{ secrets.STATUS_MODE_KEY }} + INSTALL_DOC_VIEWER: "false" + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + BBX_RELEASE_REPO: ${{ env.TARGET_RELEASE_REPO }} + BBX_RELEASE_TAG: ${{ env.RELEASE_TAG }} + BBX_NO_UPDATE: "true" - name: Cleanup (Unix/macOS) if: always() && matrix.os != 'windows-latest' shell: bash run: | - # Try multiple cleanup methods - if [ -x ./bbx.sh ]; then - ./bbx.sh stop || true - elif command -v bbx &>/dev/null; then + if command -v bbx &>/dev/null; then bbx stop || true + elif command -v browserbox &>/dev/null; then + browserbox pm2 stop bb-main || true fi - # Kill any remaining browserbox processes pkill -f browserbox || true + diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..80ebe64a3 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,12 @@ +# Agent Notes (BrowserBox public repo) + +## Scope + +This repo (`BrowserBox/BrowserBox`) contains the public release surface: installers, workflows, and public documentation. + +## CI / saga iteration + +- Primary workflow: `.github/workflows/bbx-saga.yaml` +- Debugging guide: `docs/ci-saga-debugging.md` +- Trigger one matrix slice at a time: `admin-tools/retrigger-public-saga.sh` + diff --git a/INDEX.md b/INDEX.md new file mode 100644 index 000000000..39f44cbaf --- /dev/null +++ b/INDEX.md @@ -0,0 +1,11 @@ +# BrowserBox Index + +## Release & CI + +- Release flow (private → public): `docs/release-flow.md` +- Public saga debugging loop: `docs/ci-saga-debugging.md` + +## Admin tools + +- Public saga retrigger helper: `admin-tools/retrigger-public-saga.sh` + diff --git a/admin-tools/README.md b/admin-tools/README.md new file mode 100644 index 000000000..72a8e6b0e --- /dev/null +++ b/admin-tools/README.md @@ -0,0 +1,4 @@ +# Admin tools (public) + +- `admin-tools/retrigger-public-saga.sh`: triggers `.github/workflows/bbx-saga.yaml` with a single OS/container slice. + diff --git a/admin-tools/retrigger-public-saga.sh b/admin-tools/retrigger-public-saga.sh new file mode 100755 index 000000000..fbbb53dec --- /dev/null +++ b/admin-tools/retrigger-public-saga.sh @@ -0,0 +1,133 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat <<'USAGE' +Usage: admin-tools/retrigger-public-saga.sh [options] + +Triggers the public saga workflow (`.github/workflows/bbx-saga.yaml`) in BrowserBox/BrowserBox +with a narrowed matrix (one OS, optionally one or more container images). + +Options: + --repo Default: BrowserBox/BrowserBox + --ref Default: main + --os Default: ubuntu-latest + --containers Container images csv; use "native" for no container. Default: native + --no-tmate Disable tmate debug steps + --watch Watch the run until completion (saves failed logs to /tmp) + +Examples: + admin-tools/retrigger-public-saga.sh v15.10.2 --os ubuntu-latest --containers debian:latest --no-tmate --watch + admin-tools/retrigger-public-saga.sh v15.10.2 --os ubuntu-latest --containers native --no-tmate --watch + admin-tools/retrigger-public-saga.sh v15.10.2 --os macos-latest --no-tmate --watch + admin-tools/retrigger-public-saga.sh v15.10.2 --os windows-latest --no-tmate --watch +USAGE +} + +if [[ $# -lt 1 ]]; then + usage + exit 2 +fi + +release_tag="$1" +shift + +repo="BrowserBox/BrowserBox" +ref="main" +os="ubuntu-latest" +containers_csv="native" +disable_tmate="false" +watch="false" + +while [[ $# -gt 0 ]]; do + case "$1" in + --repo) + repo="${2:?missing value for --repo}" + shift 2 + ;; + --ref) + ref="${2:?missing value for --ref}" + shift 2 + ;; + --os) + os="${2:?missing value for --os}" + shift 2 + ;; + --containers) + containers_csv="${2:?missing value for --containers}" + shift 2 + ;; + --no-tmate) + disable_tmate="true" + shift 1 + ;; + --watch) + watch="true" + shift 1 + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "Unknown arg: $1" >&2 + usage + exit 2 + ;; + esac +done + +if ! command -v gh >/dev/null 2>&1; then + echo "gh CLI is required." >&2 + exit 1 +fi +if ! command -v jq >/dev/null 2>&1; then + echo "jq is required." >&2 + exit 1 +fi + +readarray -t containers < <(printf '%s' "$containers_csv" | tr ',' '\n' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//' | awk 'NF') +if [[ "${#containers[@]}" -eq 0 ]]; then + containers=("native") +fi + +containers_json="$(printf '%s\n' "${containers[@]}" | jq -Rsc 'split("\n")[:-1] | map(if . == "native" then "" else . end)')" +matrix_json="$(jq -cn --arg os "$os" --argjson containers "$containers_json" '{os:[$os], container_image:$containers, exclude:[] }')" + +prev_id="$(gh run list --repo "$repo" --workflow bbx-saga.yaml --limit 1 --json databaseId --jq '.[0].databaseId // empty' 2>/dev/null || true)" + +echo "Triggering bbx-saga.yaml on ${repo}@${ref} for ${release_tag}..." +gh workflow run bbx-saga.yaml \ + --repo "$repo" \ + --ref "$ref" \ + -f "release_tag=${release_tag}" \ + -f "disable_tmate=${disable_tmate}" \ + -f "matrix_json=${matrix_json}" >/dev/null + +echo "Waiting for run to register..." +run_id="" +for _ in {1..60}; do + run_id="$(gh run list --repo "$repo" --workflow bbx-saga.yaml --limit 5 --json databaseId --jq '.[0].databaseId // empty' 2>/dev/null || true)" + if [[ -n "$run_id" && "$run_id" != "$prev_id" ]]; then + break + fi + sleep 2 +done + +if [[ -z "$run_id" || "$run_id" == "$prev_id" ]]; then + echo "Failed to detect new run id." >&2 + exit 1 +fi + +echo "Run: https://github.com/${repo}/actions/runs/${run_id}" + +if [[ "$watch" == "true" ]]; then + gh run watch "$run_id" --repo "$repo" --exit-status || { + out_dir="/tmp/bbx-public-saga/${run_id}" + mkdir -p "$out_dir" + gh run view "$run_id" --repo "$repo" --log-failed >"${out_dir}/log-failed.txt" 2>&1 || true + echo "Saved failed logs to ${out_dir}/log-failed.txt" >&2 + exit 1 + } +fi + diff --git a/bbx.sh b/bbx.sh index dc68f56b2..ce81388cb 100755 --- a/bbx.sh +++ b/bbx.sh @@ -518,7 +518,7 @@ if ([ "$EUID" -ne 0 ] && ! $SUDO true 2>/dev/null); then fi # env -export BBX_DONT_KILL_CHROME_ON_STOP="true" +export BBX_DONT_KILL_CHROME_ON_STOP="${BBX_DONT_KILL_CHROME_ON_STOP-true}" export BBX_REQUIRE_RELEASE=1 # Default paths @@ -527,7 +527,7 @@ BBX_NEW_DIR="${BBX_HOME}/new" COMMAND_DIR="" REPO_URL="https://github.com/BrowserBox/BrowserBox-source" owner_repo="${REPO_URL#https://github.com/}" -BBX_SHARE="/usr/local/share/dosyago" +BBX_SHARE="/usr/local/share/dosaygo" if [[ ":$PATH:" == *":/usr/local/bin:"* ]] && $SUDO test -w /usr/local/bin; then COMMAND_DIR="/usr/local/bin" elif $SUDO test -w /usr/bin; then @@ -540,7 +540,7 @@ fi BBX_BIN="${COMMAND_DIR}/bbx" # Config file (secondary to test.env and login.link) -BB_CONFIG_DIR="${HOME}/.config/dosyago/bbpro" +BB_CONFIG_DIR="${HOME}/.config/dosaygo/bbpro" CONFIG_FILE="${BB_CONFIG_DIR}/config" CERT_META_FILE="${BB_CONFIG_DIR}/tickets/cert.meta.env" [ ! -d "$BB_CONFIG_DIR" ] && mkdir -p "$BB_CONFIG_DIR" @@ -567,7 +567,7 @@ clean_temp_installers() { # Ensure installation_id exists with a UUID ensure_installation_id() { - local INSTALL_ID_DIR="${HOME}/.config/dosyago/bbpro" + local INSTALL_ID_DIR="${HOME}/.config/dosaygo/bbpro" local INSTALL_ID_FILE="${INSTALL_ID_DIR}/installation_id" # Create directory if it doesn't exist with owner-only permissions @@ -988,11 +988,12 @@ get_canonical_bbx_version() { # Set the canonical version for use throughout the script VERSION="$(get_canonical_bbx_version)" -if ! command -v bbpro &>/dev/null || ! test -d "${HOME}/.config/dosyago/bbpro"; then +if ! command -v bbpro &>/dev/null || ! test -d "${HOME}/.config/dosaygo/bbpro"; then if [[ "$1" != "install" ]] && [[ "$1" != "uninstall" ]] && [[ "$1" != "update-background" ]] && [[ "$1" != "--version" ]] && [[ "$1" != "-v" ]] && [[ "$1" != "--help" ]] && [[ "$1" != "-h" ]]; then banner - printf "\n${RED}Run ${NC}${BOLD}bbx install${NC}${RED} first.${NC}\n" - printf "\tYou may need to run bbx uninstall to remove any previous or broken installation.\n" + printf "\n${RED}BrowserBox is not installed yet.${NC}\n" + printf "\tRun: ${BOLD}curl -fsSL https://browserbox.io/install.sh | bash${NC}\n" + printf "\tIf reinstalling, run ${BOLD}bbx uninstall${NC} first.\n" exit 1 fi fi @@ -2345,7 +2346,7 @@ echo "SSH tunnel process started with PID: \$tunnel_pid" sleep 8 echo -e "\${GREEN}Tunnel established!${NC}" -loginLink="\$(ssh -T -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "\$remote_user_at_host" "cat ~/.config/dosyago/bbpro/login.link")" +loginLink="\$(ssh -T -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "\$remote_user_at_host" "cat ~/.config/dosaygo/bbpro/login.link")" printf "\nAccess BrowserBox at: \${GREEN}\${loginLink}\${NC}\n\n" echo -e "This script will keep the tunnel alive. Press \${YELLOW}Ctrl+C\${NC} to stop." @@ -2467,7 +2468,18 @@ cf_run() { # Start cloudflared in background with logs to BB_CONFIG_DIR local cf_log_file="${BB_CONFIG_DIR}/cloudflared.log" printf "${YELLOW}Starting Cloudflare tunnel to http://127.0.0.1:${PORT}...${NC}\n" - cloudflared tunnel --no-autoupdate --url "http://127.0.0.1:${PORT}" > "$cf_log_file" 2>&1 & + + # Build cloudflared args with optional edge IP version + local cf_edge_args=() + local cf_edge_ip_version="${BBX_CF_EDGE_IP_VERSION:-4}" + if [[ "$cf_edge_ip_version" != "4" && "$cf_edge_ip_version" != "6" ]]; then + printf "${YELLOW}Warning: BBX_CF_EDGE_IP_VERSION must be 4 or 6 (got: %s); defaulting to 4${NC}\n" "$cf_edge_ip_version" + cf_edge_ip_version="4" + fi + cf_edge_args+=(--edge-ip-version "${cf_edge_ip_version}") + printf "${YELLOW}Using edge IP version: ${cf_edge_ip_version}${NC}\n" + + cloudflared tunnel --no-autoupdate "${cf_edge_args[@]}" --url "http://127.0.0.1:${PORT}" > "$cf_log_file" 2>&1 & local cf_pid=$! # Cleanup function for cf_run - defined before trap to ensure proper execution order @@ -2875,34 +2887,61 @@ pre_install() { create_master_user "$install_user" fi + install_group="$(id -gn "$install_user")" + local user_home + user_home="$(getent passwd "$install_user" | cut -d: -f6)" + + # Fix ownership of any root-created files BEFORE switching to user + if [[ -d "$BB_CONFIG_DIR" ]]; then + chown -R "${install_user}:${install_group}" "$BB_CONFIG_DIR" 2>/dev/null || true + fi + cp -f "$0" /tmp/bbx.sh chmod +x /tmp/bbx.sh - install_group="$(id -gn "$install_user")" chown "${install_user}:${install_group}" /tmp/bbx.sh - # Build a temp env file to persist BBX-related vars across the login shell. - local su_env_vars=(BBX_HOSTNAME EMAIL LICENSE_KEY BBX_TEST_AGREEMENT STATUS_MODE INSTALL_DOC_VIEWER BBX_NO_UPDATE BBX_RELEASE_REPO BBX_RELEASE_TAG TARGET_RELEASE_REPO PRIVATE_TAG GH_TOKEN GITHUB_TOKEN BBX_INSTALL_USER BB_QUICK_EXIT) + # Build a comprehensive env file to persist vars across the login shell. + # Include all BBX-related vars plus PATH-related vars for nvm/node. + local su_env_vars=( + BBX_HOSTNAME EMAIL LICENSE_KEY BBX_TEST_AGREEMENT STATUS_MODE + INSTALL_DOC_VIEWER BBX_NO_UPDATE BBX_RELEASE_REPO BBX_RELEASE_TAG + TARGET_RELEASE_REPO PRIVATE_TAG GH_TOKEN GITHUB_TOKEN BBX_INSTALL_USER + BB_QUICK_EXIT NVM_DIR NODE_PATH + ) local env_file - env_file="$(mktemp)" + env_file="${user_home}/.bbx_env_restore.sh" + # Start fresh + : > "$env_file" local var val for var in "${su_env_vars[@]}"; do val="${!var-}" [[ -n "$val" ]] || continue - printf '%s=%q\n' "$var" "$val" >> "$env_file" + printf 'export %s=%q\n' "$var" "$val" >> "$env_file" done + # Preserve PATH separately (critical for finding nvm, node, etc.) + if [[ -n "${PATH:-}" ]]; then + printf 'export PATH=%q\n' "$PATH" >> "$env_file" + fi chown "${install_user}:${install_group}" "$env_file" 2>/dev/null || true chmod 640 "$env_file" 2>/dev/null || true # Switch to the non-root user and run install echo "Switching to user $install_user..." su - "$install_user" -c "set -a; source $(printf '%q' "$env_file"); /tmp/bbx.sh install" + local install_rc=$? if [[ -z "$BBX_TEST_AGREEMENT" ]] || [ -t 0 ]; then # Replace the root shell with the new user's shell exec su - "$install_user" -c "set -a; source $(printf '%q' "$env_file"); rm -f $(printf '%q' "$env_file"); bash -l" else - rm -f "$env_file" - return 1 + # In CI/CD mode, keep env file for test script handoff and return install exit code + # The test script will source this file when it hands off to the install user + if [[ $install_rc -eq 0 ]]; then + return 1 # Signal to caller that we've handled root->user switch + else + rm -f "$env_file" + exit $install_rc + fi fi else # If not running as root, continue with the normal install @@ -3317,6 +3356,28 @@ update() { return 1 fi + # Download manifest and signature to global system location (multiuser support) + local manifest_url="https://github.com/${PUBLIC_REPO}/releases/download/${repo_tag}/release.manifest.json" + local sig_url="https://github.com/${PUBLIC_REPO}/releases/download/${repo_tag}/release.manifest.json.sig" + local global_dir="/usr/local/share/dosaygo/bbpro" + local user_config_dir="${HOME}/.config/dosaygo/bbpro" + + # Try global location first (requires sudo), fallback to user dir + if [[ -w "/usr/local/share" ]] || [[ "$EUID" -eq 0 ]]; then + mkdir -p "$global_dir" 2>/dev/null || sudo mkdir -p "$global_dir" + printf "${YELLOW}Downloading release manifest to global location...${NC}\n" + curl -L -sS --connect-timeout 10 -o "${global_dir}/release.manifest.json" "$manifest_url" || true + curl -L -sS --connect-timeout 10 -o "${global_dir}/release.manifest.json.sig" "$sig_url" || true + # Set permissions for all users to read + chmod 644 "${global_dir}/release.manifest.json" "${global_dir}/release.manifest.json.sig" 2>/dev/null || true + else + # Fallback to user config dir if no write access to global + mkdir -p "$user_config_dir" + printf "${YELLOW}Downloading release manifest to user config...${NC}\n" + curl -L -sS --connect-timeout 10 -o "${user_config_dir}/release.manifest.json" "$manifest_url" || true + curl -L -sS --connect-timeout 10 -o "${user_config_dir}/release.manifest.json.sig" "$sig_url" || true + fi + # Execute printf "${YELLOW}Running post-update installation tasks...${NC}\n" "$temp_exe" --install @@ -3490,7 +3551,7 @@ stop_user() { local current_time=$(date +%s) local should_schedule=true local home_dir=$(get_home_dir "$user") - local expiry_file="$home_dir/.config/dosyago/bbpro/expiry_time" + local expiry_file="$home_dir/.config/dosaygo/bbpro/expiry_time" # Check for existing expiry time if $SUDO test -f "$expiry_file"; then @@ -3514,7 +3575,7 @@ stop_user() { echo "$SUDO -u \"$user\" stop_bbpro" | at now + "${delay_minutes}" minutes 2>/dev/null # Update expiry time local new_expiry_timestamp=$((current_time + delay_seconds)) - $SUDO -u "$user" bash -c "mkdir -p \"${home_dir}/.config/dosyago/bbpro\"; echo \"$new_expiry_timestamp\" > \"$expiry_file\"" + $SUDO -u "$user" bash -c "mkdir -p \"${home_dir}/.config/dosaygo/bbpro\"; echo \"$new_expiry_timestamp\" > \"$expiry_file\"" printf "${GREEN}Scheduled stop for $user at $new_expiry_timestamp${NC}\n" else # Immediate stop @@ -3672,7 +3733,7 @@ run_as() { local HOME_DIR=$(get_home_dir "$user") # Ensure config directory exists with proper ownership - $SUDO -u "$user" mkdir -p "$HOME_DIR/.config/dosyago/bbpro" || { printf "${RED}Failed to create config dir for $user${NC}\n"; exit 1; } + $SUDO -u "$user" mkdir -p "$HOME_DIR/.config/dosaygo/bbpro" || { printf "${RED}Failed to create config dir for $user${NC}\n"; exit 1; } # Rsync .nvm from calling user to target user printf "${YELLOW}Copying nvm and Node.js from $HOME/.nvm to $HOME_DIR/.nvm...${NC}\n" @@ -3693,7 +3754,7 @@ run_as() { TOKEN=$(openssl rand -hex 16) # Run setup_bbpro with explicit PATH and fresh token, redirecting output as the target user - $SUDO -u "$user" bash -c "PATH=/usr/local/bin:\$PATH LICENSE_KEY="${LICENSE_KEY}" setup_bbpro --port $port --token $TOKEN > ~/.config/dosyago/bbpro/setup_output.txt 2>&1" || { printf "${RED}Setup failed for $user${NC}\n"; $SUDO cat "$HOME_DIR/.config/dosyago/bbpro/setup_output.txt"; exit 1; } + $SUDO -u "$user" bash -c "PATH=/usr/local/bin:\$PATH LICENSE_KEY="${LICENSE_KEY}" setup_bbpro --port $port --token $TOKEN > ~/.config/dosaygo/bbpro/setup_output.txt 2>&1" || { printf "${RED}Setup failed for $user${NC}\n"; $SUDO cat "$HOME_DIR/.config/dosaygo/bbpro/setup_output.txt"; exit 1; } # Use caller's LICENSE_KEY if [ -z "$LICENSE_KEY" ]; then @@ -3704,11 +3765,11 @@ run_as() { sleep 2 # Retrieve token - if $SUDO test -f "$HOME_DIR/.config/dosyago/bbpro/test.env"; then - TOKEN=$($SUDO -u "$user" bash -c "source ~/.config/dosyago/bbpro/test.env && echo \$LOGIN_TOKEN") || { printf "${RED}Failed to source test.env for $user${NC}\n"; exit 1; } + if $SUDO test -f "$HOME_DIR/.config/dosaygo/bbpro/test.env"; then + TOKEN=$($SUDO -u "$user" bash -c "source ~/.config/dosaygo/bbpro/test.env && echo \$LOGIN_TOKEN") || { printf "${RED}Failed to source test.env for $user${NC}\n"; exit 1; } fi - if [ -z "$TOKEN" ] && $SUDO test -f "$HOME_DIR/.config/dosyago/bbpro/login.link"; then - TOKEN=$($SUDO cat "$HOME_DIR/.config/dosyago/bbpro/login.link" | grep -oE 'token=[^&]+' | sed 's/token=//') + if [ -z "$TOKEN" ] && $SUDO test -f "$HOME_DIR/.config/dosaygo/bbpro/login.link"; then + TOKEN=$($SUDO cat "$HOME_DIR/.config/dosaygo/bbpro/login.link" | grep -oE 'token=[^&]+' | sed 's/token=//') fi [ -n "$TOKEN" ] || { printf "${RED}Failed to retrieve login token for $user${NC}\n"; exit 1; } @@ -3895,7 +3956,7 @@ usage() { printf " bbx [options]\n\n" printf "${BOLD}SETUP & MANAGEMENT${NC}\n" - printf " ${GREEN}install${NC} Install BrowserBox and this CLI.\n" + printf " ${GREEN}install${NC} Install BrowserBox using https://browserbox.io/install.sh\n" printf " ${GREEN}uninstall${NC} Remove all BrowserBox components.\n" printf " ${GREEN}setup${NC} Configure core options. ${BOLD}bbx setup [--port|-p

] [--hostname|-h ] [--token|-t ] [--zeta|-z]${NC}\n" printf " ${CYAN}activate${NC} Activate a license for more users. ${BOLD}bbx activate [number_of_users]${NC}\n" @@ -4053,7 +4114,11 @@ activate() { # Call check_and_prepare_update with the first argument [ -n "$BBX_NO_UPDATE" ] || check_and_prepare_update "$1" case "$1" in - install) shift 1; install_bbx "$@";; + install) + printf "${YELLOW}Installation now uses the standalone installer:${NC}\n" + printf " ${BOLD}curl -fsSL https://browserbox.io/install.sh | bash${NC}\n" + exit 0 + ;; uninstall) shift 1; uninstall "$@";; setup) shift 1; setup "$@";; certify) shift 1; certify "$@";; diff --git a/deploy-scripts/install.sh b/deploy-scripts/install.sh new file mode 100755 index 000000000..51d75adb8 --- /dev/null +++ b/deploy-scripts/install.sh @@ -0,0 +1,717 @@ +#!/usr/bin/env bash +set -euo pipefail + +debug_user="${USER:-$(id -un)}" +base_debug_dir="${TMPDIR:-/tmp}/bbx-install-debug-${debug_user}" +job_name="${GITHUB_JOB:-install}" +run_id="${GITHUB_RUN_ID:-local}" +run_attempt="${GITHUB_RUN_ATTEMPT:-1}" + +sanitize_debug_label() { + local label="${1:-unknown}" + label="${label//\//-}" + label="${label//:/-}" + label="${label// /-}" + label="$(printf '%s' "$label" | tr '[:upper:]' '[:lower:]')" + label="$(printf '%s' "$label" | tr -cd 'a-z0-9._-')" + if [[ -z "$label" ]]; then + label="unknown" + fi + printf '%s' "$label" +} + +detect_os_label() { + local os_label="${RUNNER_OS:-$(uname -s)}" + os_label="$(printf '%s' "$os_label" | tr '[:upper:]' '[:lower:]')" + if [[ "$os_label" == "darwin" || "$os_label" == "macos" ]]; then + printf '%s' "macos" + return + fi + if [[ "$os_label" == "linux" ]]; then + if [[ -r /etc/os-release ]]; then + local os_id os_ver + os_id="$(. /etc/os-release; echo "${ID:-linux}")" + os_ver="$(. /etc/os-release; echo "${VERSION_ID:-}")" + os_id="$(sanitize_debug_label "$os_id")" + os_ver="$(printf '%s' "$os_ver" | tr -cd '0-9.')" + if [[ -n "$os_ver" ]]; then + printf '%s' "${os_id}${os_ver}" + else + printf '%s' "${os_id}" + fi + return + fi + fi + printf '%s' "$(sanitize_debug_label "$os_label")" +} + +if [[ -n "${BBX_DEBUG_OS_LABEL:-}" ]]; then + runner_os="$(sanitize_debug_label "$BBX_DEBUG_OS_LABEL")" +else + runner_os="$(detect_os_label)" +fi + +os_debug_dir="${base_debug_dir}/${runner_os}" +debug_dir="${os_debug_dir}/${job_name}-${run_id}-${run_attempt}" +mkdir -p "$os_debug_dir" +chmod 1777 "$base_debug_dir" 2>/dev/null || true +chmod 1777 "$os_debug_dir" 2>/dev/null || true +rm -rf "$debug_dir" +mkdir -p "$debug_dir" +debug_log="${debug_dir}/install.log" +exec > >(tee -a "$debug_log") 2>&1 +set -x +echo "Installer debug logging enabled. Log: ${debug_log}" >&2 + +usage() { + cat <<'USAGE' +BrowserBox installer (non-Windows) + +Usage: + install.sh [--yes|-y] [--help] + +Environment overrides: + BBX_RELEASE_REPO GitHub repo for releases (default: BrowserBox/BrowserBox) + BBX_RELEASE_TAG Pin a specific release tag (e.g., v15.9.4) + GH_TOKEN/GITHUB_TOKEN GitHub token for private/internal repos + BBX_NO_UPDATE Skip release lookups (requires BBX_RELEASE_TAG) + BBX_FULL_INSTALL Force --full-install + BBX_HOSTNAME Hostname for --full-install + EMAIL Email for --full-install (LetsEncrypt) + BBX_INSTALL_USER Non-root install user when running as root +USAGE +} + +if [[ "${1:-}" == "--help" || "${1:-}" == "-h" ]]; then + usage + exit 0 +fi + +# Prefer GH_TOKEN if provided; fall back to GITHUB_TOKEN for convenience. +if [[ -z "${GH_TOKEN:-}" && -n "${GITHUB_TOKEN:-}" ]]; then + GH_TOKEN="$GITHUB_TOKEN" +fi + +script_source="${BASH_SOURCE[0]:-$0}" +script_source_base="$(basename "$script_source")" +case "$script_source_base" in + bash|sh|dash|ksh|zsh|ash) + script_source="" + ;; +esac +if [[ -z "$script_source" || "$script_source" == "-" || "$script_source" == "/dev/stdin" || "$script_source" == "/proc/self/fd/0" ]]; then + script_source="" +fi +if [[ -n "$script_source" && ! -f "$script_source" ]]; then + script_source="" +fi +if [[ -z "$script_source" ]]; then + install_script_url="${BBX_INSTALL_SCRIPT_URL:-https://browserbox.io/install.sh}" + temp_script="$(mktemp "${TMPDIR:-/tmp}/bbx-install-script.XXXX")" + curl -fsSL "$install_script_url" -o "$temp_script" + chmod 644 "$temp_script" + script_source="$temp_script" +fi +script_dir="$(cd "$(dirname "$script_source")" && pwd)" +script_path="${script_dir}/$(basename "$script_source")" + +maybe_load_gh_token_from_gh() { + if [[ -n "${GH_TOKEN:-}" ]]; then + return 0 + fi + if ! command -v gh >/dev/null 2>&1; then + return 0 + fi + + local repo_root + repo_root="$(git rev-parse --show-toplevel 2>/dev/null || true)" + if [[ -z "$repo_root" ]]; then + return 0 + fi + + if [[ "$script_path" != "$repo_root/deploy-scripts/install.sh" ]]; then + return 0 + fi + + local token + token="$(gh auth token 2>/dev/null || true)" + if [[ -n "$token" ]]; then + GH_TOKEN="$token" + if [[ -z "${GITHUB_TOKEN:-}" ]]; then + GITHUB_TOKEN="$token" + fi + fi +} + +maybe_load_gh_token_from_gh + +BBX_RELEASE_REPO="${BBX_RELEASE_REPO:-BrowserBox/BrowserBox}" + +INTEGRITY_PUBLIC_KEY_PEM='-----BEGIN PUBLIC KEY----- +MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAnqKI++Z5x+cHF1je6Ww9 +r3hNRuefjZzlJGPD56IQTbVIDXZT45uGNHelg+BjlZezdGH86y29zKgx2g3pt8cC +Yp8KMSgg69uo9EVFlDw8HQ1Sf7rciiU89neb48lkm5GfzXtAyIFWQj83AHDblQUq +UJoXuu7YQLskHiRa0YPOkPf5KUHS8Yv1OJwXldsmd/+NGCrZki1o6xEt55B5qo3J +89jUiVnSafUhZXuQiwYfRT5MVoBBFl6TK/kg3qTF4oVBvz0r4HO/C1uAEytaDEI4 +CFy2XO6i64DgSbkjzXCsomlHU0ywPbLxXPUst5AZwX62f/caGKGZs7IrZDBYNI2k +bBZ5fCAFhExwI0HUVIFC31YFpFRZB3UnVQdE0q8UuZyCstubPk7gdkEljnCXDnMB +bvgk5+5y8WgCrbu3mndlbb4K9NqxFq3tJppM8Gq8Rip94DghUBlRMXCBwaZ+EsBZ +ZwkpTdoWvsJcO+NwHscRvHNRcDRUrDwMrTpSs/cfCRMUo0ze0ZxpenCQuQpae7ei +Rs4+aW0rrwZBFo+o5GNWDOADAoD4JEPBNuSJyOw4mjdTgf8O9pIJfDF7HtX7pHr7 +e8u3jamSWvZSZA+50fI6iL05JUDA4cQ529voRTxiLALgLkSnlGY2EQrDr9A8lH4/ +hYdYq1pXWapoaFZTuPK4ln8CAwEAAQ== +-----END PUBLIC KEY-----' + +is_truthy() { + case "${1:-}" in + 1|true|TRUE|yes|YES|y|Y|on|ON) return 0 ;; + *) return 1 ;; + esac +} + +is_interactive() { + [[ -t 0 && -t 1 ]] +} + +ensure_user_exists() { + local user="$1" + if id "$user" >/dev/null 2>&1; then + return 0 + fi + + local os + os="$(uname -s)" + if [[ "$os" == "Darwin" ]]; then + echo "User '$user' does not exist. Please create it manually on macOS." >&2 + return 1 + fi + + if command -v useradd >/dev/null 2>&1; then + useradd -m -s /bin/bash "$user" + elif command -v adduser >/dev/null 2>&1; then + adduser --disabled-password --gecos "" "$user" + else + echo "Unable to create user '$user' (useradd/adduser not found)." >&2 + return 1 + fi + return 0 +} + +ensure_sudo_group() { + local user="$1" + if command -v getent >/dev/null 2>&1; then + if getent group sudo >/dev/null 2>&1; then + usermod -aG sudo "$user" 2>/dev/null || true + return 0 + fi + if getent group wheel >/dev/null 2>&1; then + usermod -aG wheel "$user" 2>/dev/null || true + return 0 + fi + fi + return 0 +} + +handoff_env_args() { + local keys=( + BBX_RELEASE_REPO + BBX_RELEASE_TAG + GH_TOKEN + GITHUB_TOKEN + BBX_NO_UPDATE + BBX_FULL_INSTALL + BBX_HOSTNAME + EMAIL + BBX_TEST_AGREEMENT + BBX_INSTALL_USER + ) + HANDOFF_ENV=() + for key in "${keys[@]}"; do + if [[ -n "${!key:-}" ]]; then + HANDOFF_ENV+=("${key}=${!key}") + fi + done +} + +if [[ "$(id -u)" -eq 0 && -z "${BBX_ROOT_HANDOFF_DONE:-}" ]]; then + install_user="${BBX_INSTALL_USER:-}" + if [[ -z "$install_user" ]]; then + if ! is_interactive; then + echo "Running as root requires BBX_INSTALL_USER to be set." >&2 + exit 1 + fi + read -r -p "Install as which non-root user? " install_user + fi + + if [[ -z "$install_user" ]]; then + echo "No install user provided. Aborting." >&2 + exit 1 + fi + + ensure_user_exists "$install_user" || exit 1 + ensure_sudo_group "$install_user" || true + + export BBX_ROOT_HANDOFF_DONE=1 + export BBX_INSTALL_USER="$install_user" + + if command -v sudo >/dev/null 2>&1; then + handoff_env_args + exec sudo -u "$install_user" -H env BBX_ROOT_HANDOFF_DONE=1 "${HANDOFF_ENV[@]}" bash "$script_source" "$@" + fi + + if command -v su >/dev/null 2>&1; then + arg_line="" + for arg in "$@"; do + arg_line+=" $(printf '%q' "$arg")" + done + handoff_env_args + env_line="$(printf '%q ' "${HANDOFF_ENV[@]}")" + exec su - "$install_user" -c "env BBX_ROOT_HANDOFF_DONE=1 ${env_line} bash $(printf '%q' "$script_source")${arg_line}" + fi + + echo "Unable to switch to user '$install_user' (sudo/su not available)." >&2 + exit 1 +fi + +YES_FLAG="" +if [[ "${1:-}" == "--yes" || "${1:-}" == "-y" ]]; then + YES_FLAG="--yes" + shift +fi + +require_cmd() { + if ! command -v "$1" >/dev/null 2>&1; then + echo "Missing required command: $1" >&2 + exit 1 + fi +} + +require_cmd curl +require_cmd openssl +require_cmd jq +require_cmd xxd + +hex_to_bin() { + local hex_file="$1" + local bin_file="$2" + if command -v xxd >/dev/null 2>&1; then + xxd -r -p "$hex_file" > "$bin_file" + return 0 + fi + if command -v python3 >/dev/null 2>&1; then + python3 - "$hex_file" "$bin_file" <<'PY' +import binascii,sys +with open(sys.argv[1], 'r', encoding='utf-8') as f: + data = f.read().strip() +with open(sys.argv[2], 'wb') as out: + out.write(binascii.unhexlify(data)) +PY + return 0 + fi + if command -v python >/dev/null 2>&1; then + python - "$hex_file" "$bin_file" <<'PY' +import binascii,sys +with open(sys.argv[1], 'r') as f: + data = f.read().strip() +with open(sys.argv[2], 'wb') as out: + out.write(binascii.unhexlify(data)) +PY + return 0 + fi + echo "Unable to decode signature hex (need xxd or python)." >&2 + return 1 +} + +verify_manifest_signature() { + local manifest_path="$1" + local sig_path="$2" + local work_dir="$3" + local key_path="$work_dir/integrity_root.pem" + local sig_bin="$work_dir/manifest.sig.bin" + local payload="$work_dir/manifest.payload" + + printf '%s\n' "$INTEGRITY_PUBLIC_KEY_PEM" > "$key_path" + + printf 'INTEGRITY/RELEASE_MANIFEST/v1\0' > "$payload" + cat "$manifest_path" >> "$payload" + hex_to_bin "$sig_path" "$sig_bin" + + if ! openssl dgst -sha256 -verify "$key_path" -signature "$sig_bin" "$payload" >/dev/null 2>&1; then + echo "Release manifest signature verification failed." >&2 + return 1 + fi +} + +get_latest_release_tag() { + if [[ -n "${BBX_NO_UPDATE:-}" ]]; then + if [[ -n "${BBX_RELEASE_TAG:-}" ]]; then + echo "$BBX_RELEASE_TAG" + return 0 + fi + echo "BBX_NO_UPDATE is set; provide BBX_RELEASE_TAG to continue." >&2 + exit 1 + fi + + local api_url="https://api.github.com/repos/${BBX_RELEASE_REPO}/releases/latest" + local auth=() + if [[ -n "${GH_TOKEN:-}" ]]; then + auth=(-H "Authorization: Bearer ${GH_TOKEN}") + fi + + local response + response=$(curl -sS --connect-timeout 10 "${auth[@]}" "$api_url" || true) + local tag + tag=$(printf '%s' "$response" | jq -r '.tag_name // empty' 2>/dev/null) + + if [[ -z "$tag" ]]; then + echo "Failed to fetch latest release tag from ${BBX_RELEASE_REPO}." >&2 + exit 1 + fi + echo "$tag" +} + +fetch_release_json() { + local tag="$1" + local auth_header=() + if [[ -n "${GH_TOKEN:-}" ]]; then + auth_header=(-H "Authorization: Bearer ${GH_TOKEN}") + fi + + local json + json=$(curl -sS --fail "${auth_header[@]}" "https://api.github.com/repos/${BBX_RELEASE_REPO}/releases/tags/${tag}" 2>/dev/null || true) + if [[ -z "$json" && -n "${GH_TOKEN:-}" ]]; then + json=$(curl -sS --fail "${auth_header[@]}" "https://api.github.com/repos/${BBX_RELEASE_REPO}/releases" 2>/dev/null || true) + fi + printf '%s' "$json" +} + +extract_asset_id() { + local asset_name="$1" + if command -v jq >/dev/null 2>&1; then + jq -r --arg name "$asset_name" '.assets[] | select(.name==$name) | .id' 2>/dev/null | head -n1 + return 0 + fi + + if command -v python3 >/dev/null 2>&1; then + python3 - "$asset_name" <<'PY' +import json,sys +name=sys.argv[1] +try: + data=json.load(sys.stdin) +except Exception: + print('') + sys.exit(0) +assets=data.get('assets',[]) +for asset in assets: + if asset.get('name')==name: + print(asset.get('id','')) + break +PY + return 0 + fi + + if command -v python >/dev/null 2>&1; then + python - "$asset_name" <<'PY' +import json,sys +name=sys.argv[1] +try: + data=json.load(sys.stdin) +except Exception: + print('') + sys.exit(0) +assets=data.get('assets',[]) +for asset in assets: + if asset.get('name')==name: + print(asset.get('id','')) + break +PY + return 0 + fi + + awk -v name="$asset_name" ' + BEGIN{RS="{";FS=","} + { + has=0;id="" + for(i=1;i<=NF;i++){ + if($i ~ "\\\"name\\\"" && $i ~ name){has=1} + if($i ~ "\\\"id\\\""){gsub(/[^0-9]/,"",$i); id=$i} + } + if(has && id!=""){print id; exit} + }' +} + +download_release_asset() { + local tag="$1" + local asset_name="$2" + local out_path="$3" + + if [[ -z "$out_path" ]]; then + echo "Download path is empty; cannot write release asset." >&2 + exit 1 + fi + if [[ -d "$out_path" ]]; then + echo "Download path '$out_path' is a directory; cannot write release asset." >&2 + exit 1 + fi + mkdir -p "$(dirname "$out_path")" + + if [[ -n "${GH_TOKEN:-}" || "$BBX_RELEASE_REPO" != "BrowserBox/BrowserBox" ]]; then + if [[ -z "${GH_TOKEN:-}" ]]; then + echo "GH_TOKEN/GITHUB_TOKEN is required to download from ${BBX_RELEASE_REPO}." >&2 + exit 1 + fi + + local release_json + release_json="$(fetch_release_json "$tag")" + if [[ -z "$release_json" ]]; then + echo "Failed to fetch release metadata for ${tag}." >&2 + exit 1 + fi + + local asset_id + asset_id=$(printf '%s' "$release_json" | extract_asset_id "$asset_name") + if [[ -z "$asset_id" || "$asset_id" == "null" ]]; then + echo "Asset ${asset_name} not found on release ${tag}." >&2 + exit 1 + fi + + curl -L --fail --progress-bar --connect-timeout 60 \ + -H "Authorization: Bearer ${GH_TOKEN}" \ + -H "Accept: application/octet-stream" \ + -o "$out_path" "https://api.github.com/repos/${BBX_RELEASE_REPO}/releases/assets/${asset_id}" + return 0 + fi + + local url="https://github.com/${BBX_RELEASE_REPO}/releases/download/${tag}/${asset_name}" + curl -L --fail --progress-bar --connect-timeout 60 -o "$out_path" "$url" +} + +hash_file() { + local path="$1" + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$path" | awk '{print $1}' + return 0 + fi + if command -v shasum >/dev/null 2>&1; then + shasum -a 256 "$path" | awk '{print $1}' + return 0 + fi + if command -v openssl >/dev/null 2>&1; then + openssl dgst -sha256 "$path" | awk '{print $2}' + return 0 + fi + echo "Unable to compute SHA-256 (sha256sum/shasum/openssl missing)." >&2 + return 1 +} + +manifest_get_value() { + local manifest_path="$1" + local expr="$2" + if command -v jq >/dev/null 2>&1; then + jq -r "$expr" "$manifest_path" 2>/dev/null + return 0 + fi + if command -v python3 >/dev/null 2>&1; then + python3 - "$manifest_path" "$expr" <<'PY' +import json,sys +path=sys.argv[1] +expr=sys.argv[2] +with open(path,'r',encoding='utf-8') as f: + data=json.load(f) +# Only supports the exact expressions used in this script. +if expr.startswith('.artifacts['): + key=expr.split('["',1)[1].split('"]',1)[0] + rest=expr.split('].',1)[1] + entry=data.get('artifacts',{}).get(key,{}) + print(entry.get(rest,'') or '') + sys.exit(0) +if expr == '.install.fullInstallRequired': + val=data.get('install',{}).get('fullInstallRequired', False) + print('true' if val else 'false') + sys.exit(0) +if expr == '.full_install_required': + val=data.get('full_install_required', False) + print('true' if val else 'false') + sys.exit(0) +print('') +PY + return 0 + fi + if command -v python >/dev/null 2>&1; then + python - "$manifest_path" "$expr" <<'PY' +import json,sys +path=sys.argv[1] +expr=sys.argv[2] +with open(path,'r') as f: + data=json.load(f) +if expr.startswith('.artifacts['): + key=expr.split('["',1)[1].split('"]',1)[0] + rest=expr.split('].',1)[1] + entry=data.get('artifacts',{}).get(key,{}) + print(entry.get(rest,'') or '') + sys.exit(0) +if expr == '.install.fullInstallRequired': + val=data.get('install',{}).get('fullInstallRequired', False) + print('true' if val else 'false') + sys.exit(0) +if expr == '.full_install_required': + val=data.get('full_install_required', False) + print('true' if val else 'false') + sys.exit(0) +print('') +PY + return 0 + fi + echo "" # fallback +} + +resolve_platform() { + case "$(uname -s)" in + Linux) echo "linux" ;; + Darwin) echo "darwin" ;; + *) + echo "Unsupported OS: $(uname -s)" >&2 + exit 1 + ;; + esac +} + +resolve_arch() { + local arch + arch="$(uname -m)" + case "$arch" in + x86_64|amd64) echo "x64" ;; + arm64|aarch64) echo "arm64" ;; + *) echo "$arch" ;; + esac +} + +platform="$(resolve_platform)" +arch="$(resolve_arch)" +artifact_key="${platform}-${arch}" + +if [[ "$artifact_key" == "darwin-x64" ]]; then + echo "macOS x64 is not supported by current releases." >&2 + exit 1 +fi + +if [[ "$artifact_key" != "linux-x64" && "$artifact_key" != "darwin-arm64" ]]; then + echo "Unsupported platform: ${artifact_key}" >&2 + exit 1 +fi + +release_tag="${BBX_RELEASE_TAG:-}" +if [[ -z "$release_tag" ]]; then + release_tag="$(get_latest_release_tag)" +fi + +work_dir="$(mktemp -d "${TMPDIR:-/tmp}/bbx-install.XXXX")" +trap 'rm -rf "$work_dir"' EXIT + +manifest_path="$work_dir/release.manifest.json" +manifest_sig_path="$work_dir/release.manifest.json.sig" + +echo "Downloading release manifest..." >&2 +download_release_asset "$release_tag" "release.manifest.json" "$manifest_path" +download_release_asset "$release_tag" "release.manifest.json.sig" "$manifest_sig_path" + +verify_manifest_signature "$manifest_path" "$manifest_sig_path" "$work_dir" + +asset_name="$(manifest_get_value "$manifest_path" ".artifacts[\"$artifact_key\"].fileName")" +asset_sha="$(manifest_get_value "$manifest_path" ".artifacts[\"$artifact_key\"].sha256")" + +if [[ -z "$asset_name" || "$asset_name" == "null" ]]; then + echo "Release manifest missing artifact for ${artifact_key}." >&2 + exit 1 +fi + +temp_binary="$work_dir/${asset_name}" + +echo "Downloading ${asset_name} (${release_tag})..." >&2 +download_release_asset "$release_tag" "$asset_name" "$temp_binary" +chmod +x "$temp_binary" + +if [[ -n "$asset_sha" ]]; then + actual_sha="$(hash_file "$temp_binary")" || actual_sha="" + if [[ -n "$actual_sha" && "$actual_sha" != "$asset_sha" ]]; then + echo "SHA-256 mismatch for ${asset_name}." >&2 + exit 1 + fi +else + echo "Release manifest missing sha256 for ${artifact_key}." >&2 + exit 1 +fi + +# Install manifest to a shared location for integrity checks. +manifest_target_dir="${HOME}/.config/dosaygo/bbpro" +if [[ -w "/usr/local/share" || "$(id -u)" -eq 0 ]]; then + manifest_target_dir="/usr/local/share/dosaygo/bbpro" +fi +mkdir -p "$manifest_target_dir" +cp "$manifest_path" "$manifest_target_dir/release.manifest.json" +cp "$manifest_sig_path" "$manifest_target_dir/release.manifest.json.sig" +chmod 644 "$manifest_target_dir/release.manifest.json" "$manifest_target_dir/release.manifest.json.sig" 2>/dev/null || true + +full_install=false +if ! command -v browserbox >/dev/null 2>&1; then + full_install=true +fi +if is_truthy "${BBX_FULL_INSTALL:-}"; then + full_install=true +fi + +manifest_full="$(manifest_get_value "$manifest_path" '.install.fullInstallRequired')" +legacy_full="$(manifest_get_value "$manifest_path" '.full_install_required')" +if [[ "$manifest_full" == "true" || "$legacy_full" == "true" ]]; then + full_install=true +fi + +config_dir="${HOME}/.config/dosaygo/bbpro" +if [[ -z "${BBX_HOSTNAME:-}" && -f "$config_dir/test.env" ]]; then + BBX_HOSTNAME="$(sed -n 's/^DOMAIN=//p' "$config_dir/test.env" | tail -n1)" +fi + +if [[ -z "${EMAIL:-}" && -f "$config_dir/.agreed" ]]; then + EMAIL="$(tail -n1 "$config_dir/.agreed" | tr -d '\r' | tr -d '\n')" +fi + +hostname_default="${BBX_HOSTNAME:-$(hostname)}" +email_value="${EMAIL:-}" + +is_local_hostname() { + case "$1" in + localhost|127.0.0.1|::1) return 0 ;; + *.local|*.test|*.example) return 0 ;; + *) return 1 ;; + esac +} + +if [[ "$full_install" == "true" ]]; then + if [[ -z "${BBX_HOSTNAME:-}" ]]; then + if is_interactive; then + read -r -p "Enter hostname (default: ${hostname_default}): " BBX_HOSTNAME + fi + fi + BBX_HOSTNAME="${BBX_HOSTNAME:-$hostname_default}" + + if [[ -z "$email_value" ]]; then + if is_interactive; then + local_notice="required" + if is_local_hostname "$BBX_HOSTNAME"; then + local_notice="optional" + fi + read -r -p "Enter your email for Let's Encrypt (${local_notice}): " email_value + fi + fi + + if ! is_local_hostname "$BBX_HOSTNAME" && [[ -z "$email_value" ]]; then + echo "Email is required for a public hostname." >&2 + exit 1 + fi + + echo "Running full install..." >&2 + "$temp_binary" --full-install "$BBX_HOSTNAME" "$email_value" ${YES_FLAG} +else + echo "Running update install..." >&2 + "$temp_binary" --install +fi diff --git a/docs/ci-saga-debugging.md b/docs/ci-saga-debugging.md new file mode 100644 index 000000000..076815100 --- /dev/null +++ b/docs/ci-saga-debugging.md @@ -0,0 +1,54 @@ +# CI Saga Debugging (Public Repo) + +This is the workflow for iterating on installer/saga failures in `BrowserBox/BrowserBox` until the matrix is green. + +## Prereqs + +- Local: `gh` (authenticated) + `jq`. +- Workflow: `.github/workflows/bbx-saga.yaml` supports `workflow_dispatch` inputs: + - `release_tag` (required) + - `matrix_json` (optional) + - `disable_tmate` (optional) + +## Fast iteration loop (one matrix slice at a time) + +1) Pick one failing environment to target: + - Linux native: `--os ubuntu-latest --containers native` + - Linux container: `--os ubuntu-latest --containers debian:latest` (or `dokken/centos-stream-10`) + - macOS: `--os macos-latest` + - Windows: `--os windows-latest` + +2) Trigger just that slice and watch it: + +```bash +./admin-tools/retrigger-public-saga.sh vX.Y.Z --os ubuntu-latest --containers debian:latest --no-tmate --watch +``` + +3) If it fails, pull logs immediately: + +```bash +gh run view --repo BrowserBox/BrowserBox --log-failed +``` + +The helper also saves failed logs to: `/tmp/bbx-public-saga//log-failed.txt`. + +4) Fix only what the failure indicates (usually one of): + - Workflow assumptions (wrong triggers/inputs, wrong installer URL, missing env vars). + - Installer regressions (`deploy-scripts/install.sh`, `windows-scripts/install.ps1`). + - Saga/test brittleness (`.github/scripts/test-bbx.sh`). + +5) Push, re-run the same slice, repeat until green, then move to the next slice. + +## Where installer logs show up (Unix/macOS) + +The installer writes debug logs under: + +- `/tmp/bbx-install-debug-//--/install.log` + +On saga failure, the workflow tails these logs automatically. + +## Tips for common failures + +- **“Could not determine release tag” (Windows)**: ensure the workflow passes `BBX_RELEASE_TAG` and that the installer is pinned to the release tag (don’t rely on “latest”). +- **Container-only flakes**: expect slower Tor/Cloudflare networking; prefer longer retry windows over failing fast. + diff --git a/docs/release-flow.md b/docs/release-flow.md index 947ec530d..5e42a7db3 100644 --- a/docs/release-flow.md +++ b/docs/release-flow.md @@ -51,6 +51,7 @@ Note: No published release is needed in the private repo; only the draft (`vX.Y. ## Workflow triggers & alignment (public repo) - `bbx-saga.yaml` (Public Release): `release: published` or manual dispatch (`release_tag`, optional `release_repo`). Runs against released binaries. Step 7. +- Public saga debugging loop: `docs/ci-saga-debugging.md` - `codeql-analysis.yml`: PR to main + weekly cron; not in release path. - `update-version-json.yaml` (public): `on: create` tag `v*-rc` (likely unused; create filters may not match patterns—monitor if you intend to keep it). - Removed: basic-install, debug, vpn, trigger-private-build, main-debug (reduces noise/minutes). The old dispatch that fed `docker-release-native-multi-arch` is gone, so that docker workflow is currently inactive. diff --git a/windows-scripts/bbx.ps1 b/windows-scripts/bbx.ps1 index 3f8ef0aa8..187924d0c 100755 --- a/windows-scripts/bbx.ps1 +++ b/windows-scripts/bbx.ps1 @@ -25,6 +25,11 @@ if ($null -ne $env:BBX_NO_UPDATE -and $env:BBX_NO_UPDATE -ne "") { $NoUpdate = ($env:BBX_NO_UPDATE.ToLowerInvariant() -in @("1", "true", "yes", "y", "on")) } } + +if ($null -eq $env:BBX_DONT_KILL_CHROME_ON_STOP) { + $env:BBX_DONT_KILL_CHROME_ON_STOP = "true" +} + $BinaryDir = "$env:LOCALAPPDATA\browserbox\bin" # Local Name (on disk) @@ -34,6 +39,7 @@ $RemoteAssetName = "browserbox-win-x64.exe" $BinaryPath = Join-Path $BinaryDir $BinaryName $script:ResolvedBinaryPath = $null +$script:RestartArgs = @() $ScriptMap = @{ "install" = "install.ps1" @@ -243,6 +249,14 @@ function Get-BinaryVersion { return "not_installed" } +function Test-InteractiveConsole { + if (-not [Environment]::UserInteractive) { return $false } + try { + if ([Console]::IsInputRedirected -or [Console]::IsOutputRedirected) { return $false } + } catch { } + return $true +} + # Reads KEY=VALUE lines from test.env function Read-TestEnv { param([string]$Path) @@ -291,7 +305,7 @@ function Get-ArgSwitch { } function Ensure-ConfigDir { - $cfgDir = Join-Path $env:USERPROFILE ".config\dosyago\bbpro" + $cfgDir = Join-Path $env:USERPROFILE ".config\dosaygo\bbpro" New-Item -ItemType Directory -Path $cfgDir -Force | Out-Null New-Item -ItemType Directory -Path (Join-Path $cfgDir "tickets") -Force | Out-Null New-Item -ItemType Directory -Path (Join-Path $cfgDir "logs") -Force | Out-Null @@ -400,16 +414,16 @@ function Stop-BrowserBoxMain { } catch { } } - $pid = $null + $ProcessId = $null if (Test-Path $pidFile) { $raw = (Get-Content $pidFile -ErrorAction SilentlyContinue | Out-String).Trim() $tmp = 0 - if ([int]::TryParse($raw, [ref]$tmp)) { $pid = $tmp } + if ([int]::TryParse($raw, [ref]$tmp)) { $ProcessId = $tmp } } - if ($pid -and (Get-Process -Id $pid -ErrorAction SilentlyContinue)) { - Stop-Process -Id $pid -Force -ErrorAction SilentlyContinue - Write-Host "Stopped BrowserBox main (PID: $pid)." -ForegroundColor Green + if ($ProcessId -and (Get-Process -Id $ProcessId -ErrorAction SilentlyContinue)) { + Stop-Process -Id $ProcessId -Force -ErrorAction SilentlyContinue + Write-Host "Stopped BrowserBox main (PID: $ProcessId)." -ForegroundColor Green } else { # Fallback: stop any browserbox.exe processes owned by this user Get-Process -Name "browserbox" -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue @@ -422,28 +436,113 @@ function Stop-BrowserBoxMain { # Function to check for updates function Check-Update { if ($NoUpdate) { return } - if (Test-BinaryExists) { - $currentVersion = Get-BinaryVersion - - if ($currentVersion -eq "unknown" -or $currentVersion -eq "not_installed") { + if (-not (Test-BinaryExists)) { return } + + $currentVersion = Get-BinaryVersion + if ($currentVersion -eq "unknown" -or $currentVersion -eq "not_installed") { + return + } + + try { + $latestTag = Get-LatestRelease -Repo $ReleaseRepo + $latestNorm = $latestTag -replace '^[vV]' + $currentNorm = $currentVersion -replace '^[vV]' + if (-not $latestNorm -or -not $currentNorm -or $latestNorm -eq $currentNorm) { return } - - try { - $latestTag = Get-LatestRelease -Repo $ReleaseRepo - $latestNorm = $latestTag -replace '^[vV]' - $currentNorm = $currentVersion -replace '^[vV]' - if ($latestNorm -and $currentNorm -and $latestNorm -ne $currentNorm) { - Write-Host "Note: A new version of BrowserBox is available: $latestTag" -ForegroundColor Yellow - Write-Host " Run 'bbx install' to update." + + $yesUpdate = $false + if ($null -ne $env:BBX_YES_UPDATE -and $env:BBX_YES_UPDATE -ne "") { + try { + $yesUpdate = [System.Convert]::ToBoolean($env:BBX_YES_UPDATE) + } catch { + $yesUpdate = ($env:BBX_YES_UPDATE.ToLowerInvariant() -in @("1", "true", "yes", "y", "on")) } } - catch { - # Silently ignore update check failures + + if (-not (Test-InteractiveConsole) -and -not $yesUpdate) { + return } + + $shouldInstall = $yesUpdate + if (-not $shouldInstall) { + $response = Read-Host "A new version of BrowserBox is available ($latestTag). Install now? [y/N]" + if ($response -match '^(y|yes)$') { + $shouldInstall = $true + } + } + + if ($shouldInstall) { + $updatedTag = Invoke-UpdateInstall + if ($updatedTag) { + Write-Host "BrowserBox updated to $updatedTag. Restarting..." -ForegroundColor Green + $env:BBX_UPDATE_ALREADY_APPLIED = "1" + & $PSCommandPath @script:RestartArgs + exit $LASTEXITCODE + } + } + } + catch { + # Silently ignore update check failures } } +function Invoke-UpdateInstall { + if ($NoUpdate -and -not $env:BBX_RELEASE_TAG) { + Write-Error "BBX_NO_UPDATE is set; provide BBX_RELEASE_TAG to update without release lookups." + return $null + } + + $tag = if ($env:BBX_RELEASE_TAG) { $env:BBX_RELEASE_TAG } else { Get-LatestRelease -Repo $ReleaseRepo } + if (-not $tag) { + Write-Error "Could not determine release tag." + return $null + } + + Download-Binary -Tag $tag + + $env:BBX_BINARY_SOURCE_PATH = $BinaryPath + $copyScript = Join-Path $PSScriptRoot "cp_commands_only.ps1" + if (Test-Path $copyScript) { + & $copyScript | Out-Null + } + + return $tag +} + +function Should-CheckUpdateNow { + $cfgDir = Ensure-ConfigDir + $checkFile = Join-Path $cfgDir "last_update_check" + $now = [DateTimeOffset]::UtcNow.ToUnixTimeSeconds() + if (Test-Path $checkFile) { + try { + $last = [int64](Get-Content $checkFile -ErrorAction Stop | Select-Object -First 1) + if ($last -gt ($now - 3600)) { + return $false + } + } catch { } + } + try { + Set-Content -Path $checkFile -Value $now -Encoding ascii -ErrorAction SilentlyContinue | Out-Null + } catch { } + return $true +} + +function Invoke-UpdateCheck { + param( + [string]$Command, + [string[]]$CommandArgs + ) + + if ($env:BBX_UPDATE_ALREADY_APPLIED) { return } + if ($NoUpdate) { return } + if ($Command -in @("update", "install", "uninstall")) { return } + if (-not (Should-CheckUpdateNow)) { return } + + $script:RestartArgs = @($Command) + $CommandArgs + Check-Update +} + # Function to show help function Show-Help { Write-Host "bbx CLI (Windows Binary Distribution)" -ForegroundColor Green @@ -453,8 +552,8 @@ function Show-Help { Write-Host " install Install BrowserBox binary and CLI" -ForegroundColor White Write-Host " update Update BrowserBox to the latest version" -ForegroundColor White Write-Host " setup Create/update test.env + login.link" -ForegroundColor White - Write-Host " run Start BrowserBox main (detached)" -ForegroundColor White - Write-Host " stop Stop BrowserBox main (best-effort)" -ForegroundColor White + Write-Host " run Start BrowserBox services (pm2-managed)" -ForegroundColor White + Write-Host " stop Stop BrowserBox services (best-effort)" -ForegroundColor White Write-Host " certify Validate license and obtain ticket" -ForegroundColor White Write-Host " uninstall Remove BrowserBox from this machine" -ForegroundColor White Write-Host " revalidate Clear ticket and revalidate license" -ForegroundColor White @@ -554,7 +653,7 @@ function Convert-ArgListToSplat { # Function to handle revalidate command function Invoke-Revalidate { - $ticketPath = Join-Path $env:USERPROFILE ".config\dosyago\bbpro\tickets\ticket.json" + $ticketPath = Join-Path $env:USERPROFILE ".config\dosaygo\bbpro\tickets\ticket.json" if (-not (Test-Path (Split-Path $ticketPath))) { Write-Warning "Ticket directory does not exist at $(Split-Path $ticketPath)" @@ -662,6 +761,9 @@ elseif ($CommandArgs -and ($CommandArgs -contains "-Help")) { Show-CommandHelp -Command $normalizedCommand exit 0 } + +Invoke-UpdateCheck -Command $normalizedCommand -CommandArgs $CommandArgs + elseif (Invoke-CommandScript -Command $normalizedCommand -Arguments $CommandArgs) { exit $LASTEXITCODE } diff --git a/windows-scripts/cp_commands_only.ps1 b/windows-scripts/cp_commands_only.ps1 new file mode 100644 index 000000000..1a358013c --- /dev/null +++ b/windows-scripts/cp_commands_only.ps1 @@ -0,0 +1,128 @@ +# +# cp_commands_only.ps1 +# +# Copies the BrowserBox command wrappers ('bbx.ps1', 'stop_bbpro.ps1') to a directory in the user's PATH. +# It prioritizes user-specific, non-system locations to avoid requiring administrator privileges. +# The script outputs the chosen directory path to stdout so the main binary can be copied there. +# + +# --- Functions --- + +# Function to find a writable, non-system directory in the PATH. +function Get-UserPathTargetDirectory { + $path_dirs = ($env:Path -split [System.IO.Path]::PathSeparator) | Where-Object { $_ -ne "" } | Select-Object -Unique + + # Priority order for target directories + $preferred_dirs = @( + [System.IO.Path]::Combine($env:USERPROFILE, "bin"), + [System.IO.Path]::Combine($env:USERPROFILE, "Scripts"), + [System.IO.Path]::Combine($env:LOCALAPPDATA, "Microsoft\WindowsApps") # Often writable + ) + + # Check preferred directories first if they are in PATH + foreach ($dir in $preferred_dirs) { + if ($path_dirs -contains $dir) { + if (Test-Path -Path $dir -PathType Container) { + try { + $testFile = [System.IO.Path]::Combine($dir, "tmp_write_test.tmp") + [System.IO.File]::WriteAllText($testFile, "test") + [System.IO.File]::Delete($testFile) + # If we got here, it's writable + return $dir + } catch { + # Not writable, continue + } + } + } + } + + # Fallback to the first writable user-level directory in PATH + foreach ($dir in $path_dirs) { + if ($dir.StartsWith($env:USERPROFILE) -or $dir.StartsWith($env:LOCALAPPDATA)) { + if (Test-Path -Path $dir -PathType Container) { + try { + $testFile = [System.IO.Path]::Combine($dir, "tmp_write_test.tmp") + [System.IO.File]::WriteAllText($testFile, "test") + [System.IO.File]::Delete($testFile) + return $dir + } catch { + # Not writable, continue + } + } + } + } + + return $null +} + +# --- Main Script --- + +# Determine the target directory for commands +$dest_dir = Get-UserPathTargetDirectory +if (-not $dest_dir) { + Write-Error "Could not find a suitable writable directory in your PATH. Please add one (e.g., '$env:USERPROFILE\bin') and try again." + exit 1 +} + +# The script's own directory, to find other scripts to copy. Assumes it's run from the extracted temp dir. +$script_dir = Split-Path -Parent $MyInvocation.MyCommand.Path + +# List of command files to copy from the same directory +$command_files = @( + "bbx.ps1", + "stop.ps1", + "start.ps1", + "setup.ps1", + "certify.ps1", + "uninstall.ps1" +) + +# Copy the files +foreach ($file in $command_files) { + $source_path = Join-Path -Path $script_dir -ChildPath $file + if (Test-Path $source_path) { + $dest_path = Join-Path -Path $dest_dir -ChildPath $file + Copy-Item -Path $source_path -Destination $dest_path -Force + Write-Host "Copied $file to $dest_dir" + } else { + # This script will be in the 'deploy-scripts' temp folder, but the ps1 wrappers are in 'windows-scripts'. + # Let's check there too. + $fallback_source_path = Join-Path -Path (Join-Path -Path $script_dir -ChildPath "../windows-scripts") -ChildPath $file + if (Test-Path $fallback_source_path) { + $dest_path = Join-Path -Path $dest_dir -ChildPath $file + Copy-Item -Path $fallback_source_path -Destination $dest_path -Force + Write-Host "Copied $file to $dest_dir from fallback path." + } else { + Write-Warning "Command script '$file' not found in '$script_dir' or fallback path. Skipping." + } + } +} + +# Legacy note: Chai assets were previously synced here from the source tree to +# support source-based installs. During the binary distribution migration the +# browserbox installer seeds %USERPROFILE%\.config\dosaygo\bbpro\chai, so these +# copy-only scripts intentionally skip that work now. + +# Copy the binary executable to the destination directory +$binary_source_path = $env:BBX_BINARY_SOURCE_PATH +if (-not $binary_source_path) { + # Fallback: assume binary is in the same directory as this script (when extracted from SEA) + $binary_source_path = Join-Path -Path $script_dir -ChildPath "..\browserbox.exe" +} + +if (Test-Path $binary_source_path) { + Write-Host "Copying browserbox binary from $binary_source_path..." + $binary_dest_path = Join-Path -Path $dest_dir -ChildPath "browserbox.exe" + # Remove existing binary first to avoid corruption + if (Test-Path $binary_dest_path) { + Write-Host "Removing existing browserbox binary..." + Remove-Item -Path $binary_dest_path -Force + } + Copy-Item -Path $binary_source_path -Destination $binary_dest_path -Force + Write-Host "Binary copied to $binary_dest_path" +} else { + Write-Warning "Binary not found at '$binary_source_path'. Skipping binary copy." +} + +# IMPORTANT: Output the destination directory to stdout for the calling process +Write-Output $dest_dir diff --git a/windows-scripts/install.ps1 b/windows-scripts/install.ps1 index b57caa277..94e4b335f 100755 --- a/windows-scripts/install.ps1 +++ b/windows-scripts/install.ps1 @@ -1,209 +1,346 @@ -# install.ps1 -# Located at C:\Program Files\browserbox\windows-scripts\install.ps1 [CmdletBinding()] -param () -if ($PSBoundParameters.ContainsKey('Help') -or $args -contains '-help') { +param( + [Parameter(Mandatory = $false, HelpMessage = "Show help.")] + [switch]$Help +) + +if ($Help -or $args -contains '-help') { Write-Host "bbx install" -ForegroundColor Green - Write-Host "Install BrowserBox and bbx CLI" -ForegroundColor Yellow + Write-Host "Install BrowserBox (binary distribution)" -ForegroundColor Yellow Write-Host "Usage: bbx install" -ForegroundColor Cyan - Write-Host "Options: None" -ForegroundColor Cyan + Write-Host "Options:" -ForegroundColor Cyan + Write-Host " -Help Show this help message" -ForegroundColor White + Write-Host "" + Write-Host "Environment overrides:" -ForegroundColor Cyan + Write-Host " BBX_RELEASE_REPO, BBX_RELEASE_TAG, GH_TOKEN/GITHUB_TOKEN, BBX_NO_UPDATE" -ForegroundColor White + $global:LASTEXITCODE = 0 return } -$ProgressPreference = 'SilentlyContinue' -$branch = 'main' -$ForceAll = $false -$Debug = $false -if (-not ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { - Write-Host "Not running as Administrator. Relaunching..." -ForegroundColor Yellow - $arguments = @( - "-NoProfile" - "-ExecutionPolicy", "Bypass" - "-Command", "irm bbx.dosaygo.com | iex" - ) - Start-Process powershell -Verb RunAs -ArgumentList $arguments - Start-Sleep -Seconds 2 - return + +$ErrorActionPreference = "Stop" + +$PublicRepo = "BrowserBox/BrowserBox" +$ReleaseRepo = if ($env:BBX_RELEASE_REPO) { $env:BBX_RELEASE_REPO } else { $PublicRepo } +$Token = if ($env:GH_TOKEN) { $env:GH_TOKEN } elseif ($env:GITHUB_TOKEN) { $env:GITHUB_TOKEN } else { "" } +$NoUpdate = $false +if ($null -ne $env:BBX_NO_UPDATE -and $env:BBX_NO_UPDATE -ne "") { + try { + $NoUpdate = [System.Convert]::ToBoolean($env:BBX_NO_UPDATE) + } catch { + $NoUpdate = ($env:BBX_NO_UPDATE.ToLowerInvariant() -in @("1", "true", "yes", "y", "on")) + } } -Write-Host "Running as Administrator." -ForegroundColor Green -Start-Sleep -Seconds 2 -$bbxUrl = "https://github.com/BrowserBox/BrowserBox/archive/refs/heads/$branch.zip" -$tempZip = "$env:TEMP\browserbox-$branch.zip" -$installDir = "C:\Program Files\browserbox" -$bbxDir = "$installDir\windows-scripts" -$tempExtractDir = "$env:TEMP\browserbox-extract-$branch" -$unzipPath = "C:\Program Files\Git\usr\bin\unzip.exe" -# winget -$wingetPath = (Get-Command winget -ErrorAction SilentlyContinue).Path -if (-not $wingetPath -or $ForceAll) { - Write-Host "Installing winget..." -ForegroundColor Cyan + +$BinaryDir = "$env:LOCALAPPDATA\browserbox\bin" +$BinaryName = "browserbox.exe" +$RemoteAssetName = "browserbox-win-x64.exe" +$BinaryPath = Join-Path $BinaryDir $BinaryName + +function Ensure-BinaryDir { + if (-not (Test-Path $BinaryDir)) { + New-Item -ItemType Directory -Path $BinaryDir -Force | Out-Null + } +} + +function Get-LatestRelease { + param([string]$Repo) + + if ($NoUpdate) { + if ($env:BBX_RELEASE_TAG) { return $env:BBX_RELEASE_TAG } + return $null + } + + $headers = @{} + if ($Token) { $headers["Authorization"] = "Bearer $Token" } + try { - if ($PSVersionTable.PSVersion.Major -ge 6) { - # Prefer current PS7+ shell for reliability - & ([ScriptBlock]::Create((irm asheroto.com/winget))) -Force + $apiUrl = "https://api.github.com/repos/$Repo/releases/latest" + $response = Invoke-RestMethod -Uri $apiUrl -TimeoutSec 10 -Headers $headers -ErrorAction Stop + return $response.tag_name + } catch { + try { + Write-Host "Latest release lookup failed (check for drafts), checking release list..." -ForegroundColor Gray + $apiUrl = "https://api.github.com/repos/$Repo/releases?per_page=1" + $response = Invoke-RestMethod -Uri $apiUrl -TimeoutSec 10 -Headers $headers -ErrorAction Stop + if ($response -and $response.Count -gt 0) { + return $response[0].tag_name + } + } catch { + Write-Error "Failed to fetch latest release from $Repo : $_" + exit 1 + } + } + return $null +} + +function Download-Binary { + param( + [string]$Tag + ) + + Ensure-BinaryDir + + $tempFile = "$BinaryPath.tmp" + Write-Host "Downloading BrowserBox $Tag for Windows..." -ForegroundColor Cyan + + $headers = @{} + if ($Token) { $headers["Authorization"] = "Bearer $Token" } + + $useAssetApi = $Token -or ($ReleaseRepo -ne $PublicRepo) + if ($ReleaseRepo -ne $PublicRepo -and -not $Token) { + Write-Error "GH_TOKEN/GITHUB_TOKEN is required to download from private/internal repo $ReleaseRepo." + exit 1 + } + + try { + if ($useAssetApi) { + if ($Tag) { + $release = Invoke-RestMethod -Uri "https://api.github.com/repos/$ReleaseRepo/releases/tags/$Tag" -Headers $headers -ErrorAction Stop + } else { + $releases = Invoke-RestMethod -Uri "https://api.github.com/repos/$ReleaseRepo/releases" -Headers $headers -ErrorAction Stop + if (-not $releases -or $releases.Count -eq 0) { + Write-Error "No releases found in $ReleaseRepo" + exit 1 + } + $release = $releases[0] + $Tag = $release.tag_name + } + + $asset = $release.assets | Where-Object { $_.name -eq $RemoteAssetName } | Select-Object -First 1 + if (-not $asset) { + Write-Error "Release $Tag not found in $ReleaseRepo" + exit 1 + } + $assetUrl = "https://api.github.com/repos/$ReleaseRepo/releases/assets/$($asset.id)" + Invoke-WebRequest -Uri $assetUrl -Headers @{ Authorization = "Bearer $Token"; Accept = "application/octet-stream" } -OutFile $tempFile -ErrorAction Stop } else { - # PS5: Set TLS explicitly in subprocess - Start-Process powershell -ArgumentList "-NoProfile -ExecutionPolicy Bypass -Command `"[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12; & { IEX ((New-Object Net.WebClient).DownloadString('https://asheroto.com/winget')) } -Force`"" -Wait -NoNewWindow + $downloadUrl = if ($Tag) { + "https://github.com/$ReleaseRepo/releases/download/$Tag/$RemoteAssetName" + } else { + "https://github.com/$ReleaseRepo/releases/latest/download/$RemoteAssetName" + } + Invoke-WebRequest -Uri $downloadUrl -OutFile $tempFile -ErrorAction Stop } + + if (Test-Path $BinaryPath) { + Remove-Item $BinaryPath -Force + } + Move-Item $tempFile $BinaryPath -Force } catch { - Write-Host "Primary winget installation method failed. Trying fallback..." -ForegroundColor Yellow - [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 - & ([ScriptBlock]::Create((irm asheroto.com/winget))) -Force + Write-Error "Failed to download binary: $_" + exit 1 } - $env:Path = [Environment]::GetEnvironmentVariable("Path", "Machine") + ";" + [Environment]::GetEnvironmentVariable("Path", "User") - $wingetPath = (Get-Command winget -ErrorAction SilentlyContinue).Path - if (-not $wingetPath) { - Write-Warning "winget installation failed or not found in PATH -- continuing anyway." - } else { - Write-Host "winget installed successfully." -ForegroundColor Green +} + +$IntegrityPublicKeyPem = @' +-----BEGIN PUBLIC KEY----- +MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAnqKI++Z5x+cHF1je6Ww9 +r3hNRuefjZzlJGPD56IQTbVIDXZT45uGNHelg+BjlZezdGH86y29zKgx2g3pt8cC +Yp8KMSgg69uo9EVFlDw8HQ1Sf7rciiU89neb48lkm5GfzXtAyIFWQj83AHDblQUq +UJoXuu7YQLskHiRa0YPOkPf5KUHS8Yv1OJwXldsmd/+NGCrZki1o6xEt55B5qo3J +89jUiVnSafUhZXuQiwYfRT5MVoBBFl6TK/kg3qTF4oVBvz0r4HO/C1uAEytaDEI4 +CFy2XO6i64DgSbkjzXCsomlHU0ywPbLxXPUst5AZwX62f/caGKGZs7IrZDBYNI2k +bBZ5fCAFhExwI0HUVIFC31YFpFRZB3UnVQdE0q8UuZyCstubPk7gdkEljnCXDnMB +bvgk5+5y8WgCrbu3mndlbb4K9NqxFq3tJppM8Gq8Rip94DghUBlRMXCBwaZ+EsBZ +ZwkpTdoWvsJcO+NwHscRvHNRcDRUrDwMrTpSs/cfCRMUo0ze0ZxpenCQuQpae7ei +Rs4+aW0rrwZBFo+o5GNWDOADAoD4JEPBNuSJyOw4mjdTgf8O9pIJfDF7HtX7pHr7 +e8u3jamSWvZSZA+50fI6iL05JUDA4cQ529voRTxiLALgLkSnlGY2EQrDr9A8lH4/ +hYdYq1pXWapoaFZTuPK4ln8CAwEAAQ== +-----END PUBLIC KEY----- +'@ + +function Convert-HexToBytes { + param([string]$Hex) + $Hex = $Hex.Trim() + if ($Hex.Length % 2 -ne 0) { + throw "Invalid hex string length." } -} else { - Write-Host "winget already installed -- skipping." -ForegroundColor Cyan -} -# Check for unzip (from Git for Windows) and install Git.Git if not found -if (-not (Test-Path $unzipPath) -or $ForceAll) { - Write-Host "unzip not found at $unzipPath. Installing Git for Windows to get it..." -ForegroundColor Cyan - winget install --id Git.Git --accept-source-agreements --accept-package-agreements --force --silent - $env:Path = [Environment]::GetEnvironmentVariable("Path", "Machine") + ";" + [Environment]::GetEnvironmentVariable("Path", "User") - if (Test-Path $unzipPath) { - Write-Host "unzip installed successfully via Git.Git." -ForegroundColor Green - } else { - Write-Error "Failed to find unzip at $unzipPath after installing Git.Git! Check Git installation." - return + $bytes = New-Object byte[] ($Hex.Length / 2) + for ($i = 0; $i -lt $Hex.Length; $i += 2) { + $bytes[$i / 2] = [Convert]::ToByte($Hex.Substring($i, 2), 16) } -} else { - Write-Host "unzip already installed at $unzipPath -- skipping." -ForegroundColor Cyan -} -# Node.js -$nodePath = (Get-Command node -ErrorAction SilentlyContinue).Path -if ($nodePath) { - Write-Host "Node.js already installed at $nodePath -- installing LTS anyway..." -ForegroundColor Cyan -} -Write-Host "Installing Node.js LTS..." -ForegroundColor Cyan -winget install --id OpenJS.NodeJS.LTS --accept-source-agreements --accept-package-agreements --silent -$env:Path = "$env:Path;$env:ProgramFiles\nodejs" -# Verify Node.js and npm -$nodeVersion = & node --version 2>$null -$npmVersion = & npm --version 2>$null -if ($nodeVersion -and $npmVersion) { - Write-Host "Node.js $nodeVersion and npm $npmVersion installed successfully." -ForegroundColor Green -} else { - Write-Warning "Node.js or npm not found -- continuing anyway." -} -# mkcert -$mkcertPath = (Get-Command mkcert -ErrorAction SilentlyContinue).Path -if (-not $mkcertPath -or $ForceAll) { - Write-Host "Installing mkcert..." -ForegroundColor Cyan - winget install --id FiloSottile.mkcert --accept-source-agreements --accept-package-agreements --Location "$env:ProgramFiles\mkcert" --silent - $env:Path = "$env:Path;$env:ProgramFiles\mkcert" -} else { - Write-Host "mkcert already installed at $mkcertPath -- skipping." -ForegroundColor Cyan -} -# Certbot -$certbotPath = (Get-Command certbot -ErrorAction SilentlyContinue).Path -if (-not $certbotPath -or $ForceAll) { - Write-Host "Installing Certbot..." -ForegroundColor Cyan - winget install --id EFF.Certbot --accept-source-agreements --accept-package-agreements --silent - $env:Path = "$env:Path;$env:ProgramFiles\Certbot\bin" -} else { - Write-Host "Certbot already installed at $certbotPath -- skipping." -ForegroundColor Cyan -} -# Google Chrome -$chromePath = Get-ItemProperty -Path "HKLM:\Software\Microsoft\Windows\CurrentVersion\App Paths\chrome.exe" -ErrorAction SilentlyContinue -if (-not $chromePath -or $ForceAll) { - Write-Host "Installing Google Chrome..." -ForegroundColor Cyan - winget install --id Google.Chrome.EXE --accept-source-agreements --accept-package-agreements --force --silent -} else { - Write-Host "Google Chrome already installed -- skipping." -ForegroundColor Cyan -} -# BrowserBox -Write-Host "Downloading BrowserBox from branch '$branch'..." -ForegroundColor Cyan -(New-Object Net.WebClient).DownloadFile($bbxUrl, $tempZip) -if ($Debug) { Read-Host "Downloaded ZIP to $tempZip. Press Enter to continue..." } -Write-Host "Cleaning existing install at $installDir..." -ForegroundColor Cyan -if (Test-Path $installDir) { - Remove-Item $installDir -Recurse -Force -} -if ($Debug) { Read-Host "Cleaned $installDir (if it existed). Press Enter to continue..." } -Write-Host "Extracting to temporary directory $tempExtractDir with unzip..." -ForegroundColor Cyan -if (Test-Path $tempExtractDir) { - Remove-Item $tempExtractDir -Recurse -Force -} -New-Item -Path $tempExtractDir -ItemType Directory -Force | Out-Null -& $unzipPath -q $tempZip -d $tempExtractDir -if ($LASTEXITCODE -eq 0) { - Write-Host "Extraction completed successfully." -ForegroundColor Green -} else { - Write-Error "Extraction failed with exit code $LASTEXITCODE!" - throw "INSTALL Error" -} -if ($Debug) { Read-Host "Extracted ZIP to $tempExtractDir. Press Enter to continue..." } -Write-Host "Mirroring to $installDir using robocopy..." -ForegroundColor Cyan -$extractedRoot = "$tempExtractDir\BrowserBox-$branch" -if (Test-Path $extractedRoot) { - # Ensure installDir exists - if (-not (Test-Path $installDir)) { - New-Item -Path $installDir -ItemType Directory -Force | Out-Null + return $bytes +} + +function Read-AsnLength { + param([System.IO.BinaryReader]$Reader) + $length = $Reader.ReadByte() + if ($length -lt 0x80) { return $length } + $byteCount = $length -band 0x7F + $length = 0 + for ($i = 0; $i -lt $byteCount; $i++) { + $length = ($length -shl 8) -bor $Reader.ReadByte() } - # Use robocopy to mirror the directory structure - robocopy $extractedRoot $installDir /MIR /R:5 /W:5 /MT:8 /SL - if ($LASTEXITCODE -le 7) { # robocopy exit codes 0-7 indicate success - Write-Host "Successfully mirrored files to $installDir" -ForegroundColor Green - Remove-Item $tempExtractDir -Recurse -Force + return $length +} + +function Read-AsnSequence { + param([System.IO.BinaryReader]$Reader) + $tag = $Reader.ReadByte() + if ($tag -ne 0x30) { throw "Invalid ASN.1 sequence tag: $tag" } + return Read-AsnLength -Reader $Reader +} + +function Read-AsnIntegerBytes { + param([System.IO.BinaryReader]$Reader) + $tag = $Reader.ReadByte() + if ($tag -ne 0x02) { throw "Invalid ASN.1 integer tag: $tag" } + $length = Read-AsnLength -Reader $Reader + $bytes = $Reader.ReadBytes($length) + if ($bytes.Length -gt 0 -and $bytes[0] -eq 0x00) { + return $bytes[1..($bytes.Length - 1)] + } + return $bytes +} + +function Get-RsaPublicKeyFromPem { + param([string]$Pem) + $base64 = $Pem -replace '-----BEGIN PUBLIC KEY-----','' -replace '-----END PUBLIC KEY-----','' -replace '\s','' + $der = [Convert]::FromBase64String($base64) + $ms = New-Object System.IO.MemoryStream(,$der) + $reader = New-Object System.IO.BinaryReader($ms) + $null = Read-AsnSequence -Reader $reader + $algLen = Read-AsnSequence -Reader $reader + $null = $reader.ReadBytes($algLen) + $bitTag = $reader.ReadByte() + if ($bitTag -ne 0x03) { throw "Invalid ASN.1 bit string tag: $bitTag" } + $bitLen = Read-AsnLength -Reader $reader + $null = $reader.ReadByte() + $bitBytes = $reader.ReadBytes($bitLen - 1) + $inner = New-Object System.IO.BinaryReader((New-Object System.IO.MemoryStream(,$bitBytes))) + $null = Read-AsnSequence -Reader $inner + $modulus = Read-AsnIntegerBytes -Reader $inner + $exponent = Read-AsnIntegerBytes -Reader $inner + $params = New-Object System.Security.Cryptography.RSAParameters + $params.Modulus = $modulus + $params.Exponent = $exponent + $rsa = New-Object System.Security.Cryptography.RSACryptoServiceProvider + $rsa.ImportParameters($params) + return $rsa +} + +function Verify-ManifestSignature { + param( + [string]$ManifestPath, + [string]$SignaturePath + ) + + if (-not (Test-Path $ManifestPath)) { throw "Manifest not found at $ManifestPath" } + if (-not (Test-Path $SignaturePath)) { throw "Signature not found at $SignaturePath" } + + $manifestBytes = [System.IO.File]::ReadAllBytes($ManifestPath) + $domain = [System.Text.Encoding]::UTF8.GetBytes("INTEGRITY/RELEASE_MANIFEST/v1`0") + $payload = New-Object byte[] ($domain.Length + $manifestBytes.Length) + [System.Array]::Copy($domain, 0, $payload, 0, $domain.Length) + [System.Array]::Copy($manifestBytes, 0, $payload, $domain.Length, $manifestBytes.Length) + + $sigHex = (Get-Content $SignaturePath -Raw).Trim() + if (-not $sigHex) { throw "Signature file is empty." } + $sigBytes = Convert-HexToBytes -Hex $sigHex + + $rsa = [System.Security.Cryptography.RSA]::Create() + if ($rsa -and ($rsa | Get-Member -Name ImportFromPem -MemberType Method)) { + $rsa.ImportFromPem($IntegrityPublicKeyPem) } else { - Write-Error "robocopy failed with exit code $LASTEXITCODE!" + $rsa = Get-RsaPublicKeyFromPem -Pem $IntegrityPublicKeyPem } -} else { - Write-Warning "Expected $extractedRoot not found after extraction!" -} -Remove-Item "$tempZip" -if ($Debug) { Read-Host "Mirrored contents to $installDir and cleaned up temp files. Press Enter to continue..." } -# Prepare step -$prepareScript = "$bbxDir\prepare.ps1" -if (Test-Path $prepareScript) { - & powershell -NoProfile -ExecutionPolicy Bypass -File "$prepareScript" - if ($LASTEXITCODE -ne 0) { - Write-Warning "Preparation step failed -- continuing anyway." + $ok = $rsa.VerifyData($payload, $sigBytes, [System.Security.Cryptography.HashAlgorithmName]::SHA256, [System.Security.Cryptography.RSASignaturePadding]::Pkcs1) + if (-not $ok) { throw "Release manifest signature verification failed." } +} + +function Get-FileSha256 { + param([string]$Path) + if (-not (Test-Path $Path)) { throw "File not found: $Path" } + return (Get-FileHash -Path $Path -Algorithm SHA256).Hash.ToLowerInvariant() +} + +$tag = if ($env:BBX_RELEASE_TAG) { $env:BBX_RELEASE_TAG } else { Get-LatestRelease -Repo $ReleaseRepo } +if (-not $tag) { + Write-Error "Could not determine release tag." + exit 1 +} + +Download-Binary -Tag $tag + +# Try global location first (C:\ProgramData), fallback to user dir +$globalDir = Join-Path $env:PROGRAMDATA "dosaygo\bbpro" +$userConfigDir = "$env:USERPROFILE\.config\dosaygo\bbpro" + +# Check if we can write to ProgramData (admin privileges) +$useGlobal = $false +try { + if (-not (Test-Path $globalDir)) { + New-Item -ItemType Directory -Path $globalDir -Force -ErrorAction Stop | Out-Null + } + $useGlobal = $true +} catch { + # No admin access, use user config dir + if (-not (Test-Path $userConfigDir)) { + New-Item -ItemType Directory -Path $userConfigDir -Force | Out-Null } -} else { - Write-Warning "prepare.ps1 not found at $prepareScript -- skipping preparation." -} -# Debug: Show extracted contents -Write-Host "Checking install directory contents..." -ForegroundColor Cyan -Get-ChildItem $installDir -Recurse | ForEach-Object { if ($Debug) { Write-Host "Found: $($_.FullName)" } } -if ($Debug) { Read-Host "Listed contents of $installDir. Press Enter to continue..." } -# PATH (add bbx.ps1 directory to both Machine and User scopes) -$bbxDir = "$installDir\windows-scripts" -# Machine PATH -$currentMachinePath = [Environment]::GetEnvironmentVariable("Path", "Machine") -if ($currentMachinePath -notlike "*$bbxDir*") { - Write-Host "Adding '$bbxDir' to Machine PATH permanently..." -ForegroundColor Cyan - $newMachinePath = "$currentMachinePath;$bbxDir" - [Environment]::SetEnvironmentVariable("Path", $newMachinePath, "Machine") -} -# User PATH -$currentUserPath = [Environment]::GetEnvironmentVariable("Path", "User") -if ($currentUserPath -notlike "*$bbxDir*") { - Write-Host "Adding '$bbxDir' to User PATH permanently..." -ForegroundColor Cyan - $newUserPath = "$currentUserPath;$bbxDir" - [Environment]::SetEnvironmentVariable("Path", $newUserPath, "User") -} -# Update current session PATH -$env:Path = [Environment]::GetEnvironmentVariable("Path", "Machine") + ";" + [Environment]::GetEnvironmentVariable("Path", "User") -if ($Debug) { Read-Host "Updated PATH with $bbxDir (Machine and User). Press Enter to continue..." } -# Verify -Write-Host "BrowserBox installed! Running 'bbx -help'..." -ForegroundColor Green -$bbxPath = "$bbxDir\bbx.ps1" -if (Test-Path $bbxPath) { - & powershell -NoProfile -ExecutionPolicy Bypass -File "$bbxPath" -help -} else { - Write-Warning "bbx.ps1 not found at $bbxPath! Searching for it..." - $foundBbx = Get-ChildItem -Path $installDir -Filter "bbx.ps1" -Recurse -ErrorAction SilentlyContinue | Select-Object -First 1 - if ($foundBbx) { - Write-Host "Found bbx.ps1 at $($foundBbx.FullName), running it..." -ForegroundColor Cyan - & powershell -NoProfile -ExecutionPolicy Bypass -File "$($foundBbx.FullName)" -help +} + +$targetDir = if ($useGlobal) { $globalDir } else { $userConfigDir } +$locationDesc = if ($useGlobal) { "global location" } else { "user config" } + +$tempBase = $env:TEMP +if (-not $tempBase) { $tempBase = "C:\Windows\Temp" } +$tempDir = Join-Path $tempBase "bbx-installer" +New-Item -ItemType Directory -Path $tempDir -Force | Out-Null +$manifestPath = Join-Path $tempDir "release.manifest.json" +$sigPath = Join-Path $tempDir "release.manifest.json.sig" + +Write-Host "Downloading release manifest to $locationDesc..." -ForegroundColor Yellow +try { + $publicRepo = if ($PublicRepo) { $PublicRepo } else { "BrowserBox/BrowserBox" } + if ($Token -or $ReleaseRepo -ne $publicRepo) { + if (-not $Token) { throw "GH_TOKEN/GITHUB_TOKEN is required to download manifests from $ReleaseRepo." } + $headers = @{ Authorization = "Bearer $Token" } + $release = Invoke-RestMethod -Uri "https://api.github.com/repos/$ReleaseRepo/releases/tags/$tag" -Headers $headers -ErrorAction Stop + $manifestAsset = $release.assets | Where-Object { $_.name -eq "release.manifest.json" } | Select-Object -First 1 + $sigAsset = $release.assets | Where-Object { $_.name -eq "release.manifest.json.sig" } | Select-Object -First 1 + if (-not $manifestAsset -or -not $sigAsset) { throw "Manifest assets not found on release $tag." } + Invoke-WebRequest -Uri "https://api.github.com/repos/$ReleaseRepo/releases/assets/$($manifestAsset.id)" -Headers @{ Authorization = "Bearer $Token"; Accept = "application/octet-stream" } -OutFile $manifestPath -ErrorAction Stop + Invoke-WebRequest -Uri "https://api.github.com/repos/$ReleaseRepo/releases/assets/$($sigAsset.id)" -Headers @{ Authorization = "Bearer $Token"; Accept = "application/octet-stream" } -OutFile $sigPath -ErrorAction Stop } else { - Write-Error "bbx.ps1 not found anywhere in $installDir! Check ZIP structure for branch '$branch'." - throw "INSTALL Error" + $manifestUrl = "https://github.com/$ReleaseRepo/releases/download/$tag/release.manifest.json" + $sigUrl = "https://github.com/$ReleaseRepo/releases/download/$tag/release.manifest.json.sig" + Invoke-WebRequest -Uri $manifestUrl -OutFile $manifestPath -ErrorAction Stop + Invoke-WebRequest -Uri $sigUrl -OutFile $sigPath -ErrorAction Stop + } + Verify-ManifestSignature -ManifestPath $manifestPath -SignaturePath $sigPath + + $manifestJson = Get-Content $manifestPath -Raw | ConvertFrom-Json + $artifactKey = "win32-x64" + $entry = $manifestJson.artifacts.$artifactKey + if (-not $entry) { throw "Manifest missing entry for $artifactKey." } + if (-not $entry.sha256) { throw "Manifest missing sha256 for $artifactKey." } + + $actualSha = Get-FileSha256 -Path $BinaryPath + if ($actualSha -ne $entry.sha256.ToLowerInvariant()) { + throw "SHA-256 mismatch for downloaded binary." } + + Copy-Item $manifestPath (Join-Path $targetDir "release.manifest.json") -Force + Copy-Item $sigPath (Join-Path $targetDir "release.manifest.json.sig") -Force +} catch { + Write-Error "Failed to download or verify release manifest: $_" + exit 1 +} + +# Ensure cp_commands_only.ps1 copies the binary we just downloaded. +$env:BBX_BINARY_SOURCE_PATH = $BinaryPath + +$installArgs = @("--install") +if ($env:BBX_FULL_INSTALL -and $env:BBX_FULL_INSTALL -ne "" -and $env:BBX_FULL_INSTALL -ne "0" -and $env:BBX_FULL_INSTALL.ToLowerInvariant() -ne "false") { + $installArgs = @("--full-install") +} + +Write-Host "Running BrowserBox installer: $BinaryPath $($installArgs -join ' ')" -ForegroundColor Yellow +& $BinaryPath @installArgs +$installExit = $LASTEXITCODE +if ($installExit -ne 0) { + Write-Error "BrowserBox installer exited with code $installExit" + exit $installExit } -if ($Debug) { Read-Host "Ran 'bbx -help' (or tried to). Press Enter to finish..." } +exit 0 diff --git a/windows-scripts/install_deps.ps1 b/windows-scripts/install_deps.ps1 new file mode 100644 index 000000000..176dc9078 --- /dev/null +++ b/windows-scripts/install_deps.ps1 @@ -0,0 +1,211 @@ +# install_deps.ps1 +# Install external dependencies for BrowserBox binary distribution +# This script only installs system dependencies and does NOT download BrowserBox source code +[CmdletBinding()] +param () + +$ProgressPreference = 'SilentlyContinue' +$ForceAll = $false +$ForceChrome = $false +if ($null -ne $env:BBX_FORCE_CHROME_INSTALL -and $env:BBX_FORCE_CHROME_INSTALL -ne "") { + try { + $ForceChrome = [System.Convert]::ToBoolean($env:BBX_FORCE_CHROME_INSTALL) + } catch { + $ForceChrome = ($env:BBX_FORCE_CHROME_INSTALL.ToLowerInvariant() -in @("1", "true", "yes", "y", "on")) + } +} +# Allow callers to enable NodeJS install if explicitly enabled; default is skip +$InstallNode = $false +if ($env:BBX_INSTALL_NODEJS -eq "true") { + $InstallNode = $true +} + +function Is-Admin { + try { + return ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) + } catch { + return $false + } +} + +$IsAdmin = Is-Admin + +function Try-ElevateAndRerun { + param( + [int]$TimeoutSeconds = 180 + ) + + if ($env:BBX_INSTALL_DEPS_ELEVATED_ATTEMPT -eq "1") { + return $false + } + + # Avoid hanging in clearly non-interactive CI contexts. + if ($env:BBX_SKIP_UAC -and $env:BBX_SKIP_UAC -ne "" -and $env:BBX_SKIP_UAC -ne "0" -and $env:BBX_SKIP_UAC -ne "false") { + return $false + } + + Write-Host "Attempting to elevate to Administrator for system-wide installs..." -ForegroundColor Yellow + $exe = "powershell" + try { + if (Get-Command pwsh -ErrorAction SilentlyContinue) { $exe = "pwsh" } + } catch { } + + $scriptPath = $MyInvocation.MyCommand.Path + $escapedScriptPath = $scriptPath.Replace("'", "''") + $childCmd = "`$env:BBX_INSTALL_DEPS_ELEVATED_ATTEMPT='1'; & '$escapedScriptPath'" + $args = @( + "-NoProfile", + "-ExecutionPolicy", "Bypass", + "-Command", $childCmd + ) + + try { + $p = Start-Process -FilePath $exe -Verb RunAs -ArgumentList $args -PassThru + } catch { + Write-Warning "Elevation attempt failed to start: $_" + return $false + } + + try { + $p | Wait-Process -Timeout $TimeoutSeconds -ErrorAction Stop + } catch { + Write-Warning "Elevation attempt did not complete within ${TimeoutSeconds}s; falling back to per-user installs." + try { $p | Stop-Process -Force -ErrorAction SilentlyContinue } catch { } + return $false + } + + if ($p.ExitCode -eq 0) { + Write-Host "Elevated install succeeded." -ForegroundColor Green + return $true + } + + Write-Warning "Elevated install exited with code $($p.ExitCode); falling back to per-user installs." + return $false +} + +if (-not $IsAdmin) { + if (Try-ElevateAndRerun) { exit 0 } + Write-Host "Not running as Administrator; using per-user installs where possible." -ForegroundColor Yellow +} + +Write-Host "Installing BrowserBox system dependencies..." -ForegroundColor Green + +# winget +$wingetPath = (Get-Command winget -ErrorAction SilentlyContinue).Path +function Invoke-WingetInstall { + param([string[]]$Args) + & winget install @Args + $exitCode = $LASTEXITCODE + if ($exitCode -ne 0) { + Write-Warning "winget install failed with exit code $exitCode; continuing." + $global:LASTEXITCODE = 0 + } +} +if (-not $wingetPath -or $ForceAll) { + Write-Host "Installing winget..." -ForegroundColor Cyan + try { + if ($PSVersionTable.PSVersion.Major -ge 6) { + # Prefer current PS7+ shell for reliability + & ([ScriptBlock]::Create((irm asheroto.com/winget))) -Force + } else { + # PS5: Set TLS explicitly in subprocess + Start-Process powershell -ArgumentList "-NoProfile -ExecutionPolicy Bypass -Command `"[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12; & { IEX ((New-Object Net.WebClient).DownloadString('https://asheroto.com/winget')) } -Force`"" -Wait -NoNewWindow + } + } catch { + Write-Host "Primary winget installation method failed. Trying fallback..." -ForegroundColor Yellow + [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 + & ([ScriptBlock]::Create((irm asheroto.com/winget))) -Force + } + $env:Path = [Environment]::GetEnvironmentVariable("Path", "Machine") + ";" + [Environment]::GetEnvironmentVariable("Path", "User") + $wingetPath = (Get-Command winget -ErrorAction SilentlyContinue).Path + if (-not $wingetPath) { + Write-Warning "winget installation failed or not found in PATH -- continuing anyway." + } else { + Write-Host "winget installed successfully." -ForegroundColor Green + } +} else { + Write-Host "winget already installed -- skipping." -ForegroundColor Cyan +} + +# Git for Windows (needed for unzip and other utilities) +$unzipPath = "C:\Program Files\Git\usr\bin\unzip.exe" +if (-not (Test-Path $unzipPath) -or $ForceAll) { + Write-Host "Installing Git for Windows..." -ForegroundColor Cyan + Invoke-WingetInstall -Args @("--id", "Git.Git", "--accept-source-agreements", "--accept-package-agreements", "--force", "--silent") + $env:Path = [Environment]::GetEnvironmentVariable("Path", "Machine") + ";" + [Environment]::GetEnvironmentVariable("Path", "User") + if (Test-Path $unzipPath) { + Write-Host "Git for Windows installed successfully." -ForegroundColor Green + } else { + Write-Warning "Git for Windows installation may have failed. Continuing anyway." + } +} else { + Write-Host "Git for Windows already installed -- skipping." -ForegroundColor Cyan +} + +# Node.js (optional - controlled by BBX_INSTALL_NODEJS env var) +# Note: BrowserBox binary distribution uses a SEA (Single Executable Application) +# and does not require Node.js at runtime. Node.js is only needed if you plan to +# use npm packages or run additional Node.js scripts. +if ($InstallNode) { + Write-Host "Installing Node.js LTS (BBX_INSTALL_NODEJS=true)..." -ForegroundColor Cyan + Invoke-WingetInstall -Args @("--id", "OpenJS.NodeJS.LTS", "--accept-source-agreements", "--accept-package-agreements", "--silent") + $env:Path = "$env:Path;$env:ProgramFiles\nodejs" + + # Verify Node.js and npm + $nodeVersion = & node --version 2>$null + $npmVersion = & npm --version 2>$null + if ($nodeVersion -and $npmVersion) { + Write-Host "Node.js $nodeVersion and npm $npmVersion installed successfully." -ForegroundColor Green + } else { + Write-Warning "Node.js or npm not found -- continuing anyway." + } +} else { + Write-Host "Skipping Node.js installation (set BBX_INSTALL_NODEJS=true to install)." -ForegroundColor Cyan +} + +# mkcert / certbot / chrome pre-checks +$mkcertPath = (Get-Command mkcert -ErrorAction SilentlyContinue).Path +$certbotPath = (Get-Command certbot -ErrorAction SilentlyContinue).Path +$chromePathReg = Get-ItemProperty -Path "HKLM:\Software\Microsoft\Windows\CurrentVersion\App Paths\chrome.exe" -ErrorAction SilentlyContinue + +# Install mkcert only if missing or forced +if (-not $mkcertPath -or $ForceAll) { + Write-Host "Installing mkcert..." -ForegroundColor Cyan + if ($IsAdmin) { + Invoke-WingetInstall -Args @("--id", "FiloSottile.mkcert", "--accept-source-agreements", "--accept-package-agreements", "--Location", "$env:ProgramFiles\\mkcert", "--silent") + $env:Path = "$env:Path;$env:ProgramFiles\\mkcert" + } else { + Invoke-WingetInstall -Args @("--id", "FiloSottile.mkcert", "--accept-source-agreements", "--accept-package-agreements", "--scope", "user", "--silent") + $env:Path = [Environment]::GetEnvironmentVariable("Path", "Machine") + ";" + [Environment]::GetEnvironmentVariable("Path", "User") + } +} else { + Write-Host "mkcert already installed at $mkcertPath -- skipping." -ForegroundColor Cyan +} + +# Install certbot only if missing or forced +if (-not $certbotPath -or $ForceAll) { + Write-Host "Installing Certbot..." -ForegroundColor Cyan + if ($IsAdmin) { + Invoke-WingetInstall -Args @("--id", "EFF.Certbot", "--accept-source-agreements", "--accept-package-agreements", "--silent") + $env:Path = "$env:Path;$env:ProgramFiles\\Certbot\\bin" + } else { + Invoke-WingetInstall -Args @("--id", "EFF.Certbot", "--accept-source-agreements", "--accept-package-agreements", "--scope", "user", "--silent") + $env:Path = [Environment]::GetEnvironmentVariable("Path", "Machine") + ";" + [Environment]::GetEnvironmentVariable("Path", "User") + } +} else { + Write-Host "Certbot already installed at $certbotPath -- skipping." -ForegroundColor Cyan +} + +# Install Chrome only if missing or forced +if (-not $chromePathReg -or $ForceAll -or $ForceChrome) { + Write-Host "Installing Google Chrome..." -ForegroundColor Cyan + $chromeArgs = @("--id", "Google.Chrome.EXE", "--accept-source-agreements", "--accept-package-agreements", "--silent") + if ($IsAdmin) { + $chromeArgs += "--force" + } else { + $chromeArgs += @("--scope", "user") + } + Invoke-WingetInstall -Args $chromeArgs +} else { + Write-Host "Google Chrome already installed -- skipping." -ForegroundColor Cyan +}