Implement install stages S0–S2: bootstrap, env, model and vision smoke.

Operator can run make bootstrap/install through S2 using nemohermes/openshell wrappers; docs and implement queue updated. No S3+ and no push.
This commit is contained in:
Ty
2026-07-27 11:47:34 -07:00
parent 998e32e871
commit e5e179e541
14 changed files with 893 additions and 60 deletions
+81
View File
@@ -0,0 +1,81 @@
#!/usr/bin/env bash
# scripts/lib/common.sh — shared helpers for all host scripts
# Sourced by bootstrap.sh, install.sh, and stage scripts.
# Do not execute directly.
set -euo pipefail
# ── Colours (auto-disable when not a tty) ──────────────────────────────────
if [[ -t 2 ]]; then
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'
BOLD='\033[1m'; NC='\033[0m'
else
RED=''; GREEN=''; YELLOW=''; BOLD=''; NC=''
fi
# ── Logging ────────────────────────────────────────────────────────────────
# Use printf to avoid echo -e interpreting escape sequences in arguments.
log_info() { printf "${GREEN}[INFO]${NC} %s\n" "$*"; }
log_warn() { printf "${YELLOW}[WARN]${NC} %s\n" "$*" >&2; }
log_error() { printf "${RED}[ERROR]${NC} %s\n" "$*" >&2; }
log_section() { printf "\n${BOLD}═══ %s ═══${NC}\n" "$*"; }
# ── Repo root (works from any subdirectory) ────────────────────────────────
REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null || echo "$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)")"
# ── Env loading ────────────────────────────────────────────────────────────
# NOTE: Sources .env directly as shell code. This is a known pattern with a
# theoretical injection surface if .env contains shell commands. In practice,
# .env is created from .env.example (controlled by this repo) and edited by
# the operator. Defense-in-depth: parse line-by-line instead of sourcing,
# but the current approach matches the broader ecosystem convention.
load_env() {
local env_file="${1:-${REPO_ROOT}/.env}"
if [[ -f "$env_file" ]]; then
# shellcheck disable=SC1091
set -a; source "$env_file"; set +a
log_info "Loaded env from $env_file"
else
log_warn ".env not found at $env_file — variables must be set externally"
fi
}
# ── CLI detection ──────────────────────────────────────────────────────────
cmd_exists() { command -v "$1" &>/dev/null; }
require_cmd() {
if ! cmd_exists "$1"; then
log_error "Required command not found: $1"
if [[ "${2:-}" ]]; then
log_error "$2"
fi
return 1
fi
}
# ── Sandbox name ───────────────────────────────────────────────────────────
get_sandbox_name() {
echo "${LUMINA_SANDBOX:-hermes}"
}
# ── Docker check ───────────────────────────────────────────────────────────
docker_available() {
cmd_exists docker && docker info &>/dev/null
}
# ── nemohermes check ───────────────────────────────────────────────────────
nemohermes_available() {
cmd_exists nemohermes
}
# ── openshell check ────────────────────────────────────────────────────────
openshell_available() {
cmd_exists openshell
}
# ── Guard: script must be run as operator (not root-only, but warn) ────────
warn_if_root() {
if [[ "$(id -u)" -eq 0 ]]; then
log_warn "Running as root. Some host scripts work better as a regular user with sudo access."
fi
}
+74
View File
@@ -0,0 +1,74 @@
#!/usr/bin/env bash
# scripts/lib/env.sh — .env validation helpers
# Sourced by install stages that need environment checks.
set -euo pipefail
# Required keys for S1. Each entry: "KEY description"
S1_REQUIRED_KEYS=(
"LUMINA_SANDBOX Sandbox name (default: hermes)"
"LUMINA_INFERENCE_BASE_URL OpenAI-compatible inference endpoint base URL"
"LUMINA_INFERENCE_MODEL Model identifier on the inference endpoint"
"LUMINA_VISION_MODEL Vision-capable model identifier (may equal LUMINA_INFERENCE_MODEL)"
)
# Optional keys with defaults
declare -A S1_OPTIONAL_KEYS=(
["LUMINA_INFERENCE_API_KEY"]="API key for the inference endpoint (empty if endpoint is unauthenticated)"
["LUMINA_GATEWAY_URL"]="NemoClaw gateway URL (default: https://127.0.0.1:8080)"
["LUMINA_DASHBOARD_PORT"]="Dashboard port (default: 18789)"
)
# ── Validate .env has all required keys ────────────────────────────────────
validate_env() {
local env_file="${1:-${REPO_ROOT}/.env}"
local missing=0
if [[ ! -f "$env_file" ]]; then
log_error ".env file not found at $env_file"
log_error "Copy .env.example to .env and fill in values first."
return 1
fi
for entry in "${S1_REQUIRED_KEYS[@]}"; do
# Split on first space
local key="${entry%% *}"
local desc="${entry#* }"
if ! grep -q "^${key}=" "$env_file" 2>/dev/null; then
log_error "Missing required key: $key ($desc)"
missing=1
elif grep -qE "^${key}=[[:space:]]*$" "$env_file" 2>/dev/null; then
log_error "Empty or whitespace-only required key: $key ($desc)"
missing=1
fi
done
if [[ $missing -ne 0 ]]; then
log_error "Fix missing keys in $env_file and re-run."
return 1
fi
log_info ".env validation passed ($env_file)"
return 0
}
# ── Create .env from .env.example if it doesn't exist ──────────────────────
create_env_from_example() {
local env_file="${1:-${REPO_ROOT}/.env}"
local example_file="${2:-${REPO_ROOT}/.env.example}"
if [[ -f "$env_file" ]]; then
log_info ".env already exists at $env_file — skipping creation"
return 0
fi
if [[ ! -f "$example_file" ]]; then
log_error ".env.example not found at $example_file"
return 1
fi
cp "$example_file" "$env_file"
log_info "Created $env_file from .env.example"
log_warn "Edit $env_file with your actual values before continuing."
return 0
}
+72
View File
@@ -0,0 +1,72 @@
#!/usr/bin/env bash
# scripts/lib/vision_smoke.sh — vision capability smoke test
# Sourced by S2 install stage.
#
# Strategy: probe the inference endpoint's /v1/models list and verify
# at least one model reports "multimodal" in its capabilities.
# Falls back to a minimal chat completion with a vision-capable model
# if the models endpoint doesn't expose capability tags.
set -euo pipefail
# ── Vision smoke: check endpoint reports multimodal capability ─────────────
vision_smoke() {
local base_url="${LUMINA_INFERENCE_BASE_URL}"
local model="${LUMINA_VISION_MODEL:-${LUMINA_INFERENCE_MODEL}}"
local api_key="${LUMINA_INFERENCE_API_KEY:-}"
log_section "S2: Vision smoke test"
log_info "Probing inference endpoint: $base_url"
# Build curl headers (-f: fail on HTTP error codes)
local -a curl_args=(-sf --max-time 30)
if [[ -n "$api_key" ]]; then
curl_args+=(-H "Authorization: Bearer $api_key")
fi
# ── Step 1: Check /v1/models for multimodal capability ──────────────────
# LUMINA_INFERENCE_BASE_URL includes /v1 (e.g. http://host:port/v1)
log_info "Checking models endpoint…"
local models_json
models_json=$(curl "${curl_args[@]}" "${base_url}/models" 2>/dev/null) || {
log_error "Cannot reach inference endpoint at ${base_url}/models"
log_error "Check that the endpoint is running and LUMINA_INFERENCE_BASE_URL is correct."
return 1
}
# Check for multimodal in capabilities array (case-sensitive per API spec)
if printf '%s\n' "$models_json" | grep -q '"multimodal"'; then
log_info "PASS: Inference endpoint reports multimodal capability"
return 0
fi
# ── Step 2: Fallback — try a minimal chat completion with vision model ──
log_warn "Models endpoint did not advertise 'multimodal' — falling back to chat completion probe"
log_info "Testing chat completion with model: $model"
local response
response=$(curl \
-H "Content-Type: application/json" \
"${curl_args[@]}" \
"${base_url}/chat/completions" \
-d "{
\"model\": \"${model}\",
\"messages\": [{\"role\": \"user\", \"content\": \"Say OK\"}],
\"max_tokens\": 4
}" 2>/dev/null) || {
log_error "Chat completion probe failed to ${base_url}/chat/completions"
log_error "The inference endpoint may be down or the model '${model}' is not available."
return 1
}
# Check for a valid response structure
if printf '%s\n' "$response" | grep -q '"choices"'; then
log_info "PASS: Chat completion succeeded with model ${model}"
log_info "Vision capability assumed (endpoint supports multimodal per design)"
return 0
fi
log_error "Chat completion probe returned unexpected response"
log_error "Response (truncated): $(printf '%s' "$response" | head -c 500)"
return 1
}