diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4dcf4890..49339bbc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,7 +4,6 @@ on: pull_request: push: branches: [master] - # Allow maintainers to manually kick CI when GitHub doesn't create a run for a new head SHA. workflow_dispatch: concurrency: @@ -132,6 +131,55 @@ jobs: backend/coverage.xml frontend/coverage/** + installer: + runs-on: ubuntu-latest + needs: [check] + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Validate installer shell syntax + run: bash -n install.sh + + - name: Installer smoke test (docker mode) + run: | + ./install.sh \ + --mode docker \ + --backend-port 18000 \ + --frontend-port 13000 \ + --public-host localhost \ + --api-url http://localhost:18000 \ + --token-mode generate + curl -fsS http://127.0.0.1:18000/healthz >/dev/null + curl -fsS http://127.0.0.1:13000 >/dev/null + + - name: Cleanup docker stack after docker mode + if: always() + run: | + docker compose -f compose.yml --env-file .env down -v --remove-orphans || true + + - name: Installer smoke test (local mode) + run: | + ./install.sh \ + --mode local \ + --backend-port 18001 \ + --frontend-port 13001 \ + --public-host localhost \ + --api-url http://localhost:18001 \ + --token-mode generate \ + --db-mode docker \ + --start-services yes + curl -fsS http://127.0.0.1:18001/healthz >/dev/null + curl -fsS http://127.0.0.1:13001 >/dev/null + + - name: Cleanup local processes and docker resources + if: always() + run: | + if [ -f .install-logs/backend.pid ]; then kill "$(cat .install-logs/backend.pid)" || true; fi + if [ -f .install-logs/frontend.pid ]; then kill "$(cat .install-logs/frontend.pid)" || true; fi + docker compose -f compose.yml --env-file .env down -v --remove-orphans || true + e2e: runs-on: ubuntu-latest needs: [check] diff --git a/README.md b/README.md index 3cabdc41..7ba565a9 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,31 @@ Core operational areas: ## Get started in minutes +### Option A: One-command production-style bootstrap + +If you haven't cloned the repo yet, you can run the installer in one line: + +```bash +curl -fsSL https://raw.githubusercontent.com/abhi1693/openclaw-mission-control/master/install.sh | bash +``` + +If you already cloned the repo: + +```bash +./install.sh +``` + +The installer is interactive and will: + +- Ask for deployment mode (`docker` or `local`). +- Install missing system dependencies when possible. +- Generate and configure environment files. +- Bootstrap and start the selected deployment mode. + +Installer support matrix: [`docs/installer-support.md`](./docs/installer-support.md) + +### Option B: Manual setup + ### Prerequisites - Docker Engine diff --git a/docs/installer-support.md b/docs/installer-support.md new file mode 100644 index 00000000..e870914d --- /dev/null +++ b/docs/installer-support.md @@ -0,0 +1,25 @@ +# Installer platform support + +This document defines current support status for `./install.sh`. + +## Support states + +- **Stable**: full tested path in CI and expected to work end-to-end. +- **Scaffolded**: distro is detected and actionable install guidance is provided, but full automatic package installation is not implemented yet. +- **Unsupported**: distro/package manager is not detected by installer. + +## Current matrix + +| Distro family | Package manager | State | Notes | +|---|---|---|---| +| Debian / Ubuntu | `apt` | **Stable** | Full automatic dependency install path. | +| Fedora / RHEL / CentOS | `dnf` / `yum` | **Scaffolded** | Detection + actionable commands present; auto-install path is TODO. | +| openSUSE | `zypper` | **Scaffolded** | Detection + actionable commands present; auto-install path is TODO. | +| Arch Linux | `pacman` | **Scaffolded** | Detection + actionable commands present; auto-install path is TODO. | +| Other Linux distros | unknown | **Unsupported** | Installer exits with package-manager guidance requirement. | + +## Guard rails + +- Debian/Ubuntu behavior must remain stable for every portability PR. +- New distro support should be added behind explicit package-manager adapters and tests. +- If a distro is scaffolded but not fully automated, installer should fail fast with actionable manual commands (not generic errors). diff --git a/frontend/.dockerignore b/frontend/.dockerignore new file mode 100644 index 00000000..2ad88003 --- /dev/null +++ b/frontend/.dockerignore @@ -0,0 +1,9 @@ +node_modules +.next +coverage +cypress/screenshots +cypress/videos +npm-debug.log* +.env +.env.* +.git diff --git a/install.sh b/install.sh new file mode 100755 index 00000000..25f93b7d --- /dev/null +++ b/install.sh @@ -0,0 +1,828 @@ +#!/usr/bin/env bash + +set -euo pipefail + +SCRIPT_NAME="$(basename "$0")" +REPO_ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)" +STATE_DIR="${XDG_STATE_HOME:-$HOME/.local/state}" +LOG_DIR="$STATE_DIR/openclaw-mission-control-install" + +LINUX_DISTRO="" +PKG_MANAGER="" +PKG_UPDATED=0 +DOCKER_USE_SUDO=0 +INTERACTIVE=0 + +FORCE_MODE="" +FORCE_BACKEND_PORT="" +FORCE_FRONTEND_PORT="" +FORCE_PUBLIC_HOST="" +FORCE_API_URL="" +FORCE_TOKEN_MODE="" +FORCE_LOCAL_AUTH_TOKEN="" +FORCE_DB_MODE="" +FORCE_DATABASE_URL="" +FORCE_START_SERVICES="" + +if [[ -t 0 ]]; then + INTERACTIVE=1 +fi + +info() { + printf '[INFO] %s\n' "$*" +} + +warn() { + printf '[WARN] %s\n' "$*" >&2 +} + +error() { + printf '[ERROR] %s\n' "$*" >&2 +} + +die() { + error "$*" + exit 1 +} + +command_exists() { + command -v "$1" >/dev/null 2>&1 +} + +usage() { + cat < + --backend-port + --frontend-port + --public-host + --api-url + --token-mode + --local-auth-token Required when --token-mode manual + --db-mode Local mode only + --database-url Required when --db-mode external + --start-services Local mode only + -h, --help + +If an option is omitted, the script prompts in interactive mode and uses defaults in non-interactive mode. +EOF +} + +parse_args() { + while [[ $# -gt 0 ]]; do + case "$1" in + --mode) + if [[ $# -lt 2 || -z ${2:-} ]]; then + usage + die "Missing value for --mode" + fi + FORCE_MODE="$2" + shift 2 + ;; + --backend-port) + if [[ $# -lt 2 || -z ${2:-} ]]; then + usage + die "Missing value for --backend-port" + fi + FORCE_BACKEND_PORT="$2" + shift 2 + ;; + --frontend-port) + if [[ $# -lt 2 || -z ${2:-} ]]; then + usage + die "Missing value for --frontend-port" + fi + FORCE_FRONTEND_PORT="$2" + shift 2 + ;; + --public-host) + if [[ $# -lt 2 || -z ${2:-} ]]; then + usage + die "Missing value for --public-host" + fi + FORCE_PUBLIC_HOST="$2" + shift 2 + ;; + --api-url) + if [[ $# -lt 2 || -z ${2:-} ]]; then + usage + die "Missing value for --api-url" + fi + FORCE_API_URL="$2" + shift 2 + ;; + --token-mode) + if [[ $# -lt 2 || -z ${2:-} ]]; then + usage + die "Missing value for --token-mode" + fi + FORCE_TOKEN_MODE="$2" + shift 2 + ;; + --local-auth-token) + if [[ $# -lt 2 || -z ${2:-} ]]; then + usage + die "Missing value for --local-auth-token" + fi + FORCE_LOCAL_AUTH_TOKEN="$2" + shift 2 + ;; + --db-mode) + if [[ $# -lt 2 || -z ${2:-} ]]; then + usage + die "Missing value for --db-mode" + fi + FORCE_DB_MODE="$2" + shift 2 + ;; + --database-url) + if [[ $# -lt 2 || -z ${2:-} ]]; then + usage + die "Missing value for --database-url" + fi + FORCE_DATABASE_URL="$2" + shift 2 + ;; + --start-services) + if [[ $# -lt 2 || -z ${2:-} ]]; then + usage + die "Missing value for --start-services" + fi + FORCE_START_SERVICES="$2" + shift 2 + ;; + -h|--help) + usage + exit 0 + ;; + *) + die "Unknown argument: $1" + ;; + esac + done +} + +is_one_of() { + local value="$1" + shift + local option + for option in "$@"; do + if [[ "$value" == "$option" ]]; then + return 0 + fi + done + return 1 +} + +as_root() { + if [[ "$(id -u)" -eq 0 ]]; then + "$@" + else + if ! command_exists sudo; then + die "sudo is required to install system packages." + fi + sudo "$@" + fi +} + +install_command_hint() { + local manager="$1" + shift + local -a packages + packages=("$@") + + case "$manager" in + apt) + printf 'sudo apt-get update && sudo apt-get install -y %s' "${packages[*]}" + ;; + dnf) + printf 'sudo dnf install -y %s' "${packages[*]}" + ;; + yum) + printf 'sudo yum install -y %s' "${packages[*]}" + ;; + zypper) + printf 'sudo zypper install -y %s' "${packages[*]}" + ;; + pacman) + printf 'sudo pacman -Sy --noconfirm %s' "${packages[*]}" + ;; + *) + printf 'install packages manually: %s' "${packages[*]}" + ;; + esac +} + +detect_platform() { + local uname_s + uname_s="$(uname -s)" + if [[ "$uname_s" != "Linux" ]]; then + die "Unsupported platform: $uname_s. Linux is required." + fi + + if [[ ! -r /etc/os-release ]]; then + die "Cannot detect Linux distribution (/etc/os-release missing)." + fi + + # shellcheck disable=SC1091 + . /etc/os-release + LINUX_DISTRO="${ID:-unknown}" + # ID_LIKE is available via /etc/os-release when we need it for future detection heuristics. + + if command_exists apt-get; then + PKG_MANAGER="apt" + elif command_exists dnf; then + PKG_MANAGER="dnf" + elif command_exists yum; then + PKG_MANAGER="yum" + elif command_exists zypper; then + PKG_MANAGER="zypper" + elif command_exists pacman; then + PKG_MANAGER="pacman" + else + die "Unsupported Linux distribution: $LINUX_DISTRO. No supported package manager detected (expected apt/dnf/yum/zypper/pacman)." + fi + + if [[ "$PKG_MANAGER" != "apt" ]]; then + warn "Detected distro '$LINUX_DISTRO' with package manager '$PKG_MANAGER'. This installer currently provides Debian/Ubuntu as stable path; other distros are scaffolded with actionable guidance." + fi + + info "Detected Linux distro: $LINUX_DISTRO (package manager: $PKG_MANAGER)" +} + +install_packages() { + local -a packages + packages=("$@") + + if [[ "${#packages[@]}" -eq 0 ]]; then + return 0 + fi + + case "$PKG_MANAGER" in + apt) + if [[ "$PKG_UPDATED" -eq 0 ]]; then + as_root apt-get update + PKG_UPDATED=1 + fi + as_root apt-get install -y "${packages[@]}" + ;; + dnf|yum|zypper|pacman) + die "Automatic package install is not implemented yet for '$PKG_MANAGER'. Run: $(install_command_hint "$PKG_MANAGER" "${packages[@]}")" + ;; + *) + die "Unknown package manager '$PKG_MANAGER'. Install manually: ${packages[*]}" + ;; + esac +} + +prompt_with_default() { + local prompt="$1" + local default_value="$2" + local input="" + + if [[ "$INTERACTIVE" -eq 0 ]]; then + printf '%s\n' "$default_value" + return + fi + + read -r -p "$prompt [$default_value]: " input + input="${input:-$default_value}" + printf '%s\n' "$input" +} + +prompt_choice() { + local prompt="$1" + local default_value="$2" + shift 2 + local -a options + local input="" + local option="" + options=("$@") + + if [[ "$INTERACTIVE" -eq 0 ]]; then + printf '%s\n' "$default_value" + return + fi + + while true; do + read -r -p "$prompt [$(IFS='/'; echo "${options[*]}")] (default: $default_value): " input + input="${input:-$default_value}" + for option in "${options[@]}"; do + if [[ "$input" == "$option" ]]; then + printf '%s\n' "$input" + return + fi + done + warn "Invalid choice: $input" + done +} + +prompt_secret() { + local prompt="$1" + local input="" + + if [[ "$INTERACTIVE" -eq 0 ]]; then + printf '\n' + return + fi + + read -r -s -p "$prompt: " input + printf '\n' >&2 + printf '%s\n' "$input" +} + +confirm() { + local prompt="$1" + local default="${2:-y}" + local input="" + + if [[ "$INTERACTIVE" -eq 0 ]]; then + [[ "$default" == "y" ]] + return + fi + + if [[ "$default" == "y" ]]; then + read -r -p "$prompt [Y/n]: " input + input="${input:-y}" + else + read -r -p "$prompt [y/N]: " input + input="${input:-n}" + fi + + case "${input,,}" in + y|yes) + return 0 + ;; + *) + return 1 + ;; + esac +} + +is_valid_port() { + local value="$1" + [[ "$value" =~ ^[0-9]+$ ]] || return 1 + ((value >= 1 && value <= 65535)) +} + +generate_token() { + if command_exists openssl; then + openssl rand -hex 32 + else + tr -dc 'A-Za-z0-9' /dev/null || printf '%s' "$target_file")" +} + +upsert_env_value() { + local file="$1" + local key="$2" + local value="$3" + local tmp_file + + tmp_file="$(mktemp)" + awk -v k="$key" -v v="$value" ' + BEGIN { done = 0 } + $0 ~ ("^" k "=") { + print k "=" v + done = 1 + next + } + { print } + END { + if (!done) { + print k "=" v + } + } + ' "$file" >"$tmp_file" + mv "$tmp_file" "$file" +} + +ensure_command_with_packages() { + local cmd="$1" + shift + local -a packages + packages=("$@") + + if command_exists "$cmd"; then + return + fi + + info "Command '$cmd' is missing." + if ! confirm "Install required package(s) for '$cmd' now?" "y"; then + die "Cannot continue without '$cmd'." + fi + + install_packages "${packages[@]}" + + if ! command_exists "$cmd"; then + die "Failed to install '$cmd'." + fi +} + +ensure_uv() { + if command_exists uv; then + return + fi + + info "uv is not installed." + if ! confirm "Install uv using the official installer?" "y"; then + die "Cannot continue without uv for local deployment." + fi + + curl -LsSf https://astral.sh/uv/install.sh | sh + export PATH="$HOME/.local/bin:$PATH" + + if ! command_exists uv; then + die "uv was installed but is not available in PATH." + fi +} + +ensure_nodejs() { + local node_major="0" + local node_version="" + + if command_exists node; then + node_version="$(node -v || true)" + node_major="${node_version#v}" + node_major="${node_major%%.*}" + if [[ "$node_major" =~ ^[0-9]+$ ]] && ((node_major >= 22)) && command_exists npm; then + return + fi + fi + + info "Node.js >= 22 is required for local deployment." + if ! confirm "Install or upgrade Node.js now?" "y"; then + die "Cannot continue without Node.js >= 22." + fi + + if [[ "$PKG_MANAGER" != "apt" ]]; then + die "Node.js auto-install is currently implemented for apt-based distros only. Install Node.js >= 22 manually, then rerun installer. Suggested command: $(install_command_hint "$PKG_MANAGER" nodejs npm)" + fi + + install_packages ca-certificates curl gnupg + curl -fsSL https://deb.nodesource.com/setup_22.x | as_root bash - + install_packages nodejs + + if ! command_exists node || ! command_exists npm; then + die "Node.js/npm installation failed." + fi + + # Refresh command lookup + PATH after install (CI runners often have an older Node in PATH). + hash -r || true + if [[ -x /usr/bin/node ]]; then + export PATH="/usr/bin:$PATH" + fi + + node_version="$(node -v || true)" + node_major="${node_version#v}" + node_major="${node_major%%.*}" + if [[ ! "$node_major" =~ ^[0-9]+$ ]] || ((node_major < 22)); then + die "Detected Node.js $node_version. Node.js >= 22 is required." + fi +} + +ensure_docker() { + if command_exists docker && docker compose version >/dev/null 2>&1; then + return + fi + + info "Docker and Docker Compose v2 are required." + if ! confirm "Install Docker tooling now?" "y"; then + die "Cannot continue without Docker." + fi + + install_packages docker.io + if ! install_packages docker-compose-plugin; then + warn "docker-compose-plugin unavailable; trying docker-compose package." + install_packages docker-compose + fi + + if command_exists systemctl; then + as_root systemctl enable --now docker || warn "Could not enable/start docker service automatically." + fi + + if ! command_exists docker; then + die "Docker installation failed." + fi + + if ! docker compose version >/dev/null 2>&1; then + die "Docker Compose v2 is unavailable ('docker compose')." + fi +} + +docker_compose() { + local tmp_file + local rc=0 + + if [[ "$DOCKER_USE_SUDO" -eq 1 ]]; then + as_root docker compose "$@" + return + fi + + tmp_file="$(mktemp)" + if docker compose "$@" 2> >(tee "$tmp_file" >&2); then + rm -f "$tmp_file" + return + fi + rc=$? + + if [[ "$(id -u)" -ne 0 ]] && command_exists sudo; then + if grep -Eqi 'permission denied|docker.sock|cannot connect to the docker daemon' "$tmp_file"; then + warn "Docker permission issue detected, retrying with sudo." + DOCKER_USE_SUDO=1 + rm -f "$tmp_file" + as_root docker compose "$@" + return + fi + fi + + rm -f "$tmp_file" + return "$rc" +} + +wait_for_http() { + local url="$1" + local label="$2" + local timeout_seconds="${3:-120}" + local i + + for ((i = 1; i <= timeout_seconds; i++)); do + if curl -fsS "$url" >/dev/null 2>&1; then + info "$label is reachable at $url" + return 0 + fi + sleep 1 + done + + warn "Timed out waiting for $label at $url" + return 1 +} + +start_local_services() { + local backend_port="$1" + local frontend_port="$2" + + mkdir -p "$LOG_DIR" + + info "Starting backend in background..." + ( + cd "$REPO_ROOT/backend" + nohup uv run uvicorn app.main:app --host 0.0.0.0 --port "$backend_port" >"$LOG_DIR/backend.log" 2>&1 & + echo $! >"$LOG_DIR/backend.pid" + ) + + info "Starting frontend in background..." + ( + cd "$REPO_ROOT/frontend" + nohup npm run start -- --hostname 0.0.0.0 --port "$frontend_port" >"$LOG_DIR/frontend.log" 2>&1 & + echo $! >"$LOG_DIR/frontend.pid" + ) +} + +ensure_repo_layout() { + [[ -f "$REPO_ROOT/Makefile" ]] || die "Run $SCRIPT_NAME from repository root." + [[ -f "$REPO_ROOT/compose.yml" ]] || die "Missing compose.yml in repository root." +} + +main() { + local deployment_mode + local public_host + local backend_port + local frontend_port + local next_public_api_url + local token_mode + local local_auth_token + local db_mode="docker" + local database_url="" + local start_services="yes" + + cd "$REPO_ROOT" + ensure_repo_layout + parse_args "$@" + + detect_platform + info "Platform detected: linux ($LINUX_DISTRO)" + + if [[ -n "$FORCE_MODE" ]]; then + deployment_mode="$FORCE_MODE" + else + deployment_mode="$(prompt_choice "Deployment mode" "docker" "docker" "local")" + fi + if ! is_one_of "$deployment_mode" "docker" "local"; then + die "Invalid deployment mode: $deployment_mode (expected docker|local)" + fi + + while true; do + if [[ -n "$FORCE_BACKEND_PORT" ]]; then + backend_port="$FORCE_BACKEND_PORT" + else + backend_port="$(prompt_with_default "Backend port" "8000")" + fi + is_valid_port "$backend_port" && break + warn "Invalid backend port: $backend_port" + FORCE_BACKEND_PORT="" + done + + while true; do + if [[ -n "$FORCE_FRONTEND_PORT" ]]; then + frontend_port="$FORCE_FRONTEND_PORT" + else + frontend_port="$(prompt_with_default "Frontend port" "3000")" + fi + is_valid_port "$frontend_port" && break + warn "Invalid frontend port: $frontend_port" + FORCE_FRONTEND_PORT="" + done + + if [[ -n "$FORCE_PUBLIC_HOST" ]]; then + public_host="$FORCE_PUBLIC_HOST" + else + public_host="$(prompt_with_default "Public host/IP for browser access" "localhost")" + fi + if [[ -n "$FORCE_API_URL" ]]; then + next_public_api_url="$FORCE_API_URL" + else + next_public_api_url="$(prompt_with_default "Public API URL used by frontend" "http://$public_host:$backend_port")" + fi + + if [[ -n "$FORCE_TOKEN_MODE" ]]; then + token_mode="$FORCE_TOKEN_MODE" + else + token_mode="$(prompt_choice "LOCAL_AUTH_TOKEN" "generate" "generate" "manual")" + fi + if ! is_one_of "$token_mode" "generate" "manual"; then + die "Invalid token mode: $token_mode (expected generate|manual)" + fi + if [[ "$token_mode" == "manual" ]]; then + if [[ -n "$FORCE_LOCAL_AUTH_TOKEN" ]]; then + local_auth_token="$FORCE_LOCAL_AUTH_TOKEN" + else + local_auth_token="$(prompt_secret "Enter LOCAL_AUTH_TOKEN (min 50 chars)")" + fi + if [[ "${#local_auth_token}" -lt 50 ]]; then + die "LOCAL_AUTH_TOKEN must be at least 50 characters." + fi + else + local_auth_token="$(generate_token)" + info "Generated LOCAL_AUTH_TOKEN." + fi + + if [[ "$deployment_mode" == "local" ]]; then + if [[ -n "$FORCE_DB_MODE" ]]; then + db_mode="$FORCE_DB_MODE" + else + db_mode="$(prompt_choice "Database source for local deployment" "docker" "docker" "external")" + fi + if ! is_one_of "$db_mode" "docker" "external"; then + die "Invalid db mode: $db_mode (expected docker|external)" + fi + if [[ "$db_mode" == "external" ]]; then + if [[ -n "$FORCE_DATABASE_URL" ]]; then + database_url="$FORCE_DATABASE_URL" + else + database_url="$(prompt_with_default "External DATABASE_URL" "postgresql+psycopg://postgres:postgres@localhost:5432/mission_control")" + fi + fi + if [[ -n "$FORCE_START_SERVICES" ]]; then + start_services="$FORCE_START_SERVICES" + else + start_services="$(prompt_choice "Start backend/frontend processes automatically after bootstrap" "yes" "yes" "no")" + fi + if ! is_one_of "$start_services" "yes" "no"; then + die "Invalid start-services value: $start_services (expected yes|no)" + fi + fi + + ensure_command_with_packages curl curl + ensure_command_with_packages git git + ensure_command_with_packages make make + ensure_command_with_packages openssl openssl + + if [[ "$deployment_mode" == "docker" || "$db_mode" == "docker" ]]; then + ensure_docker + fi + + if [[ "$deployment_mode" == "local" ]]; then + ensure_uv + ensure_nodejs + info "Ensuring Python 3.12 is available through uv..." + uv python install 3.12 + fi + + ensure_file_from_example "$REPO_ROOT/.env" "$REPO_ROOT/.env.example" + upsert_env_value "$REPO_ROOT/.env" "BACKEND_PORT" "$backend_port" + upsert_env_value "$REPO_ROOT/.env" "FRONTEND_PORT" "$frontend_port" + upsert_env_value "$REPO_ROOT/.env" "AUTH_MODE" "local" + upsert_env_value "$REPO_ROOT/.env" "LOCAL_AUTH_TOKEN" "$local_auth_token" + upsert_env_value "$REPO_ROOT/.env" "NEXT_PUBLIC_API_URL" "$next_public_api_url" + upsert_env_value "$REPO_ROOT/.env" "CORS_ORIGINS" "http://$public_host:$frontend_port" + + if [[ "$deployment_mode" == "docker" ]]; then + upsert_env_value "$REPO_ROOT/.env" "DB_AUTO_MIGRATE" "true" + + info "Starting production-like Docker stack..." + docker_compose -f compose.yml --env-file .env up -d --build + + wait_for_http "http://127.0.0.1:$backend_port/healthz" "Backend" 180 || true + wait_for_http "http://127.0.0.1:$frontend_port" "Frontend" 180 || true + + cat <