From 87503352811ea34813f9e6721c5542001619316f Mon Sep 17 00:00:00 2001 From: Abhimanyu Saharan Date: Fri, 13 Feb 2026 14:41:27 +0530 Subject: [PATCH 01/10] feat: add installer script and CI configuration for deployment modes --- .github/workflows/ci.yml | 49 +++ README.md | 15 + frontend/.dockerignore | 9 + install.sh | 727 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 800 insertions(+) create mode 100644 frontend/.dockerignore create mode 100755 install.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f17053e0..fc1ada8e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -102,6 +102,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..b398c3d0 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,21 @@ Core operational areas: ## Get started in minutes +### Option A: One-command production-style bootstrap + +```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. + +### Option B: Manual setup + ### Prerequisites - Docker Engine 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..30500801 --- /dev/null +++ b/install.sh @@ -0,0 +1,727 @@ +#!/usr/bin/env bash + +set -euo pipefail + +SCRIPT_NAME="$(basename "$0")" +REPO_ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)" +LOG_DIR="$REPO_ROOT/.install-logs" + +LINUX_DISTRO="" +APT_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) + FORCE_MODE="${2:-}" + shift 2 + ;; + --backend-port) + FORCE_BACKEND_PORT="${2:-}" + shift 2 + ;; + --frontend-port) + FORCE_FRONTEND_PORT="${2:-}" + shift 2 + ;; + --public-host) + FORCE_PUBLIC_HOST="${2:-}" + shift 2 + ;; + --api-url) + FORCE_API_URL="${2:-}" + shift 2 + ;; + --token-mode) + FORCE_TOKEN_MODE="${2:-}" + shift 2 + ;; + --local-auth-token) + FORCE_LOCAL_AUTH_TOKEN="${2:-}" + shift 2 + ;; + --db-mode) + FORCE_DB_MODE="${2:-}" + shift 2 + ;; + --database-url) + FORCE_DATABASE_URL="${2:-}" + shift 2 + ;; + --start-services) + 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 +} + +detect_platform() { + local uname_s + local id_like + uname_s="$(uname -s)" + if [[ "$uname_s" != "Linux" ]]; then + die "Unsupported platform: $uname_s. This installer currently supports Ubuntu/Debian only." + 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="${ID_LIKE:-}" + + if [[ "$LINUX_DISTRO" != "ubuntu" && "$LINUX_DISTRO" != "debian" && ! "$id_like" =~ (^|[[:space:]])debian($|[[:space:]]) ]]; then + die "Unsupported Linux distribution: $LINUX_DISTRO. This installer currently supports Ubuntu/Debian only." + fi + + if ! command_exists apt-get; then + die "apt-get is required on this system." + fi +} + +install_packages() { + local -a packages + packages=("$@") + + if [[ "${#packages[@]}" -eq 0 ]]; then + return 0 + fi + + if [[ "$APT_UPDATED" -eq 0 ]]; then + as_root apt-get update + APT_UPDATED=1 + fi + as_root apt-get install -y "${packages[@]}" +} + +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 >= 20)) && command_exists npm; then + return + fi + fi + + info "Node.js >= 20 is required for local deployment." + if ! confirm "Install or upgrade Node.js now?" "y"; then + die "Cannot continue without Node.js >= 20." + fi + + install_packages ca-certificates curl gnupg + curl -fsSL https://deb.nodesource.com/setup_20.x | as_root bash - + install_packages nodejs + + if ! command_exists node || ! command_exists npm; then + die "Node.js/npm installation failed." + fi + + node_version="$(node -v || true)" + node_major="${node_version#v}" + node_major="${node_major%%.*}" + if [[ ! "$node_major" =~ ^[0-9]+$ ]] || ((node_major < 20)); then + die "Detected Node.js $node_version. Node.js >= 20 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 < Date: Fri, 13 Feb 2026 14:45:15 +0530 Subject: [PATCH 02/10] chore: remove manual CI trigger for master branch in workflow configuration --- .github/workflows/ci.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fc1ada8e..5842eba6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,8 +3,6 @@ name: CI 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: From 645e620ae9ca07a273916cf1069862e8ef40c12c Mon Sep 17 00:00:00 2001 From: Abhimanyu Saharan Date: Fri, 13 Feb 2026 09:40:54 +0000 Subject: [PATCH 03/10] installer: scaffold package manager abstraction and support matrix --- README.md | 2 + docs/installer-support.md | 25 +++++++++++++ install.sh | 77 +++++++++++++++++++++++++++++++++------ 3 files changed, 93 insertions(+), 11 deletions(-) create mode 100644 docs/installer-support.md diff --git a/README.md b/README.md index b398c3d0..f6e6d4a2 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,8 @@ The installer is interactive and will: - 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 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/install.sh b/install.sh index 30500801..ab2bfa36 100755 --- a/install.sh +++ b/install.sh @@ -7,7 +7,8 @@ REPO_ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)" LOG_DIR="$REPO_ROOT/.install-logs" LINUX_DISTRO="" -APT_UPDATED=0 +PKG_MANAGER="" +PKG_UPDATED=0 DOCKER_USE_SUDO=0 INTERACTIVE=0 @@ -145,12 +146,40 @@ as_root() { 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 local id_like uname_s="$(uname -s)" if [[ "$uname_s" != "Linux" ]]; then - die "Unsupported platform: $uname_s. This installer currently supports Ubuntu/Debian only." + die "Unsupported platform: $uname_s. Linux is required." fi if [[ ! -r /etc/os-release ]]; then @@ -162,13 +191,25 @@ detect_platform() { LINUX_DISTRO="${ID:-unknown}" id_like="${ID_LIKE:-}" - if [[ "$LINUX_DISTRO" != "ubuntu" && "$LINUX_DISTRO" != "debian" && ! "$id_like" =~ (^|[[:space:]])debian($|[[:space:]]) ]]; then - die "Unsupported Linux distribution: $LINUX_DISTRO. This installer currently supports Ubuntu/Debian only." + 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 ! command_exists apt-get; then - die "apt-get is required on this system." + 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() { @@ -179,11 +220,21 @@ install_packages() { return 0 fi - if [[ "$APT_UPDATED" -eq 0 ]]; then - as_root apt-get update - APT_UPDATED=1 - fi - as_root apt-get install -y "${packages[@]}" + 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() { @@ -383,6 +434,10 @@ ensure_nodejs() { die "Cannot continue without Node.js >= 20." fi + if [[ "$PKG_MANAGER" != "apt" ]]; then + die "Node.js auto-install is currently implemented for apt-based distros only. Install Node.js >= 20 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_20.x | as_root bash - install_packages nodejs From 01fc50a1d29c26a00ed5b9dcdbd80b6115b0fc91 Mon Sep 17 00:00:00 2001 From: Abhimanyu Saharan Date: Fri, 13 Feb 2026 14:07:17 +0000 Subject: [PATCH 04/10] installer: require Node.js >= 22 --- install.sh | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/install.sh b/install.sh index ab2bfa36..d601ac41 100755 --- a/install.sh +++ b/install.sh @@ -424,22 +424,22 @@ ensure_nodejs() { node_version="$(node -v || true)" node_major="${node_version#v}" node_major="${node_major%%.*}" - if [[ "$node_major" =~ ^[0-9]+$ ]] && ((node_major >= 20)) && command_exists npm; then + if [[ "$node_major" =~ ^[0-9]+$ ]] && ((node_major >= 22)) && command_exists npm; then return fi fi - info "Node.js >= 20 is required for local deployment." + 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 >= 20." + 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 >= 20 manually, then rerun installer. Suggested command: $(install_command_hint "$PKG_MANAGER" nodejs npm)" + 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_20.x | as_root bash - + curl -fsSL https://deb.nodesource.com/setup_22.x | as_root bash - install_packages nodejs if ! command_exists node || ! command_exists npm; then @@ -449,8 +449,8 @@ ensure_nodejs() { node_version="$(node -v || true)" node_major="${node_version#v}" node_major="${node_major%%.*}" - if [[ ! "$node_major" =~ ^[0-9]+$ ]] || ((node_major < 20)); then - die "Detected Node.js $node_version. Node.js >= 20 is required." + if [[ ! "$node_major" =~ ^[0-9]+$ ]] || ((node_major < 22)); then + die "Detected Node.js $node_version. Node.js >= 22 is required." fi } From be111106204eceec0b0061e71001039ecce5d971 Mon Sep 17 00:00:00 2001 From: Abhimanyu Saharan Date: Fri, 13 Feb 2026 14:15:40 +0000 Subject: [PATCH 05/10] installer: prefer /usr/bin/node after NodeSource install --- install.sh | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/install.sh b/install.sh index d601ac41..c6374ef4 100755 --- a/install.sh +++ b/install.sh @@ -446,6 +446,12 @@ ensure_nodejs() { 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%%.*}" @@ -779,4 +785,4 @@ Stop local background services: SUMMARY } -main "$@" +main "$@" \ No newline at end of file From 22a51cccfb50ee58f29ed334091c106ab1db254b Mon Sep 17 00:00:00 2001 From: Abhimanyu Saharan Date: Fri, 13 Feb 2026 16:40:13 +0000 Subject: [PATCH 06/10] docs: add curl|bash one-liner for install.sh --- README.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/README.md b/README.md index f6e6d4a2..7ba565a9 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,14 @@ Core operational areas: ### 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 ``` From 80c4390dec1eb69afb12e9d6e4fab13fd3a1b55b Mon Sep 17 00:00:00 2001 From: Abhimanyu Saharan Date: Fri, 13 Feb 2026 16:49:42 +0000 Subject: [PATCH 07/10] installer: validate required flag values in parse_args --- install.sh | 60 +++++++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 50 insertions(+), 10 deletions(-) diff --git a/install.sh b/install.sh index c6374ef4..cc116d90 100755 --- a/install.sh +++ b/install.sh @@ -73,43 +73,83 @@ parse_args() { while [[ $# -gt 0 ]]; do case "$1" in --mode) - FORCE_MODE="${2:-}" + if [[ $# -lt 2 || -z ${2:-} ]]; then + usage + die "Missing value for --mode" + fi + FORCE_MODE="$2" shift 2 ;; --backend-port) - FORCE_BACKEND_PORT="${2:-}" + if [[ $# -lt 2 || -z ${2:-} ]]; then + usage + die "Missing value for --backend-port" + fi + FORCE_BACKEND_PORT="$2" shift 2 ;; --frontend-port) - FORCE_FRONTEND_PORT="${2:-}" + if [[ $# -lt 2 || -z ${2:-} ]]; then + usage + die "Missing value for --frontend-port" + fi + FORCE_FRONTEND_PORT="$2" shift 2 ;; --public-host) - FORCE_PUBLIC_HOST="${2:-}" + if [[ $# -lt 2 || -z ${2:-} ]]; then + usage + die "Missing value for --public-host" + fi + FORCE_PUBLIC_HOST="$2" shift 2 ;; --api-url) - FORCE_API_URL="${2:-}" + if [[ $# -lt 2 || -z ${2:-} ]]; then + usage + die "Missing value for --api-url" + fi + FORCE_API_URL="$2" shift 2 ;; --token-mode) - FORCE_TOKEN_MODE="${2:-}" + if [[ $# -lt 2 || -z ${2:-} ]]; then + usage + die "Missing value for --token-mode" + fi + FORCE_TOKEN_MODE="$2" shift 2 ;; --local-auth-token) - FORCE_LOCAL_AUTH_TOKEN="${2:-}" + if [[ $# -lt 2 || -z ${2:-} ]]; then + usage + die "Missing value for --local-auth-token" + fi + FORCE_LOCAL_AUTH_TOKEN="$2" shift 2 ;; --db-mode) - FORCE_DB_MODE="${2:-}" + if [[ $# -lt 2 || -z ${2:-} ]]; then + usage + die "Missing value for --db-mode" + fi + FORCE_DB_MODE="$2" shift 2 ;; --database-url) - FORCE_DATABASE_URL="${2:-}" + if [[ $# -lt 2 || -z ${2:-} ]]; then + usage + die "Missing value for --database-url" + fi + FORCE_DATABASE_URL="$2" shift 2 ;; --start-services) - FORCE_START_SERVICES="${2:-}" + if [[ $# -lt 2 || -z ${2:-} ]]; then + usage + die "Missing value for --start-services" + fi + FORCE_START_SERVICES="$2" shift 2 ;; -h|--help) From 30c337d733897f486c55128f4a41752a07d9b4f7 Mon Sep 17 00:00:00 2001 From: Abhimanyu Saharan Date: Fri, 13 Feb 2026 16:52:25 +0000 Subject: [PATCH 08/10] installer: remove unused id_like variable --- install.sh | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/install.sh b/install.sh index cc116d90..787d6903 100755 --- a/install.sh +++ b/install.sh @@ -216,7 +216,6 @@ install_command_hint() { detect_platform() { local uname_s - local id_like uname_s="$(uname -s)" if [[ "$uname_s" != "Linux" ]]; then die "Unsupported platform: $uname_s. Linux is required." @@ -229,7 +228,7 @@ detect_platform() { # shellcheck disable=SC1091 . /etc/os-release LINUX_DISTRO="${ID:-unknown}" - id_like="${ID_LIKE:-}" + # 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" From 552dbe0cd90b2108448fd2256f068bdf0ed49e1e Mon Sep 17 00:00:00 2001 From: Abhimanyu Saharan Date: Fri, 13 Feb 2026 16:53:35 +0000 Subject: [PATCH 09/10] installer: move logs to XDG state dir --- install.sh | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/install.sh b/install.sh index 787d6903..25f93b7d 100755 --- a/install.sh +++ b/install.sh @@ -4,7 +4,8 @@ set -euo pipefail SCRIPT_NAME="$(basename "$0")" REPO_ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)" -LOG_DIR="$REPO_ROOT/.install-logs" +STATE_DIR="${XDG_STATE_HOME:-$HOME/.local/state}" +LOG_DIR="$STATE_DIR/openclaw-mission-control-install" LINUX_DISTRO="" PKG_MANAGER="" From 35a9471eb683706d703bfbdc581b7319efdcd034 Mon Sep 17 00:00:00 2001 From: Abhimanyu Saharan Date: Fri, 13 Feb 2026 16:55:12 +0000 Subject: [PATCH 10/10] ci: restore push trigger to master only --- .github/workflows/ci.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5842eba6..8b84bbb0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,6 +3,7 @@ name: CI on: pull_request: push: + branches: [master] workflow_dispatch: concurrency: