#!/usr/bin/env bash set -Eeuo pipefail REPOSITORY_URL="${CONTENT_MANAGER_REPOSITORY_URL:-https://github.com/Afsharidevops/content-manager.git}" REPOSITORY_BRANCH="${CONTENT_MANAGER_BRANCH:-main}" SOURCE_PATH="${BASH_SOURCE[0]:-}" SOURCE_DIR="$(cd -- "$(dirname -- "${SOURCE_PATH:-.}")" 2>/dev/null && pwd || pwd)" # When install.sh is streamed with curl, fetch the complete repository first. # The real wizard is then executed from the clone with prompts attached to the # user's terminal instead of the exhausted curl pipe. if [[ ! -f "$SOURCE_DIR/docker-compose.yml" ]]; then command -v git >/dev/null 2>&1 || { printf 'git is required. Install git, then run this command again.\n' >&2 exit 1 } [[ -r /dev/tty ]] || { printf 'Interactive installation requires a terminal. Clone the repository and run ./install.sh.\n' >&2 exit 1 } INSTALL_TARGET="${CONTENT_MANAGER_DIR:-$HOME/content-manager}" if [[ -d "$INSTALL_TARGET/.git" ]]; then printf '[INFO] Updating existing installation in %s\n' "$INSTALL_TARGET" git -C "$INSTALL_TARGET" fetch origin "$REPOSITORY_BRANCH" git -C "$INSTALL_TARGET" switch "$REPOSITORY_BRANCH" git -C "$INSTALL_TARGET" pull --ff-only origin "$REPOSITORY_BRANCH" elif [[ -e "$INSTALL_TARGET" ]]; then printf 'Target exists but is not a Git repository: %s\n' "$INSTALL_TARGET" >&2 printf 'Set CONTENT_MANAGER_DIR to another path or move the existing directory.\n' >&2 exit 1 else printf '[INFO] Cloning into %s\n' "$INSTALL_TARGET" git clone --depth 1 --branch "$REPOSITORY_BRANCH" "$REPOSITORY_URL" "$INSTALL_TARGET" fi chmod +x "$INSTALL_TARGET/install.sh" "$INSTALL_TARGET/manage.sh" exec "$INSTALL_TARGET/install.sh" "$@" /dev/tty fi ROOT_DIR="$SOURCE_DIR" ENV_FILE="$ROOT_DIR/.env" HERMES_DIR="$ROOT_DIR/data/hermes" NINEROUTER_DIR="$ROOT_DIR/data/9router" OMNIROUTE_DIR="$ROOT_DIR/data/omniroute" OPENWEBUI_DIR="$ROOT_DIR/data/open-webui" SMART_ROUTER_DIR="$ROOT_DIR/data/smart-router" N8N_DIR="$ROOT_DIR/data/n8n" CADDY_DIR="$ROOT_DIR/data/caddy" HERMES_DASHBOARD_ACCESS_FILE="$ROOT_DIR/data/stack-secrets/hermes-dashboard-access.env" DRY_RUN=false NO_START=false CONTENT_RECONFIGURE=false MEDIA_RECONFIGURE=false for arg in "$@"; do case "$arg" in --dry-run) DRY_RUN=true ;; --no-start) NO_START=true ;; --content-reconfigure) CONTENT_RECONFIGURE=true ;; --media-reconfigure) MEDIA_RECONFIGURE=true ;; -h|--help) printf '%s\n' "Usage: ./install.sh [--dry-run] [--no-start] [--content-reconfigure] [--media-reconfigure]" exit 0 ;; *) printf 'Unknown option: %s\n' "$arg" >&2; exit 2 ;; esac done info() { printf '\033[1;34m[INFO]\033[0m %s\n' "$*"; } ok() { printf '\033[1;32m[ OK ]\033[0m %s\n' "$*"; } warn() { printf '\033[1;33m[WARN]\033[0m %s\n' "$*"; } die() { printf '\033[1;31m[FAIL]\033[0m %s\n' "$*" >&2; exit 1; } prompt() { local label="$1" default="${2-}" value if [[ -n "$default" ]]; then read -r -p "$label [$default]: " value printf '%s' "${value:-$default}" else read -r -p "$label: " value printf '%s' "$value" fi } prompt_secret() { local label="$1" allow_empty="${2:-false}" value while true; do read -r -s -p "$label: " value printf '\n' >&2 if [[ -n "$value" || "$allow_empty" == true ]]; then printf '%s' "$value" return fi warn "A value is required." done } confirm() { local label="$1" default="${2:-n}" answer suffix if [[ "$default" == y ]]; then suffix="Y/n"; else suffix="y/N"; fi read -r -p "$label [$suffix]: " answer answer="${answer:-$default}" [[ "$answer" =~ ^[Yy]$ ]] } random_hex() { if command -v openssl >/dev/null 2>&1; then openssl rand -hex "${1:-32}" else od -An -N "${1:-32}" -tx1 /dev/urandom | tr -d ' \n' fi } hash_hermes_dashboard_password() { python3 -c ' import base64, hashlib, secrets, sys password = sys.stdin.read().encode() salt = secrets.token_bytes(16) digest = hashlib.scrypt(password, salt=salt, n=2**14, r=8, p=1, dklen=32, maxmem=0) print("scrypt$16384$8$1$" + base64.b64encode(salt).decode() + "$" + base64.b64encode(digest).decode()) ' } dotenv_quote() { local value="$1" value="${value//\\/\\\\}" value="${value//\"/\\\"}" value="${value//\$/\$\$}" printf '"%s"' "$value" } # Compose treats single-quoted .env values literally. The dashboard scrypt hash # contains dollar separators, so this avoids interpolation without multiplying # escapes every time the installer preserves an existing hash. dotenv_literal_quote() { local value="$1" [[ "$value" != *"'"* ]] || die "A literal .env value contains an unsupported single quote." printf "'%s'" "$value" } yaml_quote() { local value="$1" value="${value//\'/\'\'}" printf "'%s'" "$value" } # Emits the installer-owned mcp_servers block, or nothing when the selected # bridge is off or still waiting for an Instance-level token. Hermes resolves # the mode-specific values from data/hermes/.env at connect time, so neither # bearer token lands in the world-readable config.yaml. render_mcp_block() { [[ "${install_n8n_mcp:-false}" == true ]] || return 0 printf 'mcp_servers:\n' render_mcp_entry } render_mcp_entry() { [[ "${install_n8n_mcp:-false}" == true ]] || return 0 local url_var token_var case "${n8n_mcp_mode:-off}" in instance) url_var='N8N_INSTANCE_MCP_URL' token_var='N8N_INSTANCE_MCP_TOKEN' ;; trigger) url_var='N8N_TRIGGER_MCP_URL' token_var='N8N_TRIGGER_MCP_TOKEN' ;; *) return 0 ;; esac printf '%s\n' \ ' # >>> hermes-stack n8n mcp (managed) >>>' \ ' n8n:' \ " url: \"\${$url_var}\"" \ ' headers:' \ " Authorization: \"Bearer \${$token_var}\"" \ ' # <<< hermes-stack n8n mcp (managed) <<<' } ensure_hermes_policy_config() { local file="$1" tmp next [[ -f "$file" ]] || return 0 tmp="$(mktemp "$file.tmp.XXXXXX")" cp "$file" "$tmp" command -v python3 >/dev/null 2>&1 || die "python3 is required to reconcile Hermes policy settings." next="$(mktemp "$file.tmp.XXXXXX")" python3 - "$tmp" > "$next" <<'PY' import re import sys path = sys.argv[1] lines = open(path, encoding="utf-8").read().splitlines() def section_bounds(name): starts = [i for i, line in enumerate(lines) if re.fullmatch(rf"{re.escape(name)}:\s*", line)] if len(starts) > 1: raise SystemExit(f"Duplicate top-level {name}: sections are unsafe; restore the backup and merge them first.") if not starts: return None start = starts[0] end = next((i for i in range(start + 1, len(lines)) if re.match(r"^[^\s#]", lines[i])), len(lines)) return start, end def enforce_fields(name, required): bounds = section_bounds(name) if bounds is None: if lines and lines[-1] != "": lines.append("") lines.append(f"{name}:") lines.extend(f" {key}: {value}" for key, value in required.items()) return start, end = bounds found = set() for i in range(start + 1, end): match = re.match(r"^ ([A-Za-z0-9_-]+):", lines[i]) if match and match.group(1) in required: key = match.group(1) lines[i] = f" {key}: {required[key]}" found.add(key) insert_at = end for key, value in required.items(): if key not in found: lines.insert(insert_at, f" {key}: {value}") insert_at += 1 # Upstream terminal/code_execution enablement is a local operator decision made # with ./manage.sh set-upstream-terminal, so the installer neither forces them # off nor turns them on: it leaves whatever disabled_toolsets already says. # Still validate that the top-level agent map is unambiguous before any rewrite. section_bounds("agent") def enable_plugin(name): bounds = section_bounds("plugins") if bounds is None: if lines and lines[-1] != "": lines.append("") lines.extend(["plugins:", " enabled:", f" - {name}"]) return start, end = bounds if any(re.fullmatch(rf"\s*-\s*{re.escape(name)}\s*", line) for line in lines[start + 1:end]): return enabled = next((i for i in range(start + 1, end) if re.fullmatch(r" enabled:\s*", lines[i])), None) if enabled is None: lines[end:end] = [" enabled:", f" - {name}"] else: lines.insert(enabled + 1, f" - {name}") enable_plugin("stack-package-policy") enable_plugin("stack-execution-policy") enforce_fields("approvals", {"mode": "manual", "timeout": "300", "cron_mode": "deny"}) enforce_fields("skills", {"write_approval": "true"}) print("\n".join(lines) + "\n", end="") PY mv "$next" "$tmp" chmod 640 "$tmp" chown "$hermes_uid:$hermes_gid" "$tmp" mv "$tmp" "$file" } enable_hermes_telegram_policy_toolsets() { [[ "${install_hermes:-false}" == true && -n "${telegram_token:-}" ]] || return 0 info "Enabling stack policy toolsets for Telegram..." local attempt for attempt in {1..30}; do if "${DOCKER[@]}" compose -f "$ROOT_DIR/docker-compose.yml" --env-file "$ENV_FILE" exec -T hermes sh -lc ' cd /opt/hermes /opt/hermes/.venv/bin/hermes tools enable stack-execution-policy --platform telegram >/dev/null \ && /opt/hermes/.venv/bin/hermes tools enable stack-package-policy --platform telegram >/dev/null \ && tools="$(/opt/hermes/.venv/bin/hermes tools list --platform telegram 2>/dev/null)" \ && printf "%s\\n" "$tools" | grep -Eq "enabled[[:space:]]+stack-execution-policy([[:space:]]|$)" \ && printf "%s\\n" "$tools" | grep -Eq "enabled[[:space:]]+stack-package-policy([[:space:]]|$)" ' >/dev/null 2>&1; then "${DOCKER[@]}" compose -f "$ROOT_DIR/docker-compose.yml" --env-file "$ENV_FILE" restart hermes >/dev/null ok "Hermes Telegram stack policy toolsets enabled." return 0 fi sleep 2 done die "Could not enable stack-execution-policy and stack-package-policy for Telegram." } valid_port() { [[ "$1" =~ ^[0-9]+$ ]] && (( 1 <= 10#$1 && 10#$1 <= 65535 )) } prompt_port() { local label="$1" default="$2" value while true; do value="$(prompt "$label" "$default")" if valid_port "$value"; then printf '%s' "$value" return 0 fi warn "Enter a numeric port from 1 to 65535." >&2 done } prompt_optional_integer() { local label="$1" value while true; do value="$(prompt "$label")" if [[ -z "$value" || "$value" =~ ^-?[0-9]+$ ]]; then printf '%s' "$value" return 0 fi warn "Enter a numeric ID, or press Enter to skip." >&2 done } valid_bind_ip() { local ip="$1" octet local -a octets [[ "$ip" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]] || return 1 IFS=. read -r -a octets <<< "$ip" for octet in "${octets[@]}"; do [[ "$octet" =~ ^[0-9]+$ ]] && ((10#$octet <= 255)) || return 1 done } private_ipv4() { local first second IFS=. read -r first second _ <<< "$1" (( 10#$first == 10 \ || (10#$first == 172 && 16 <= 10#$second && 10#$second <= 31) \ || (10#$first == 192 && 10#$second == 168) )) } detect_lan_ipv4() { local candidate if [[ -n "${CONTENT_MANAGER_LAN_IP:-}" ]]; then valid_bind_ip "$CONTENT_MANAGER_LAN_IP" \ || die "CONTENT_MANAGER_LAN_IP is not a valid IPv4 address: $CONTENT_MANAGER_LAN_IP" printf '%s' "$CONTENT_MANAGER_LAN_IP" return 0 fi if command -v ip >/dev/null 2>&1; then candidate="$(ip -4 route get 1.1.1.1 2>/dev/null \ | awk '{ for (i=1; i<=NF; i++) if ($i == "src") { print $(i+1); exit } }')" if valid_bind_ip "$candidate" && private_ipv4 "$candidate"; then printf '%s' "$candidate" return 0 fi while read -r candidate; do if valid_bind_ip "$candidate" && private_ipv4 "$candidate"; then printf '%s' "$candidate" return 0 fi done < <(ip -o -4 addr show scope global 2>/dev/null | awk '{ sub(/\/.*/, "", $4); print $4 }') fi if command -v hostname >/dev/null 2>&1; then for candidate in $(hostname -I 2>/dev/null); do if valid_bind_ip "$candidate" && private_ipv4 "$candidate"; then printf '%s' "$candidate" return 0 fi done fi return 1 } suggested_bind_ip() { local current="$1" if [[ -n "$lan_ip" && "$current" == 127.0.0.1 ]]; then printf '%s' "$lan_ip" else printf '%s' "$current" fi } service_url_host() { local bind_ip="$1" if [[ "$bind_ip" == 127.0.0.1 ]]; then printf 'localhost' elif [[ "$bind_ip" == 0.0.0.0 ]]; then printf '%s' "${lan_ip:-localhost}" else printf '%s' "$bind_ip" fi } prompt_bind_ip() { local label="$1" default="$2" value while true; do value="$(prompt "$label" "$default")" if valid_bind_ip "$value"; then printf '%s' "$value" return 0 fi warn "Enter an IPv4 address such as 127.0.0.1, 192.168.1.10, or 0.0.0.0." >&2 done } valid_ids() { [[ "$1" =~ ^[0-9]+(,[0-9]+)*$ ]] } valid_domain() { [[ ${#1} -le 253 && "$1" =~ ^([A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?\.)+[A-Za-z]{2,63}$ ]] } prompt_domain() { local label="$1" value while true; do value="$(prompt "$label")" value="${value,,}" if valid_domain "$value"; then printf '%s' "$value" return 0 fi warn "Enter a domain only, such as chat.example.com, without https://, a port, or a path." >&2 done } prompt_domain_optional() { local label="$1" default="${2-}" value while true; do value="$(prompt "$label" "$default")" value="${value,,}" if [[ -z "$value" ]]; then printf '%s' "" return 0 fi if valid_domain "$value"; then printf '%s' "$value" return 0 fi warn "Enter a domain only, such as panel.example.com, or press Enter to skip." >&2 done } # One base domain drives every published hostname: a service becomes # ., so the suggestions match the operator's real zone. # An empty value keeps example.com placeholders in the printed checklists. detected_base_domain() { local url host for url in \ "$(existing_env_value S3_PUBLIC_BASE_URL)" \ "$(existing_env_value S3_PUBLIC_CONSOLE_URL)" \ "$(existing_env_value INSTAGRAM_MEDIA_PUBLIC_BASE_URL)"; do [[ -n "$url" ]] || continue host="${url#*//}" host="${host%%/*}" host="${host#*.}" if [[ "$host" == *.* ]]; then printf '%s' "$host" return 0 fi done printf '%s' "" } suggest_host() { local service="$1" [[ -n "${base_domain:-}" ]] || return 0 printf '%s.%s' "$service" "$base_domain" } # Same as suggest_host, but always returns a host: the printed checklists stay # readable when the operator has not named a domain yet. suggest_host_or_example() { local service="$1" host host="$(suggest_host "$service")" printf '%s' "${host:-$service.example.com}" } # A reverse proxy on another host publishes a service with two manual steps: # a DNS record and a proxy site block. Print both where the installer asks for # the decision so the checklist does not have to be found later in the docs. print_public_route() { local host="$1" target="$2" note="${3-}" kind="${4-}" printf ' Public route checklist for %s:\n' "$host" printf ' DNS: create an A record %s -> router public IP in ArvanCloud\n' "$host" printf ' (keep the CDN proxy toggle off until the router serves its own certificate).\n' printf ' Proxy: in the Caddy container on the router add:\n' printf ' %s {\n encode zstd gzip\n' "$host" if [[ "$kind" == console ]]; then printf ' @console_root {\n method GET\n path /\n }\n' printf ' redir @console_root /rustfs/console/ 302\n' fi printf ' reverse_proxy %s\n }\n' "$target" [[ -n "$note" ]] && printf ' Note: %s\n' "$note" } # The RustFS console is a browser app that signs in with a signed POST to the # site root of whatever host serves it, so the reverse proxy in front of it must # leave that request alone: a redirect on "/" that covers every method answers # the POST and the browser never receives a session. The block below prints a # route that keeps the friendly root redirect for GET only. ask_console_route() { local scheme="$1" current="${2-}" host default console_public_url="" default="${current#*//}" default="${default%%/*}" default="${default:-$(suggest_host console)}" default="${default:-console.example.com}" if ! confirm "Publish the RustFS console through that proxy as well?" n; then printf '%s\n' "Console target for that proxy: http://$rustfs_console_bind:$rustfs_console_port/rustfs/console/" return 0 fi host="$(prompt "Public console host name" "$default")" host="${host#*//}" host="${host%%/*}" if [[ -z "$host" ]]; then printf '%s\n' 'No console host recorded; publish it later with ./manage.sh s3-guide.' return 0 fi print_public_route "$host" "$rustfs_console_bind:$rustfs_console_port" \ 'Keep the console off the public internet unless you accept the risk. The redirect on / must stay GET-only: sign-in sends a signed POST to / and a blanket redirect drops it.' \ console printf ' Check: curl -sS -o /dev/null -w "%%{http_code}\\n" %s://%s/rustfs/console/\n' "$scheme" "$host" printf ' Sign in with the keys from ./manage.sh s3-keys --show-secrets.\n' console_public_url="$scheme://$host/rustfs/console" } install_docker() { command -v curl >/dev/null 2>&1 || die "curl is required to install Docker." local installer installer="$(mktemp)" curl -fsSL https://get.docker.com -o "$installer" if [[ "$(id -u)" -eq 0 ]]; then sh "$installer" elif command -v sudo >/dev/null 2>&1; then sudo sh "$installer" else rm -f "$installer" die "Docker installation requires root or sudo." fi rm -f "$installer" } detect_docker() { if ! command -v docker >/dev/null 2>&1; then confirm "Docker is not installed. Install it using Docker's official installer?" y \ || die "Install Docker Engine and the Compose plugin, then rerun this script." install_docker fi docker compose version >/dev/null 2>&1 \ || die "The Docker Compose plugin is required (the command must be 'docker compose')." if docker info >/dev/null 2>&1; then DOCKER=(docker) elif command -v sudo >/dev/null 2>&1 && sudo docker info >/dev/null 2>&1; then DOCKER=(sudo docker) else die "Docker exists but this user cannot access the daemon. Add the user to the docker group or run with sudo." fi } backup_existing() { local stamp stamp="$(date +%Y%m%d-%H%M%S)" [[ -f "$ENV_FILE" ]] && cp -p "$ENV_FILE" "$ENV_FILE.backup-$stamp" [[ -f "$HERMES_DIR/.env" ]] && cp -p "$HERMES_DIR/.env" "$HERMES_DIR/.env.backup-$stamp" [[ -f "$HERMES_DIR/config.yaml" ]] && cp -p "$HERMES_DIR/config.yaml" "$HERMES_DIR/config.yaml.backup-$stamp" [[ -f "$CADDY_DIR/Caddyfile" ]] && cp -p "$CADDY_DIR/Caddyfile" "$CADDY_DIR/Caddyfile.backup-$stamp" return 0 } read_unique_env_value() { local file="$1" key="$2" value="" count [[ -f "$file" ]] || { printf '%s' "$value"; return 0; } count="$(grep -c "^${key}=" "$file" || true)" (( count <= 1 )) || die "Duplicate ${key} entries in ${file#$ROOT_DIR/} are unsafe; restore the backup and keep one value." value="$(sed -n "s/^${key}=//p" "$file")" if [[ "$value" == \"*\" || "$value" == \'*\' ]]; then value="${value:1:${#value}-2}" fi printf '%s' "$value" } existing_env_value() { read_unique_env_value "$ENV_FILE" "$1" } existing_hermes_env_value() { read_unique_env_value "$HERMES_DIR/.env" "$1" } replace_env_value() { local file="$1" key="$2" value="$3" tmp tmp="$(mktemp "$file.tmp.XXXXXX")" awk -v key="$key" -v value="$value" ' BEGIN { changed=0 } index($0, key "=") == 1 { print key "=" value; changed=1; next } { print } END { if (!changed) print key "=" value } ' "$file" > "$tmp" chmod --reference="$file" "$tmp" mv "$tmp" "$file" } profile_enabled() { local profile="$1" configured configured="$(existing_env_value COMPOSE_PROFILES)" [[ ",$configured," == *",$profile,"* ]] } router_backend_label() { if [[ "$install_omniroute" == true ]]; then printf 'OmniRoute' else printf '9router' fi } choose_router_backend() { local default_label="${1:-9router}" label printf '%s\n' 'Router backend:' printf '%s\n' ' 1) 9router - single-port OpenAI-compatible gateway with automatic key provisioning' printf '%s\n' ' 2) OmniRoute - dashboard on 20128 and OpenAI-compatible API on 20129; auto/best-* aliases' while true; do label="$(prompt "Choose router backend" "$default_label")" case "$label" in 9router|1) install_nine=true; return 0 ;; omniroute|2) install_omniroute=true; return 0 ;; *) warn "Choose 9router or OmniRoute." >&2 ;; esac done } choose_s3_backend() { local default_label="${1:-off}" label # The menu goes to stderr: callers capture stdout for the selected backend. printf '%s\n' 'Object storage backend:' >&2 printf '%s\n' ' 1) RustFS - run the bundled S3-compatible server inside this stack' >&2 printf '%s\n' ' 2) External - point the stack at another S3 provider (AWS S3, Cloudflare R2, Backblaze B2, MinIO, Arvan S3, ...)' >&2 printf '%s\n' ' 3) Off - services keep their local storage' >&2 while true; do case "$default_label" in rustfs) label="$(prompt "Choose object storage" "1")" ;; external) label="$(prompt "Choose object storage" "2")" ;; *) label="$(prompt "Choose object storage" "3")" ;; esac case "$label" in rustfs|1) printf 'rustfs'; return 0 ;; external|2) printf 'external'; return 0 ;; off|none|3) printf 'off'; return 0 ;; *) warn "Choose 1 (RustFS), 2 (external), or 3 (off)." >&2 ;; esac done } printf '\nContent Manager - Easy Installer\n' printf '%s\n' '================================' printf '%s\n' 'Hermes Linux Stack v0.5.9 platform extended with the Content Manager daily content pipeline layer.' lan_ip="$(detect_lan_ipv4 || true)" if [[ -n "$lan_ip" ]]; then info "Detected LAN IPv4 address: $lan_ip (press Enter to use it when suggested)." else warn "No private LAN IPv4 address was detected; localhost will be suggested." fi configure_nine=false configure_omniroute=false configure_hermes=false configure_webui=false configure_smart_router=false configure_n8n=false configure_caddy=false configure_content=false configure_media=false existing_install=false smart_router_was_enabled=false n8n_was_enabled=false content_was_enabled=false media_was_enabled=false change_bind_ips=false n8n_ready=false install_omniroute=false omniroute_was_enabled=false install_rustfs=false s3_change=false s3_choice=off s3_backend_configured=off rustfs_access_key="" rustfs_secret_key="" nine_was_enabled=false router_backend_changed=false if [[ "$CONTENT_RECONFIGURE" == true && ! -f "$ENV_FILE" ]]; then die "--content-reconfigure requires an existing installation; run ./install.sh once to provision it." fi if [[ "$MEDIA_RECONFIGURE" == true && ! -f "$ENV_FILE" ]]; then die "--media-reconfigure requires an existing installation; run ./install.sh once to provision it." fi if [[ -f "$ENV_FILE" ]]; then existing_install=true install_nine=false; profile_enabled 9router && install_nine=true install_omniroute=false; profile_enabled omniroute && install_omniroute=true if [[ "$install_nine" == true && "$install_omniroute" == true ]]; then warn "Both router backends are enabled in COMPOSE_PROFILES; keeping 9router and disabling OmniRoute. Use this wizard again to switch backends." omniroute_was_enabled=true install_omniroute=false fi nine_was_enabled="$install_nine" [[ "$install_omniroute" == true ]] && omniroute_was_enabled=true install_hermes=false; profile_enabled hermes && install_hermes=true install_webui=false; profile_enabled open-webui && install_webui=true install_smart_router=false; profile_enabled smart-router && install_smart_router=true smart_router_was_enabled="$install_smart_router" install_n8n=false; profile_enabled n8n && install_n8n=true n8n_was_enabled="$install_n8n" install_content=false; profile_enabled content && install_content=true content_was_enabled="$install_content" install_media=false; profile_enabled media && install_media=true media_was_enabled="$install_media" install_caddy=false; profile_enabled caddy && install_caddy=true install_panel=false; profile_enabled panel && install_panel=true install_rustfs=false; profile_enabled rustfs && install_rustfs=true s3_backend_configured="$(existing_env_value S3_STORAGE_BACKEND)" s3_backend_configured="${s3_backend_configured:-off}" printf 'Existing components: %s\n' "$(existing_env_value COMPOSE_PROFILES)" printf '%s\n' 'The wizard keeps existing components, secrets, and data by default.' if [[ "$CONTENT_RECONFIGURE" == true ]]; then printf '%s\n' 'Content-focused reconfigure: existing components, data, and bind IPs are preserved.' if [[ "$install_content" == true ]]; then configure_content=true else install_content=true configure_content=true info "The Content Bot profile is not enabled yet; this run enables it." fi elif [[ "$MEDIA_RECONFIGURE" == true ]]; then printf '%s\n' 'Media Studio reconfigure: existing components, data, and bind IPs are preserved.' if [[ "$install_media" == true ]]; then configure_media=true else install_media=true configure_media=true info "The Media Studio profile is not enabled yet; this run enables it." fi else if [[ "$install_nine" == true ]]; then if confirm "Reconfigure existing 9router settings?" n; then configure_nine=true elif confirm "Switch the router backend from 9router to OmniRoute?" n; then install_nine=false install_omniroute=true configure_omniroute=true router_backend_changed=true fi elif [[ "$install_omniroute" == true ]]; then if confirm "Reconfigure existing OmniRoute settings?" n; then configure_omniroute=true elif confirm "Switch the router backend from OmniRoute to 9router?" n; then install_omniroute=false install_nine=true configure_nine=true router_backend_changed=true fi elif confirm "Add a router backend (9router or OmniRoute)?" n; then choose_router_backend [[ "$install_nine" == true ]] && configure_nine=true [[ "$install_omniroute" == true ]] && configure_omniroute=true fi if [[ "$install_hermes" == true ]]; then confirm "Reconfigure existing Hermes Agent settings?" n && configure_hermes=true elif confirm "Add Hermes Agent?" n; then install_hermes=true; configure_hermes=true fi if [[ "$install_webui" == true ]]; then confirm "Reconfigure existing Open WebUI settings?" n && configure_webui=true elif confirm "Add Open WebUI?" n; then install_webui=true; configure_webui=true fi if [[ "$install_smart_router" == true ]]; then if ! confirm "Keep the Hermes Smart Router enabled?" y; then install_smart_router=false configure_smart_router=true elif confirm "Reconfigure existing Smart Router settings?" n; then configure_smart_router=true fi elif [[ "$install_hermes" == true && ( "$install_nine" == true || "$install_omniroute" == true ) ]] \ && confirm "Enable Hermes Smart Router v0.5.9 (recommended)?" y; then install_smart_router=true configure_smart_router=true fi if [[ "$router_backend_changed" == true ]]; then if [[ "$install_smart_router" == true ]]; then configure_smart_router=true info "The router backend changed; Smart Router upstream settings will be updated for $(router_backend_label)." elif [[ "$install_hermes" == true ]]; then configure_hermes=true info "The router backend changed; Hermes provider settings will be updated for $(router_backend_label)." fi fi if [[ "$install_n8n" == true ]]; then if ! confirm "Keep n8n workflow automation enabled?" y; then install_n8n=false configure_n8n=true elif confirm "Reconfigure existing n8n settings?" n; then configure_n8n=true fi elif confirm "Add optional n8n workflow automation?" n; then install_n8n=true configure_n8n=true fi if [[ "$install_content" == true ]]; then if ! confirm "Keep the Content Bot (Telegram approve/publish bot) enabled?" y; then install_content=false configure_content=true elif confirm "Reconfigure Content Bot settings?" n; then configure_content=true fi elif confirm "Add the Content Bot (Telegram approve/publish bot; external OpenAI-compatible API when no router is installed)?" n; then install_content=true configure_content=true fi if [[ "$install_media" == true ]]; then if ! confirm "Keep Media Studio (browser/API media generation worker) enabled?" y; then install_media=false configure_media=true elif confirm "Reconfigure Media Studio settings?" n; then configure_media=true fi elif confirm "Add Media Studio (optional media worker; api-image/api-video work without Google, flow-video/gemini-image need a signed-in Google session)?" n; then install_media=true configure_media=true fi if [[ "$install_panel" == true ]]; then confirm "Keep the operator panel (web console for status, configuration, logs and actions) enabled?" y \ || install_panel=false elif confirm "Add the operator panel (optional web console for stack status, configuration, logs and actions)?" \ "$([[ "$install_content" == true || "$install_media" == true ]] && printf y || printf n)"; then install_panel=true fi if [[ "$s3_backend_configured" != off ]]; then if confirm "Keep the configured object storage ($s3_backend_configured) for this stack?" y; then if [[ "$s3_backend_configured" == rustfs ]]; then install_rustfs=true else install_rustfs=false fi else s3_change=true s3_choice="$(choose_s3_backend off)" install_rustfs=false [[ "$s3_choice" == rustfs ]] && install_rustfs=true fi elif confirm "Add S3-compatible object storage (bundled RustFS server or an external endpoint)?" n; then s3_change=true s3_choice="$(choose_s3_backend off)" install_rustfs=false [[ "$s3_choice" == rustfs ]] && install_rustfs=true fi confirm "Change published container bind IPs only?" n && change_bind_ips=true fi else printf '%s\n' '1) Install a router backend and Hermes Agent (recommended; choose 9router or OmniRoute)' printf '%s\n' '2) Install a router backend only (choose 9router or OmniRoute)' printf '%s\n' '3) Install Hermes Agent only' printf '%s\n' '4) Install Open WebUI only' printf '%s\n' '5) Install Content Bot only (external OpenAI-compatible model API)' printf '%s\n' '6) Install Media Studio only (API image generation; Google Flow/Gemini optional)' printf '%s\n' '7) Install Content pipeline only (Content Bot + Media Studio, external model API)' while true; do selection="$(prompt "Choose installation" "1")" install_router=false case "$selection" in 1) install_router=true; install_hermes=true; install_webui=false; break ;; 2) install_router=true; install_hermes=false; install_webui=false; break ;; 3) install_nine=false; install_hermes=true; install_webui=false; break ;; 4) install_nine=false; install_hermes=false; install_webui=true; break ;; 5) install_nine=false; install_hermes=false; install_webui=false; break ;; 6) install_nine=false; install_hermes=false; install_webui=false; break ;; 7) install_nine=false; install_hermes=false; install_webui=false; break ;; *) warn "Choose 1, 2, 3, 4, 5, 6, or 7." >&2 ;; esac done if [[ "$install_router" == true ]]; then choose_router_backend [[ "$install_nine" == true ]] && configure_nine=true [[ "$install_omniroute" == true ]] && configure_omniroute=true else install_nine=false install_omniroute=false fi if [[ "$selection" != 4 && "$selection" != 5 && "$selection" != 6 && "$selection" != 7 ]] \ && confirm "Also install Open WebUI?" n; then install_webui=true fi configure_hermes="$install_hermes" configure_webui="$install_webui" install_smart_router=false if [[ "$install_hermes" == true && ( "$install_nine" == true || "$install_omniroute" == true ) ]] \ && confirm "Enable Hermes Smart Router v0.5.9 (recommended)?" y; then install_smart_router=true configure_smart_router=true fi install_n8n=false if [[ "$selection" != 5 && "$selection" != 6 && "$selection" != 7 ]] \ && confirm "Add optional n8n workflow automation?" n; then install_n8n=true configure_n8n=true fi install_content=false case "$selection" in 5|7) install_content=true configure_content=true ;; 6) if confirm "Also install the Content Bot (Telegram approve/publish bot; external OpenAI-compatible API when no router is installed) to drive this Media Studio?" n; then install_content=true configure_content=true fi ;; *) if [[ "$install_nine" == true || "$install_omniroute" == true || "$install_smart_router" == true ]] \ && confirm "Add the Content Bot (Telegram approve/publish bot with Approve/Reject buttons)?" \ "$([[ "$install_smart_router" == true ]] && printf y || printf n)"; then install_content=true configure_content=true fi ;; esac install_media=false case "$selection" in 6|7) install_media=true configure_media=true ;; 5) if confirm "Also install Media Studio (AI images, uploaded-photo branding, optional Google Flow video) for this Content Bot pipeline?" n; then install_media=true configure_media=true fi ;; *) if confirm "Add Media Studio (optional media worker; api-image/api-video work without Google, flow-video/gemini-image need a signed-in Google session)?" n; then install_media=true configure_media=true fi ;; esac install_caddy=false install_panel=false if confirm "Add the operator panel (optional web console for stack status, configuration, logs and actions)?" \ "$([[ "$install_content" == true || "$install_media" == true ]] && printf y || printf n)"; then install_panel=true fi install_rustfs=false if confirm "Add S3-compatible object storage (bundled RustFS server, or an external endpoint)?" \ "$([[ "$install_webui" == true ]] && printf y || printf n)"; then s3_change=true s3_choice="$(choose_s3_backend off)" [[ "$s3_choice" == rustfs ]] && install_rustfs=true fi fi profiles="" [[ "$install_nine" == true ]] && profiles="9router" [[ "$install_omniroute" == true ]] && profiles="omniroute" [[ "$install_smart_router" == true ]] && profiles="${profiles:+$profiles,}smart-router" [[ "$install_hermes" == true ]] && profiles="${profiles:+$profiles,}hermes" [[ "$install_webui" == true ]] && profiles="${profiles:+$profiles,}open-webui" [[ "$install_n8n" == true ]] && profiles="${profiles:+$profiles,}n8n" [[ "$install_content" == true ]] && profiles="${profiles:+$profiles,}content" [[ "$install_media" == true ]] && profiles="${profiles:+$profiles,}media" [[ "$install_panel" == true ]] && profiles="${profiles:+$profiles,}panel" [[ "$install_rustfs" == true ]] && profiles="${profiles:+$profiles,}rustfs" # The NotebookLM worker is enabled and disabled by ./manage.sh, not by this # wizard, so keep the profile when the stack is reconfigured. if profile_enabled notebooklm; then profiles="${profiles:+$profiles,}notebooklm" fi # Ask once for the zone the published services live in; every hostname the # wizard suggests below is derived from it, and .env keeps the answer so later # runs and ./manage.sh reuse the same domain. base_domain="$(existing_env_value STACK_BASE_DOMAIN)" if [[ -z "$base_domain" ]]; then base_domain="$(detected_base_domain)" fi if [[ "${configure_content:-false}" == true || "${configure_media:-false}" == true \ || "$install_panel" == true || "$install_rustfs" == true ]]; then printf '\nPublic domains\n' printf '%s\n' '--------------' printf '%s\n' 'Hostnames suggested below follow this base domain: a service is published' printf '%s\n' 'as ., for example s3.stack.example.com when the base' printf '%s\n' 'domain is stack.example.com. Leave it empty to keep example.com in the' printf '%s\n' 'printed checklists.' base_domain="$(prompt_domain_optional "Base domain for published services (Enter to skip)" "$base_domain")" fi # The operator panel binds to loopback by default. A reverse proxy on the # router (or another host) can front it over the LAN, so the wizard offers the # detected LAN address the same way it does for the Instagram media host. panel_port="$(existing_env_value PANEL_PORT)" panel_port="${panel_port:-8899}" panel_bind="$(existing_env_value PANEL_BIND_IP)" panel_cookie_secure="$(existing_env_value PANEL_COOKIE_SECURE)" panel_cookie_secure="${panel_cookie_secure:-false}" panel_public_host="" if [[ "$install_panel" == true ]]; then printf '\nOperator panel\n' printf '%s\n' '--------------' printf '%s\n' 'The console listens on 127.0.0.1 by default, so only this host can open' printf '%s\n' 'it. A reverse proxy on another host (for example the router) needs the' printf '%s\n' 'LAN address instead.' if confirm "Will a reverse proxy on another host publish the operator panel?" n; then while true; do panel_bind="$(prompt "Panel bind address (LAN address that proxy connects to)" "${panel_bind:-${lan_ip:-127.0.0.1}}")" valid_bind_ip "$panel_bind" && break warn "Enter an IPv4 address, for example 192.168.1.50." done panel_public_host="$(prompt_domain_optional "Public panel hostname (Enter to skip)" "$(suggest_host panel)")" if [[ -n "$panel_public_host" ]]; then print_public_route "$panel_public_host" "$panel_bind:$panel_port" \ 'PANEL_COOKIE_SECURE=true keeps login cookies on the HTTPS host (set below).' panel_cookie_secure=true else printf '%s\n' "Point that proxy at http://$panel_bind:$panel_port and keep the console behind its token." printf '%s\n' 'Set PANEL_COOKIE_SECURE=true in .env once the HTTPS hostname is in front.' fi elif [[ -n "$panel_bind" && "$panel_bind" != 127.0.0.1 ]]; then printf '%s\n' "Keeping the existing panel bind address $panel_bind:$panel_port." printf '%s\n' 'Public route reminder: DNS A record -> router public IP, then proxy it to' printf '%s\n' "http://$panel_bind:$panel_port with PANEL_COOKIE_SECURE=true (./manage.sh panel-status, docs/PANEL.md)." else panel_bind="127.0.0.1" fi fi # RustFS follows the same pattern: the API and console listen on loopback # unless a reverse proxy on another host publishes them, in which case the # wizard suggests the detected LAN address the proxy must connect to. rustfs_port="$(existing_env_value RUSTFS_PORT)" rustfs_port="${rustfs_port:-9000}" rustfs_console_port="$(existing_env_value RUSTFS_CONSOLE_PORT)" rustfs_console_port="${rustfs_console_port:-9001}" rustfs_bind="$(existing_env_value RUSTFS_BIND_IP)" rustfs_console_bind="$(existing_env_value RUSTFS_CONSOLE_BIND_IP)" rustfs_public_url="$(existing_env_value S3_PUBLIC_BASE_URL)" rustfs_public_console_url="$(existing_env_value S3_PUBLIC_CONSOLE_URL)" s3_endpoint="$(existing_env_value S3_ENDPOINT_URL)" s3_bucket="$(existing_env_value S3_BUCKET)" s3_bucket="${s3_bucket:-locallab}" s3_region="$(existing_env_value S3_REGION)" s3_region="${s3_region:-us-east-1}" s3_access_key="$(existing_env_value S3_ACCESS_KEY_ID)" s3_secret_key="$(existing_env_value S3_SECRET_ACCESS_KEY)" if [[ "$s3_change" == true && "$s3_choice" == rustfs ]]; then printf '\nRustFS object storage\n' printf '%s\n' '--------------------' printf '%s\n' 'The S3 API and the web console listen on 127.0.0.1 by default, so only' printf '%s\n' 'this host can reach them. A reverse proxy on another host (for example' printf '%s\n' 'the router) needs the LAN address instead.' if confirm "Will a reverse proxy on another host publish the S3 API or console?" n; then while true; do rustfs_bind="$(prompt "S3 API bind address (LAN address that proxy connects to)" "${rustfs_bind:-${lan_ip:-127.0.0.1}}")" valid_bind_ip "$rustfs_bind" && break warn "Enter an IPv4 address, for example 192.168.1.50." done while true; do rustfs_console_bind="$(prompt "RustFS console bind address" "${rustfs_console_bind:-${lan_ip:-127.0.0.1}}")" valid_bind_ip "$rustfs_console_bind" && break warn "Enter an IPv4 address, for example 192.168.1.50." done if confirm "Does that proxy terminate HTTPS for the object storage?" y; then rustfs_public_url="$(prompt "Public S3 origin" \ "${rustfs_public_url:-$(suggest_host_or_example s3)}")" if [[ -n "$rustfs_public_url" && "$rustfs_public_url" != http* ]]; then rustfs_public_url="https://$rustfs_public_url" fi print_public_route "${rustfs_public_url#*//}" "$rustfs_bind:$rustfs_port" \ 'S3_PUBLIC_BASE_URL in .env keeps this origin; later changes: ./manage.sh s3-guide' ask_console_route https "$rustfs_public_console_url" rustfs_public_console_url="$console_public_url" else print_public_route "$(suggest_host_or_example s3)" "$rustfs_bind:$rustfs_port" \ 'Use your real hostname; set S3_PUBLIC_BASE_URL (and S3_PUBLIC_CONSOLE_URL) in .env once it exists.' ask_console_route http "$rustfs_public_console_url" rustfs_public_console_url="$console_public_url" fi elif [[ -n "$rustfs_bind" && "$rustfs_bind" != 127.0.0.1 ]]; then printf '%s\n' "Keeping the existing object storage bind addresses $rustfs_bind:$rustfs_port and $rustfs_console_bind:$rustfs_console_port." printf '%s\n' 'Public route reminder: point the S3 hostname (and console hostname) at the' printf '%s\n' "router public IP and proxy them to http://$rustfs_bind:$rustfs_port / http://$rustfs_console_bind:$rustfs_console_port; full steps: ./manage.sh s3-guide." else rustfs_bind="127.0.0.1" rustfs_console_bind="127.0.0.1" fi fi if [[ "$s3_change" == true && "$s3_choice" == external ]]; then printf '\nExternal S3 endpoint\n' printf '%s\n' '--------------------' printf '%s\n' 'The stack stores uploads and generated files in this bucket.' while true; do s3_endpoint="$(prompt "S3 endpoint URL (for example https://s3.eu-central-1.amazonaws.com)" "$s3_endpoint")" [[ -n "$s3_endpoint" ]] && break warn "An endpoint URL is required." done s3_bucket="$(prompt "S3 bucket name" "$s3_bucket")" while [[ -z "$s3_bucket" ]]; do warn "A bucket name is required." s3_bucket="$(prompt "S3 bucket name" "locallab")" done s3_region="$(prompt "S3 region" "$s3_region")" s3_access_key="$(prompt_secret "S3 access key id")" s3_secret_key="$(prompt_secret "S3 secret access key")" fi mkdir -p "$HERMES_DIR" "$NINEROUTER_DIR" "$OMNIROUTE_DIR" "$OPENWEBUI_DIR" "$SMART_ROUTER_DIR" "$N8N_DIR" "$CADDY_DIR" \ "$ROOT_DIR/data/content-bot" "$ROOT_DIR/data/media-studio" "$ROOT_DIR/data/panel" mkdir -p "$HERMES_DIR/lazy-packages" "$HERMES_DIR/npm-packages" "$ROOT_DIR/data/stack-secrets" chmod 700 "$ROOT_DIR/data/stack-secrets" # Empty execution policy files keep the normal Compose profile renderable while # execution remains off. manage.sh fills them only after a local opt-in. execution_owner_uid="${SUDO_UID:-$(id -u)}" execution_owner_gid="${SUDO_GID:-$(id -g)}" execution_runtime_uid="$execution_owner_uid" execution_runtime_gid="$execution_owner_gid" execution_gateway_uid="$execution_owner_uid" execution_workspace_uid="$execution_owner_uid" execution_workspace_gid="$execution_owner_gid" if [[ "$(id -u)" == 0 ]]; then execution_runtime_uid=10003 execution_runtime_gid=10003 execution_gateway_uid="$(existing_env_value HERMES_UID)" execution_gateway_uid="${execution_gateway_uid:-10000}" [[ "$execution_gateway_uid" =~ ^[1-9][0-9]*$ ]] \ || die "HERMES_UID must be a non-root numeric uid." execution_workspace_uid=10002 execution_workspace_gid=10002 fi install -d -m 0700 -o "$execution_owner_uid" -g "$execution_owner_gid" \ "$ROOT_DIR/data/stack-secrets/execution" install -d -m 0700 -o "$execution_runtime_uid" -g "$execution_runtime_gid" \ "$ROOT_DIR/data/stack-secrets/execution/docker-state" \ "$ROOT_DIR/data/stack-secrets/execution/ssh-state" \ "$ROOT_DIR/data/stack-secrets/execution/approver-state" \ "$ROOT_DIR/data/stack-secrets/execution/ssh" for execution_file in control-secret users; do execution_path="$ROOT_DIR/data/stack-secrets/execution/$execution_file" [[ -e "$execution_path" ]] \ || install -m 0640 -o "$execution_gateway_uid" -g "$execution_runtime_gid" \ /dev/null "$execution_path" chown "$execution_gateway_uid:$execution_runtime_gid" "$execution_path" chmod 640 "$execution_path" done for execution_file in approval-request-secret approval-signing-key.pem approval-public-key.pem approval-bot-token ssh-profile-integrity-secret; do execution_path="$ROOT_DIR/data/stack-secrets/execution/$execution_file" [[ -e "$execution_path" ]] \ || install -m 0600 -o "$execution_runtime_uid" -g "$execution_runtime_gid" \ /dev/null "$execution_path" chown "$execution_runtime_uid:$execution_runtime_gid" "$execution_path" chmod 600 "$execution_path" done install -d -m 0700 -o "$execution_workspace_uid" -g "$execution_workspace_gid" \ "$ROOT_DIR/data/execution-workspace" # Root installations assign the isolated runtime IDs. Unprivileged dry runs and # fixtures retain their caller's IDs; manage.sh applies runtime ownership before # execution can be enabled on a deployed root-managed stack. chown "$execution_owner_uid:$execution_owner_gid" "$ROOT_DIR/data/stack-secrets" 2>/dev/null || true backup_existing nine_bind="$(existing_env_value NINEROUTER_BIND_IP)"; nine_bind="${nine_bind:-${lan_ip:-127.0.0.1}}" nine_port="$(existing_env_value NINEROUTER_PORT)"; nine_port="${nine_port:-20128}" nine_password="$(existing_env_value NINEROUTER_INITIAL_PASSWORD)"; nine_password="${nine_password:-not-installed}" nine_require_key="$(existing_env_value NINEROUTER_REQUIRE_API_KEY)"; nine_require_key="${nine_require_key:-false}" nine_cookie_secure="$(existing_env_value NINEROUTER_AUTH_COOKIE_SECURE)"; nine_cookie_secure="${nine_cookie_secure:-false}" nine_public_url="$(existing_env_value NINEROUTER_PUBLIC_BASE_URL)"; nine_public_url="${nine_public_url:-http://localhost:20128}" existing_nine_jwt="$(existing_env_value NINEROUTER_JWT_SECRET)" existing_nine_key_secret="$(existing_env_value NINEROUTER_API_KEY_SECRET)" existing_nine_salt="$(existing_env_value NINEROUTER_MACHINE_ID_SALT)" nine_jwt="${existing_nine_jwt:-$(random_hex 32)}" nine_key_secret="${existing_nine_key_secret:-$(random_hex 32)}" nine_salt="${existing_nine_salt:-$(random_hex 32)}" if [[ "$configure_nine" == true ]]; then printf '\n9router settings\n' printf '%s\n' '----------------' nine_bind="$(prompt_bind_ip "9router host bind address" "$(suggested_bind_ip "$nine_bind")")" nine_port="$(prompt_port "Dashboard/API port" "20128")" nine_password="$(prompt_secret "Initial 9router dashboard password")" nine_public_url="$(prompt "Public dashboard URL (or local URL)" "http://$(service_url_host "$nine_bind"):$nine_port")" if [[ "$nine_public_url" == https://* ]]; then nine_cookie_secure="true"; fi if confirm "Require a 9router Bearer API key on /v1 routes?" n; then nine_require_key="true" fi fi omni_bind="$(existing_env_value OMNIROUTE_BIND_IP)"; omni_bind="${omni_bind:-${lan_ip:-127.0.0.1}}" omni_port="$(existing_env_value OMNIROUTE_PORT)"; omni_port="${omni_port:-20128}" omni_api_bind="$(existing_env_value OMNIROUTE_API_BIND_IP)"; omni_api_bind="${omni_api_bind:-$omni_bind}" omni_api_port="$(existing_env_value OMNIROUTE_API_PORT)"; omni_api_port="${omni_api_port:-20129}" omni_password="$(existing_env_value OMNIROUTE_INITIAL_PASSWORD)"; omni_password="${omni_password:-not-installed}" omni_require_key="$(existing_env_value OMNIROUTE_REQUIRE_API_KEY)"; omni_require_key="${omni_require_key:-false}" omni_cookie_secure="$(existing_env_value OMNIROUTE_AUTH_COOKIE_SECURE)"; omni_cookie_secure="${omni_cookie_secure:-false}" omni_public_url="$(existing_env_value OMNIROUTE_PUBLIC_BASE_URL)"; omni_public_url="${omni_public_url:-http://localhost:20128}" existing_omni_jwt="$(existing_env_value OMNIROUTE_JWT_SECRET)" existing_omni_key_secret="$(existing_env_value OMNIROUTE_API_KEY_SECRET)" existing_omni_salt="$(existing_env_value OMNIROUTE_MACHINE_ID_SALT)" omni_jwt="${existing_omni_jwt:-$(random_hex 32)}" omni_key_secret="${existing_omni_key_secret:-$(random_hex 32)}" omni_salt="${existing_omni_salt:-$(random_hex 32)}" omni_management_key="$(existing_env_value OMNIROUTE_MANAGEMENT_API_KEY)" omni_storage_key="$(existing_env_value OMNIROUTE_STORAGE_ENCRYPTION_KEY)" omni_key_version="$(existing_env_value OMNIROUTE_STORAGE_ENCRYPTION_KEY_VERSION)"; omni_key_version="${omni_key_version:-v1}" omni_ws_secret="$(existing_env_value OMNIROUTE_WS_BRIDGE_SECRET)" omni_allow_key_reveal="$(existing_env_value OMNIROUTE_ALLOW_API_KEY_REVEAL)"; omni_allow_key_reveal="${omni_allow_key_reveal:-false}" omni_memory_mb="$(existing_env_value OMNIROUTE_MEMORY_MB)"; omni_memory_mb="${omni_memory_mb:-512}" if [[ "$configure_omniroute" == true ]]; then printf '\nOmniRoute settings\n' printf '%s\n' '------------------' omni_bind="$(prompt_bind_ip "OmniRoute dashboard host bind address" "$(suggested_bind_ip "$omni_bind")")" omni_port="$(prompt_port "OmniRoute dashboard port" "$omni_port")" omni_api_bind="$omni_bind" omni_api_port="$(prompt_port "OmniRoute OpenAI-compatible API port" "$omni_api_port")" omni_password="$(prompt_secret "Initial OmniRoute dashboard password")" omni_public_url="$(prompt "Public dashboard URL (or local URL)" "http://$(service_url_host "$omni_bind"):$omni_port")" if [[ "$omni_public_url" == https://* ]]; then omni_cookie_secure="true"; fi if confirm "Require an OmniRoute Bearer API key on /v1 routes?" n; then if [[ -n "$(existing_env_value SMART_ROUTER_UPSTREAM_API_KEY)" ]]; then omni_require_key="true" if [[ "$router_backend_changed" == true && "$install_smart_router" == true ]]; then warn "The previous backend key is cleared on a backend switch. After the stack starts, create an OmniRoute endpoint key and run ./manage.sh set-backend-api-key KEY before enabling Smart Router route mode." fi else omni_require_key="false" warn "No SMART_ROUTER_UPSTREAM_API_KEY exists yet. Keep OmniRoute API auth off for first boot, create a key in OmniRoute, then run ./manage.sh set-backend-api-key KEY and enable OMNIROUTE_REQUIRE_API_KEY=true." fi else omni_require_key="false" fi fi openwebui_bind="$(existing_env_value OPENWEBUI_BIND_IP)"; openwebui_bind="${openwebui_bind:-${lan_ip:-127.0.0.1}}" openwebui_port="$(existing_env_value OPENWEBUI_PORT)"; openwebui_port="${openwebui_port:-3000}" openwebui_url="$(existing_env_value OPENWEBUI_URL)"; openwebui_url="${openwebui_url:-http://localhost:3000}" existing_openwebui_secret="$(existing_env_value OPENWEBUI_SECRET_KEY)" openwebui_secret="${existing_openwebui_secret:-$(random_hex 32)}" openwebui_api_url="$(existing_env_value OPENWEBUI_OPENAI_BASE_URL)" if [[ -z "$openwebui_api_url" && "$install_omniroute" == true ]]; then openwebui_api_url="http://omniroute:20129/v1" elif [[ -z "$openwebui_api_url" ]]; then openwebui_api_url="http://nine-router:20128/v1" fi openwebui_api_key="$(existing_env_value OPENWEBUI_OPENAI_API_KEY)"; openwebui_api_key="${openwebui_api_key:-local-no-auth}" openwebui_signup="$(existing_env_value OPENWEBUI_ENABLE_SIGNUP)"; openwebui_signup="${openwebui_signup:-true}" # The current Hermes image requires an auth provider for non-loopback dashboard # binds. The installer always provisions Basic Auth when the dashboard is enabled, # so the same configuration works safely on localhost, a trusted LAN, or Caddy. hermes_bind="$(existing_env_value HERMES_BIND_IP)"; hermes_bind="${hermes_bind:-127.0.0.1}" hermes_api_port="$(existing_env_value HERMES_API_PORT)"; hermes_api_port="${hermes_api_port:-8642}" hermes_dashboard_port="$(existing_env_value HERMES_DASHBOARD_PORT)"; hermes_dashboard_port="${hermes_dashboard_port:-9119}" hermes_dashboard="$(existing_env_value HERMES_DASHBOARD)"; hermes_dashboard="${hermes_dashboard:-0}" hermes_dashboard_username="$(existing_env_value HERMES_DASHBOARD_BASIC_AUTH_USERNAME)" hermes_dashboard_password_hash="$(existing_env_value HERMES_DASHBOARD_BASIC_AUTH_PASSWORD_HASH)" hermes_dashboard_secret="$(existing_env_value HERMES_DASHBOARD_BASIC_AUTH_SECRET)" hermes_dashboard_password="" hermes_dashboard_credentials_created=false if [[ -f "$HERMES_DASHBOARD_ACCESS_FILE" ]]; then hermes_dashboard_password="$(read_unique_env_value "$HERMES_DASHBOARD_ACCESS_FILE" HERMES_DASHBOARD_PASSWORD)" fi provider_name="9router" provider_url="http://nine-router:20128/v1" provider_key="local-no-auth" model_name="ai" if [[ "$install_omniroute" == true ]]; then provider_name="OmniRoute" provider_url="http://omniroute:20129/v1" model_name="auto" fi telegram_token="$(existing_hermes_env_value TELEGRAM_BOT_TOKEN)" telegram_ids="$(existing_hermes_env_value TELEGRAM_ALLOWED_USERS)" telegram_home="$(existing_hermes_env_value TELEGRAM_HOME_CHANNEL)" api_enabled="$(existing_hermes_env_value API_SERVER_ENABLED)"; api_enabled="${api_enabled:-false}" api_key="$(existing_hermes_env_value API_SERVER_KEY)" smart_router_image_repository="$(existing_env_value SMART_ROUTER_IMAGE_REPOSITORY)"; smart_router_image_repository="${smart_router_image_repository:-afsharidevops/hermes-smart-router}" smart_router_image_tag="$(existing_env_value SMART_ROUTER_IMAGE_TAG)"; smart_router_image_tag="${smart_router_image_tag:-0.6.4}" smart_router_bind="$(existing_env_value SMART_ROUTER_BIND_IP)"; smart_router_bind="${smart_router_bind:-127.0.0.1}" smart_router_port="$(existing_env_value SMART_ROUTER_PORT)"; smart_router_port="${smart_router_port:-8787}" smart_router_mode="$(existing_env_value SMART_ROUTER_MODE)"; smart_router_mode="${smart_router_mode:-observe}" smart_router_policy="$(existing_env_value SMART_ROUTER_POLICY)"; smart_router_policy="${smart_router_policy:-heuristic}" smart_router_allow_tier_overrides="$(existing_env_value SMART_ROUTER_ALLOW_TIER_OVERRIDES)"; smart_router_allow_tier_overrides="${smart_router_allow_tier_overrides:-true}" smart_router_dashboard_enabled="$(existing_env_value SMART_ROUTER_DASHBOARD_ENABLED)"; smart_router_dashboard_enabled="${smart_router_dashboard_enabled:-true}" smart_router_control_plane_enabled="$(existing_env_value SMART_ROUTER_CONTROL_PLANE_ENABLED)"; smart_router_control_plane_enabled="${smart_router_control_plane_enabled:-true}" smart_router_require_auth="$(existing_env_value SMART_ROUTER_REQUIRE_AUTH)"; smart_router_require_auth="${smart_router_require_auth:-true}" smart_router_provider_health_enabled="$(existing_env_value SMART_ROUTER_PROVIDER_HEALTH_ENABLED)"; smart_router_provider_health_enabled="${smart_router_provider_health_enabled:-true}" smart_router_coding_model="$(existing_env_value SMART_ROUTER_CODING_MODEL)" smart_router_vision_model="$(existing_env_value SMART_ROUTER_VISION_MODEL)" smart_router_secret="$(existing_env_value SMART_ROUTER_HMAC_SECRET)"; smart_router_secret="${smart_router_secret:-$(random_hex 32)}" smart_router_fast_model="$(existing_env_value SMART_ROUTER_FAST_MODEL)" smart_router_standard_model="$(existing_env_value SMART_ROUTER_STANDARD_MODEL)" smart_router_strong_model="$(existing_env_value SMART_ROUTER_STRONG_MODEL)" smart_router_observe_model="$(existing_env_value SMART_ROUTER_OBSERVE_MODEL)" smart_router_fail_open_model="$(existing_env_value SMART_ROUTER_FAIL_OPEN_MODEL)" if [[ "$install_omniroute" == true ]]; then smart_router_fast_model="${smart_router_fast_model:-auto/best-fast}" smart_router_standard_model="${smart_router_standard_model:-auto/best-chat}" smart_router_strong_model="${smart_router_strong_model:-auto/best-reasoning}" smart_router_coding_model="${smart_router_coding_model:-auto/best-coding}" smart_router_vision_model="${smart_router_vision_model:-auto/best-vision}" smart_router_observe_model="${smart_router_observe_model:-auto/best-chat}" smart_router_fail_open_model="${smart_router_fail_open_model:-auto}" else smart_router_fast_model="${smart_router_fast_model:-combo-fast}" smart_router_standard_model="${smart_router_standard_model:-combo-standard}" smart_router_strong_model="${smart_router_strong_model:-combo-strong}" smart_router_coding_model="${smart_router_coding_model:-combo-strong}" smart_router_vision_model="${smart_router_vision_model:-combo-vision}" smart_router_observe_model="${smart_router_observe_model:-ai}" smart_router_fail_open_model="${smart_router_fail_open_model:-ai}" fi if [[ "$router_backend_changed" == true && "$install_smart_router" == true ]]; then # The model namespaces differ between backends (combo-* combos versus # OmniRoute auto/best-* aliases), so a backend switch resets route profiles # to the canonical defaults of the newly selected backend. if [[ "$install_omniroute" == true ]]; then smart_router_fast_model="auto/best-fast" smart_router_standard_model="auto/best-chat" smart_router_strong_model="auto/best-reasoning" smart_router_coding_model="auto/best-coding" smart_router_vision_model="auto/best-vision" smart_router_observe_model="auto/best-chat" smart_router_fail_open_model="auto" else smart_router_fast_model="combo-fast" smart_router_standard_model="combo-standard" smart_router_strong_model="combo-strong" smart_router_coding_model="combo-strong" smart_router_vision_model="combo-vision" smart_router_observe_model="ai" smart_router_fail_open_model="ai" fi fi smart_router_ttl="$(existing_env_value SMART_ROUTER_SESSION_TTL_SECONDS)"; smart_router_ttl="${smart_router_ttl:-2700}" smart_router_max_age="$(existing_env_value SMART_ROUTER_MAX_SESSION_AGE_SECONDS)"; smart_router_max_age="${smart_router_max_age:-43200}" smart_router_demotion="$(existing_env_value SMART_ROUTER_DEMOTION_TURNS)"; smart_router_demotion="${smart_router_demotion:-5}" smart_router_fast_tokens="$(existing_env_value SMART_ROUTER_FAST_MAX_TOKENS)"; smart_router_fast_tokens="${smart_router_fast_tokens:-1024}" smart_router_standard_tokens="$(existing_env_value SMART_ROUTER_STANDARD_MAX_TOKENS)"; smart_router_standard_tokens="${smart_router_standard_tokens:-4096}" smart_router_strong_tokens="$(existing_env_value SMART_ROUTER_STRONG_MAX_TOKENS)"; smart_router_strong_tokens="${smart_router_strong_tokens:-6144}" smart_router_connect_timeout="$(existing_env_value SMART_ROUTER_CONNECT_TIMEOUT_SECONDS)"; smart_router_connect_timeout="${smart_router_connect_timeout:-10}" smart_router_read_timeout="$(existing_env_value SMART_ROUTER_READ_TIMEOUT_SECONDS)"; smart_router_read_timeout="${smart_router_read_timeout:-600}" smart_router_max_bytes="$(existing_env_value SMART_ROUTER_MAX_REQUEST_BYTES)"; smart_router_max_bytes="${smart_router_max_bytes:-10485760}" nine_image="$(existing_env_value NINEROUTER_IMAGE)"; nine_image="${nine_image:-decolua/9router:latest}" hermes_image="$(existing_env_value HERMES_IMAGE)"; hermes_image="${hermes_image:-nousresearch/hermes-agent:latest}" openwebui_image="$(existing_env_value OPENWEBUI_IMAGE)"; openwebui_image="${openwebui_image:-ghcr.io/open-webui/open-webui:main}" caddy_image="$(existing_env_value CADDY_IMAGE)"; caddy_image="${caddy_image:-caddy:2-alpine}" execution_features="$(existing_env_value EXECUTION_FEATURES)" execution_generation="$(existing_env_value EXECUTION_POLICY_GENERATION)"; execution_generation="${execution_generation:-0}" execution_workspace_generation="$(existing_env_value EXECUTION_WORKSPACE_GENERATION)"; execution_workspace_generation="${execution_workspace_generation:-$execution_generation}" execution_broker_image="$(existing_env_value EXECUTION_BROKER_IMAGE)"; execution_broker_image="${execution_broker_image:-afsharidevops/hermes-execution-broker:0.1.3}" execution_sandbox_image="$(existing_env_value EXECUTION_SANDBOX_IMAGE)"; execution_sandbox_image="${execution_sandbox_image:-python:3.13.5-slim-bookworm@sha256:4c2cf9917bd1cbacc5e9b07320025bdb7cdf2df7b0ceaccb55e9dd7e30987419}" execution_run_as="$(existing_env_value EXECUTION_RUN_AS)"; execution_run_as="${execution_run_as:-$execution_runtime_uid:$execution_runtime_gid}" execution_docker_gid="$(existing_env_value EXECUTION_DOCKER_GID)"; execution_docker_gid="${execution_docker_gid:-$(stat -c %g /var/run/docker.sock 2>/dev/null || printf 65534)}" execution_workspace="$(existing_env_value EXECUTION_WORKSPACE_HOST_PATH)"; execution_workspace="${execution_workspace:-$ROOT_DIR/data/execution-workspace}" caddy_bind="$(existing_env_value CADDY_BIND_IP)"; caddy_bind="${caddy_bind:-0.0.0.0}" n8n_image="$(existing_env_value N8N_IMAGE)"; n8n_image="${n8n_image:-n8nio/n8n:latest}" n8n_bind="$(existing_env_value N8N_BIND_IP)"; n8n_bind="${n8n_bind:-${lan_ip:-127.0.0.1}}" n8n_port="$(existing_env_value N8N_PORT)"; n8n_port="${n8n_port:-5678}" n8n_hostname="$(existing_env_value N8N_HOSTNAME)"; n8n_hostname="${n8n_hostname:-localhost}" n8n_protocol="$(existing_env_value N8N_PROTOCOL)"; n8n_protocol="${n8n_protocol:-http}" n8n_public_url="$(existing_env_value N8N_PUBLIC_URL)"; n8n_public_url="${n8n_public_url:-http://localhost:5678}" n8n_secure_cookie="$(existing_env_value N8N_SECURE_COOKIE)"; n8n_secure_cookie="${n8n_secure_cookie:-false}" n8n_proxy_hops="$(existing_env_value N8N_PROXY_HOPS)"; n8n_proxy_hops="${n8n_proxy_hops:-0}" n8n_timezone="$(existing_env_value N8N_TIMEZONE)"; n8n_timezone="${n8n_timezone:-Asia/Tehran}" n8n_diagnostics="$(existing_env_value N8N_DIAGNOSTICS_ENABLED)"; n8n_diagnostics="${n8n_diagnostics:-false}" n8n_version_notifications="$(existing_env_value N8N_VERSION_NOTIFICATIONS_ENABLED)"; n8n_version_notifications="${n8n_version_notifications:-false}" # Never regenerate: rotating this key makes every stored n8n credential undecryptable. existing_n8n_key="$(existing_env_value N8N_ENCRYPTION_KEY)" n8n_encryption_key="${existing_n8n_key:-$(random_hex 32)}" # Existing generic variables came from the workflow-trigger-only implementation. # Infer that mode so upgrades preserve the old endpoint until the operator selects # another one, then migrate the secret into its unambiguous mode-specific key. legacy_n8n_mcp_token="$(existing_hermes_env_value N8N_MCP_TOKEN)" n8n_trigger_mcp_token="$(existing_hermes_env_value N8N_TRIGGER_MCP_TOKEN)" n8n_trigger_mcp_token="${n8n_trigger_mcp_token:-$legacy_n8n_mcp_token}" n8n_instance_mcp_token="$(existing_hermes_env_value N8N_INSTANCE_MCP_TOKEN)" n8n_mcp_mode="$(existing_env_value N8N_MCP_MODE)" if [[ -z "$n8n_mcp_mode" ]]; then if [[ -n "$n8n_trigger_mcp_token" ]]; then n8n_mcp_mode=trigger; else n8n_mcp_mode=off; fi fi [[ "$n8n_mcp_mode" == instance || "$n8n_mcp_mode" == trigger || "$n8n_mcp_mode" == off ]] \ || die "N8N_MCP_MODE must be instance, trigger, or off." n8n_mcp_previous_mode="$n8n_mcp_mode" n8n_mcp_was_enabled=false [[ "$n8n_mcp_mode" != off ]] && n8n_mcp_was_enabled=true install_n8n_mcp=false case "$n8n_mcp_mode" in trigger) [[ -n "$n8n_trigger_mcp_token" ]] && install_n8n_mcp=true ;; instance) [[ -n "$n8n_instance_mcp_token" ]] && install_n8n_mcp=true ;; esac if [[ "$configure_smart_router" == true && "$install_smart_router" == true ]]; then printf '\nHermes Smart Router v0.5.9 settings\n' printf '%s\n' '-----------------------------------' printf '%s\n' 'Smart routing applies to model=auto. Content Bot defaults to auto-strong, so tier aliases are recommended for local trusted stacks.' printf '%s\n' 'Explicit upstream model names pass through without automatic tier selection.' printf '%s\n' '' printf '%s\n' 'Router modes:' printf '%s\n' ' observe - evaluate auto requests and record decisions, but dispatch through SMART_ROUTER_OBSERVE_MODEL (recommended first)' printf '%s\n' ' route - apply Smart Router decisions/profiles to auto requests; explicit model names still pass through' smart_router_bind="$(prompt_bind_ip "Smart Router bind address" "$smart_router_bind")" smart_router_port="$(prompt_port "Smart Router HTTP/API/dashboard/control-plane port" "$smart_router_port")" smart_router_mode="$(prompt "Initial mode: observe or route" "$smart_router_mode")" [[ "$smart_router_mode" == observe || "$smart_router_mode" == route ]] \ || die "Smart Router mode must be observe or route." printf '%s\n' 'Routing policies: heuristic (built-in/default), calibrated (requires calibrated.json), learned (requires learned model artifacts).' smart_router_policy="$(prompt "Routing policy: heuristic, calibrated, or learned" "$smart_router_policy")" [[ "$smart_router_policy" == heuristic || "$smart_router_policy" == calibrated || "$smart_router_policy" == learned ]] \ || die "Smart Router policy must be heuristic, calibrated, or learned." if [[ "$smart_router_policy" == calibrated && ! -s "$ROOT_DIR/smart-router/policy/calibrated.json" ]]; then die "calibrated policy selected but smart-router/policy/calibrated.json is missing." fi if [[ "$smart_router_policy" == learned && ! -s "$ROOT_DIR/smart-router/policy/learned-v4.joblib" ]]; then warn "learned policy selected but smart-router/policy/learned-v4.joblib is currently missing; create/train the artifact before starting route mode." fi if confirm "Expose tier override aliases auto-fast/auto-standard/auto-strong?" "$([[ "$smart_router_allow_tier_overrides" == true ]] && printf y || printf n)"; then smart_router_allow_tier_overrides=true else smart_router_allow_tier_overrides=false fi if confirm "Enable the Smart Router telemetry dashboard (/dashboard)?" "$([[ "$smart_router_dashboard_enabled" == true ]] && printf y || printf n)"; then smart_router_dashboard_enabled=true; else smart_router_dashboard_enabled=false; fi if confirm "Enable the v0.5.9 Hermes Operations Center (/control)?" "$([[ "$smart_router_control_plane_enabled" == true ]] && printf y || printf n)"; then smart_router_control_plane_enabled=true; else smart_router_control_plane_enabled=false; fi if [[ "$smart_router_control_plane_enabled" == true ]]; then if confirm "Require authentication for Smart Router API/control-plane requests?" "$([[ "$smart_router_require_auth" == true ]] && printf y || printf n)"; then smart_router_require_auth=true; else smart_router_require_auth=false; fi [[ "$smart_router_require_auth" == true ]] || warn "Authentication is disabled. Keep the Smart Router bound to loopback unless you fully understand the exposure risk." fi if confirm "Enable provider/model health registry and circuit-breaker fallback?" "$([[ "$smart_router_provider_health_enabled" == true ]] && printf y || printf n)"; then smart_router_provider_health_enabled=true; else smart_router_provider_health_enabled=false; fi smart_router_fast_model="$(prompt "Fast route-profile $(router_backend_label) model/alias" "$smart_router_fast_model")" smart_router_standard_model="$(prompt "Standard route-profile $(router_backend_label) model/alias" "$smart_router_standard_model")" smart_router_strong_model="$(prompt "Strong route-profile $(router_backend_label) model/alias" "$smart_router_strong_model")" smart_router_coding_model="$(prompt "Coding route-profile $(router_backend_label) model/alias" "$smart_router_coding_model")" smart_router_vision_model="$(prompt "Vision route-profile $(router_backend_label) model/alias" "$smart_router_vision_model")" if [[ "$install_omniroute" == true ]]; then info "The fast/standard/strong values are Smart Router route-profile defaults. OmniRoute resolves the auto/best-* aliases behind them." else info "The fast/standard/strong values are Smart Router route-profile defaults. 9router controls the actual provider/model composition behind those combo names." fi info "Dashboard URL after start: http://$(service_url_host "$smart_router_bind"):$smart_router_port/dashboard" info "Operations Center URL after start: http://$(service_url_host "$smart_router_bind"):$smart_router_port/control/" if [[ -n "${base_domain:-}" ]]; then info "Optional public dashboard: proxy $(suggest_host sr) to this host, then record SMART_ROUTER_PUBLIC_URL=https://$(suggest_host sr) so the operator console links through the domain; ./manage.sh domains prints the block." fi fi if [[ "$configure_n8n" == true && "$install_n8n" == true ]]; then printf '\nn8n settings\n' printf '%s\n' '------------' n8n_bind="$(prompt_bind_ip "n8n editor host bind address" "$(suggested_bind_ip "$n8n_bind")")" n8n_port="$(prompt_port "n8n editor port" "$n8n_port")" n8n_public_url="$(prompt "Public n8n URL (or local URL)" "http://$(service_url_host "$n8n_bind"):$n8n_port")" case "$n8n_public_url" in https://*) n8n_protocol="https"; n8n_secure_cookie="true" ;; http://*) n8n_protocol="http" ;; *) die "The n8n public URL must start with http:// or https://." ;; esac n8n_hostname="${n8n_public_url#*://}" n8n_hostname="${n8n_hostname%%/*}" if [[ "$n8n_hostname" == \[*\]* ]]; then n8n_hostname="${n8n_hostname#\[}"; n8n_hostname="${n8n_hostname%%\]*}" else n8n_hostname="${n8n_hostname%%:*}" fi [[ -n "$n8n_hostname" ]] || die "The n8n public URL must include a host." n8n_timezone="$(prompt "n8n timezone (IANA name)" "$n8n_timezone")" if [[ -z "$existing_n8n_key" ]]; then info "A new n8n encryption key was generated. Back it up: stored n8n credentials cannot be decrypted without it." else info "Reusing the existing n8n encryption key so stored credentials stay readable." fi fi # The MCP bridge only makes sense when Hermes and n8n are both present. Instance # mode is intentionally explicit because its user-bound token has much broader # n8n management authority than the workflow-scoped trigger mode. if [[ "$install_hermes" == true && "$install_n8n" == true ]]; then choose_n8n_mcp=false if [[ "$n8n_mcp_mode" != off ]]; then if [[ "$configure_n8n" == true || "$configure_hermes" == true ]]; then confirm "Keep the Hermes MCP connection to n8n?" y || { n8n_mcp_mode=off; choose_n8n_mcp=true; } fi elif confirm "Let Hermes connect to n8n over MCP?" n; then choose_n8n_mcp=true fi if [[ "$choose_n8n_mcp" == true && "$n8n_mcp_mode" != off ]] \ || [[ "$choose_n8n_mcp" == true && "$n8n_mcp_previous_mode" == off ]]; then printf '%s\n' '1) Instance-level MCP (user-scoped n8n MCP access; tools depend on installed n8n version)' printf '%s\n' '2) MCP Server Trigger (only explicitly connected workflow tools)' while true; do n8n_mcp_selection="$(prompt "Choose n8n MCP mode" "1")" case "$n8n_mcp_selection" in 1) n8n_mcp_mode=instance; break ;; 2) n8n_mcp_mode=trigger; break ;; *) warn "Choose 1 or 2." >&2 ;; esac done fi case "$n8n_mcp_mode" in trigger) if [[ -z "$n8n_trigger_mcp_token" ]]; then n8n_trigger_mcp_token="$(random_hex 32)" info "Generated a bearer token for the managed MCP Server Trigger credential." fi install_n8n_mcp=true ;; instance) if [[ -n "$n8n_instance_mcp_token" ]]; then install_n8n_mcp=true else install_n8n_mcp=false warn "Instance-level MCP needs an n8n owner account first. After n8n starts, this wizard will offer to collect the owner API key and n8n-generated MCP token securely; you can also finish later with ./manage.sh n8n-menu." fi ;; off) install_n8n_mcp=false ;; esac else n8n_mcp_mode=off install_n8n_mcp=false fi if [[ "$install_smart_router" == true ]]; then provider_url="http://smart-router:8080/v1" openwebui_api_url="http://smart-router:8080/v1" model_name="auto" elif [[ "$install_nine" == true ]]; then openwebui_api_url="http://nine-router:20128/v1" provider_url="http://nine-router:20128/v1" [[ "$model_name" == auto* ]] && model_name="ai" elif [[ "$install_omniroute" == true ]]; then openwebui_api_url="http://omniroute:20129/v1" provider_url="http://omniroute:20129/v1" fi if [[ "$change_bind_ips" == true ]]; then printf '\nPublished container bind IPs\n' printf '%s\n' '----------------------------' if [[ "$install_nine" == true && "$configure_nine" != true ]]; then nine_bind="$(prompt_bind_ip "9router bind IP" "$(suggested_bind_ip "$nine_bind")")" fi if [[ "$install_omniroute" == true && "$configure_omniroute" != true ]]; then omni_bind="$(prompt_bind_ip "OmniRoute bind IP" "$(suggested_bind_ip "$omni_bind")")" fi if [[ "$install_hermes" == true && "$configure_hermes" != true ]]; then hermes_bind="$(prompt_bind_ip "Hermes API/dashboard bind IP" "$(suggested_bind_ip "$hermes_bind")")" fi if [[ "$install_webui" == true && "$configure_webui" != true ]]; then openwebui_bind="$(prompt_bind_ip "Open WebUI bind IP" "$(suggested_bind_ip "$openwebui_bind")")" fi if [[ "$install_n8n" == true && "$configure_n8n" != true ]]; then n8n_bind="$(prompt_bind_ip "n8n editor bind IP" "$(suggested_bind_ip "$n8n_bind")")" fi if [[ "$install_caddy" == true && "$configure_caddy" != true ]]; then caddy_bind="$(prompt_bind_ip "Caddy HTTP/HTTPS bind IP" "$caddy_bind")" fi if [[ ( "$install_nine" == true && "$nine_bind" == 0.0.0.0 ) \ || ( "$install_omniroute" == true && "$omni_bind" == 0.0.0.0 ) \ || ( "$install_hermes" == true && "$hermes_bind" == 0.0.0.0 ) \ || ( "$install_webui" == true && "$openwebui_bind" == 0.0.0.0 ) \ || ( "$install_n8n" == true && "$n8n_bind" == 0.0.0.0 ) \ || ( "$install_caddy" == true && "$caddy_bind" == 0.0.0.0 ) ]]; then warn "0.0.0.0 publishes a service on every host interface; protect it with a firewall and authentication." fi fi if [[ "$configure_hermes" == true ]]; then printf '\nHermes Agent settings\n' printf '%s\n' '---------------------' hermes_bind="$(prompt_bind_ip "Hermes API/dashboard host bind address" "$(suggested_bind_ip "$hermes_bind")")" if [[ "$install_smart_router" == true ]]; then provider_url="http://smart-router:8080/v1" info "Hermes will reach $(router_backend_label) through the Smart Router in ${smart_router_mode} mode." elif [[ "$install_nine" == true ]]; then provider_url="http://nine-router:20128/v1" info "Hermes will reach 9router through the private Docker network." elif [[ "$install_omniroute" == true ]]; then provider_url="http://omniroute:20129/v1" info "Hermes will reach OmniRoute through the private Docker network." else provider_url="$(prompt "OpenAI-compatible API base URL (include /v1)" "http://host.docker.internal:20128/v1")" fi provider_name="$(prompt "Hermes provider name" "$(router_backend_label)")" if [[ "$install_smart_router" == true ]]; then model_name="auto" info "Hermes model is set to auto; explicit /model selections still pass through unchanged." elif [[ "$install_omniroute" == true ]]; then model_name="$(prompt "OmniRoute model name" "$model_name")" else model_name="$(prompt "9router model/combo name" "ai")" fi if [[ "$install_nine" == true ]]; then provider_key="auto-generated-after-9router-starts" info "A dedicated 9router API key will be configured automatically for Hermes." elif [[ "$install_omniroute" == true ]]; then if [[ "$install_smart_router" == true ]]; then provider_key="auto-generated-after-smart-router-starts" info "Smart Router client authentication will be configured automatically for Hermes." else provider_key="$(prompt_secret "OmniRoute API key (Enter if REQUIRE_API_KEY=false)" true)" provider_key="${provider_key:-local-no-auth}" fi else provider_key="$(prompt_secret "9router/OpenAI-compatible API key")" fi if confirm "Enable Telegram connectivity?" y; then while true; do telegram_token="$(prompt_secret "Telegram BotFather token")" [[ "$telegram_token" =~ ^[0-9]+:[A-Za-z0-9_-]{20,}$ ]] && break warn "The token format does not look valid. Expected digits, a colon, then the token." done while true; do telegram_ids="$(prompt "Allowed numeric Telegram user IDs, comma-separated")" telegram_ids="${telegram_ids//[[:space:]]/}" valid_ids "$telegram_ids" && break warn "Use numeric IDs separated by commas, without usernames." done telegram_home="$(prompt_optional_integer "Optional Telegram home chat ID for cron results (Enter to skip)")" fi dashboard_default=n [[ "$hermes_dashboard" == 1 ]] && dashboard_default=y if confirm "Enable the built-in Hermes dashboard with generated username/password authentication?" "$dashboard_default"; then hermes_dashboard="1" hermes_dashboard_port="$(prompt_port "Hermes dashboard port" "$hermes_dashboard_port")" if [[ -n "$hermes_dashboard_username" && -n "$hermes_dashboard_password_hash" \ && -n "$hermes_dashboard_secret" && -n "$hermes_dashboard_password" ]]; then info "Reusing the existing Hermes dashboard credentials." else hermes_dashboard_username="hermes_$(random_hex 6)" hermes_dashboard_password="$(python3 -c 'import secrets; print(secrets.token_urlsafe(24))')" hermes_dashboard_secret="$(random_hex 32)" hermes_dashboard_password_hash="" hermes_dashboard_credentials_created=true info "Generated a new Hermes dashboard username and password." fi if [[ "$hermes_bind" == 127.0.0.1 ]]; then info "The dashboard will be localhost-only. From another machine, use: ssh -L ${hermes_dashboard_port}:127.0.0.1:${hermes_dashboard_port} USER@SERVER" else info "Hermes Basic Auth will protect the dashboard on ${hermes_bind}:${hermes_dashboard_port}." fi else hermes_dashboard="0" fi if confirm "Enable the Hermes OpenAI-compatible API?" n; then api_enabled="true" hermes_api_port="$(prompt_port "Hermes API port" "8642")" api_key="$(random_hex 32)" fi fi if [[ "$configure_webui" == true ]]; then printf '\nOpen WebUI settings\n' printf '%s\n' '-------------------' openwebui_bind="$(prompt_bind_ip "Open WebUI host bind address" "$(suggested_bind_ip "$openwebui_bind")")" openwebui_port="$(prompt_port "Open WebUI port" "3000")" openwebui_url="$(prompt "Open WebUI public URL (or local URL)" "http://$(service_url_host "$openwebui_bind"):$openwebui_port")" if [[ "$install_nine" == true || "$install_omniroute" == true ]]; then if [[ "$install_smart_router" == true ]]; then openwebui_api_url="http://smart-router:8080/v1" info "Open WebUI will reach $(router_backend_label) through the Smart Router." elif [[ "$install_nine" == true ]]; then openwebui_api_url="http://nine-router:20128/v1" info "Open WebUI will reach 9router through the private Docker network." else openwebui_api_url="http://omniroute:20129/v1" info "Open WebUI will reach OmniRoute through the private Docker network." fi if [[ "$install_nine" == true ]]; then openwebui_api_key="auto-generated-after-9router-starts" info "A dedicated 9router API key and OpenCode-Free model will be configured automatically." else openwebui_api_key="${provider_key:-local-no-auth}" info "OmniRoute model providers are managed in its dashboard; the /v1 connection is preconfigured." fi else openwebui_api_url="$(prompt "OpenAI-compatible API base URL for Open WebUI" "http://host.docker.internal:20128/v1")" openwebui_api_key="$(prompt_secret "OpenAI-compatible API key for Open WebUI")" fi if ! confirm "Allow Open WebUI account signup?" n; then openwebui_signup="false" warn "With signup disabled, create or confirm an administrator account before exposing Open WebUI." fi fi caddy_email="" caddy_nine_domain="" caddy_webui_domain="" caddy_hermes_dashboard_domain="" caddy_hermes_api_domain="" caddy_n8n_domain="" caddy_media_domain="" content_media_bind="" content_media_port="" content_media_public_url="" if [[ "$CONTENT_RECONFIGURE" != true && ( "$install_nine" == true || "$install_omniroute" == true || "$install_webui" == true \ || "$install_n8n" == true || "$hermes_dashboard" == 1 || "$api_enabled" == true \ || "$install_content" == true ) ]]; then if [[ "$install_caddy" == true ]]; then confirm "Reconfigure existing Caddy domains?" n && configure_caddy=true elif confirm "Add optional Caddy domains with automatic HTTPS?" n; then configure_caddy=true fi if [[ "$configure_caddy" == true ]]; then install_caddy=true printf '\nCaddy domain settings\n' printf '%s\n' '---------------------' caddy_bind="$(prompt_bind_ip "Caddy HTTP/HTTPS host bind address (public HTTPS normally needs 0.0.0.0)" "$caddy_bind")" caddy_email="$(prompt "Optional ACME email for certificate notices (Enter to skip)")" if [[ -n "$caddy_email" && ( "$caddy_email" != *@* || "$caddy_email" == *[[:space:]]* ) ]]; then die "The Caddy certificate email is not valid." fi declare -A selected_domains=() if [[ "$install_nine" == true || "$install_omniroute" == true ]] \ && confirm "Publish $(router_backend_label) with a domain?" y; then caddy_nine_domain="$(prompt_domain "$(router_backend_label) domain")" selected_domains["$caddy_nine_domain"]=1 if [[ "$install_nine" == true ]]; then nine_public_url="https://$caddy_nine_domain" nine_cookie_secure="true" else omni_public_url="https://$caddy_nine_domain" omni_cookie_secure="true" fi fi if [[ "$install_webui" == true ]] && confirm "Publish Open WebUI with a domain?" y; then caddy_webui_domain="$(prompt_domain "Open WebUI domain")" [[ -z "${selected_domains[$caddy_webui_domain]+x}" ]] || die "Each service needs a unique domain." selected_domains["$caddy_webui_domain"]=1 openwebui_url="https://$caddy_webui_domain" fi if [[ "$hermes_dashboard" == 1 ]] && confirm "Publish the Hermes dashboard with a domain?" n; then caddy_hermes_dashboard_domain="$(prompt_domain "Hermes dashboard domain")" [[ -z "${selected_domains[$caddy_hermes_dashboard_domain]+x}" ]] || die "Each service needs a unique domain." selected_domains["$caddy_hermes_dashboard_domain"]=1 fi if [[ "$api_enabled" == true ]] && confirm "Publish the Hermes API with a domain?" n; then caddy_hermes_api_domain="$(prompt_domain "Hermes API domain")" [[ -z "${selected_domains[$caddy_hermes_api_domain]+x}" ]] || die "Each service needs a unique domain." selected_domains["$caddy_hermes_api_domain"]=1 fi if [[ "$install_n8n" == true ]] && confirm "Publish n8n with a domain?" n; then caddy_n8n_domain="$(prompt_domain "n8n domain")" [[ -z "${selected_domains[$caddy_n8n_domain]+x}" ]] || die "Each service needs a unique domain." selected_domains["$caddy_n8n_domain"]=1 n8n_public_url="https://$caddy_n8n_domain" n8n_hostname="$caddy_n8n_domain" n8n_protocol="https" n8n_secure_cookie="true" n8n_proxy_hops="1" fi if [[ "$install_content" == true ]] \ && confirm "Publish the Instagram media directory with a domain (stable media URL)?" n; then caddy_media_domain="$(prompt_domain "Instagram media domain")" [[ -z "${selected_domains[$caddy_media_domain]+x}" ]] || die "Each service needs a unique domain." selected_domains["$caddy_media_domain"]=1 # nginx serves the media directory; the domain fronts it, so no tunnel. [[ ",$profiles," == *,ig-media,* ]] || profiles="${profiles:+$profiles,}ig-media" fi if ((${#selected_domains[@]} == 0)); then warn "No domains were selected; Caddy will not be installed." install_caddy=false else warn "Caddy needs public DNS records plus inbound TCP 80/443 and UDP 443." printf ' DNS: create A records in ArvanCloud for:' for caddy_domain in "${!selected_domains[@]}"; do printf ' %s' "$caddy_domain" done printf '\n' printf '%s\n' ' Point them at the router public IP and forward 80/443 to this host;' printf '%s\n' ' keep the CDN proxy toggle off until the certificate is issued.' if [[ -n "$caddy_nine_domain" && "$install_nine" == true && "$nine_require_key" != true ]]; then warn "9router /v1 will be public without Bearer-key enforcement. Enable REQUIRE_API_KEY after creating a 9router endpoint key." fi if [[ -n "$caddy_nine_domain" && "$install_omniroute" == true && "$omni_require_key" != true ]]; then warn "OmniRoute /v1 will be public without Bearer-key enforcement. Create an endpoint key in OmniRoute, run ./manage.sh set-backend-api-key KEY, then enable OMNIROUTE_REQUIRE_API_KEY=true." fi if [[ -n "$caddy_webui_domain" && "$openwebui_signup" == true ]]; then warn "Create the first Open WebUI administrator promptly, then disable signup in its Admin Panel." fi if [[ -n "$caddy_n8n_domain" ]]; then warn "n8n will be public. Create its owner account immediately: the first visitor to an unclaimed instance becomes the owner." fi fi fi fi [[ "$install_caddy" == true ]] && profiles="${profiles:+$profiles,}caddy" # Content Bot settings. The writer defaults to the Smart Router client API when # the router is installed, or to 9router directly otherwise; install.sh later # synchronizes the real client key for the Smart Router path. content_bot_token="$(existing_env_value CONTENT_BOT_TOKEN)" content_channel="$(existing_env_value CONTENT_TELEGRAM_CHANNEL)" content_users="$(existing_env_value CONTENT_TELEGRAM_USERS)" content_writer_url="$(existing_env_value CONTENT_WRITER_BASE_URL)" content_writer_key="$(existing_env_value CONTENT_WRITER_API_KEY)" content_writer_model="$(existing_env_value CONTENT_WRITER_MODEL)" content_scheduler_enabled="$(existing_env_value CONTENT_SCHEDULER_ENABLED)" content_scheduler_enabled="${content_scheduler_enabled:-true}" if [[ "$install_content" == true && "$configure_content" == true ]]; then printf '\nContent Bot settings\n' printf '%s\n' '---------------------' while true; do content_bot_token="$(prompt_secret "Content Bot Telegram token (create one with @BotFather)")" [[ "$content_bot_token" =~ ^[0-9]+:[A-Za-z0-9_-]{20,}$ ]] && break warn "The token format does not look valid. Expected digits, a colon, then the token." done content_channel="$(prompt "Telegram channel ID or @username to publish to")" if [[ "$content_channel" =~ ^[0-9]+$ ]]; then warn "A channel or supergroup id starts with -100 (for example -1001234567890); a bare number is a user id." content_channel="$(prompt "Telegram channel ID (Enter to use -100$content_channel)" "-100$content_channel")" fi content_users_default="$content_users" [[ -n "$content_users_default" ]] || content_users_default="$telegram_ids" while true; do content_users="$(prompt "Allowed numeric Telegram user IDs, comma-separated" "$content_users_default")" content_users="${content_users//[[:space:]]/}" valid_ids "$content_users" && break warn "Use numeric IDs separated by commas, without usernames." done content_writer_default="$content_writer_url" if [[ -z "$content_writer_default" ]]; then if [[ "$install_smart_router" == true ]]; then content_writer_default="http://smart-router:8080/v1" elif [[ "$install_nine" == true ]]; then content_writer_default="http://nine-router:20128/v1" elif [[ "$install_omniroute" == true ]]; then content_writer_default="http://omniroute:20129/v1" fi fi content_external_api=false if [[ "$install_smart_router" != true && "$install_nine" != true && "$install_omniroute" != true ]]; then content_external_api=true printf '%s\n' 'No local router is installed: the Content Bot will call your own OpenAI-compatible API.' printf '%s\n' 'Example: base URL https://api.openai.com/v1, API key sk-..., model gpt-4o-mini.' fi while true; do content_writer_url="$(prompt "Writer OpenAI-compatible API base URL (include /v1)" "$content_writer_default")" case "$content_writer_url" in http://*|https://*) break ;; *) warn "The writer base URL must start with http:// or https://." >&2 ;; esac done if [[ "$content_external_api" == true ]]; then content_writer_key="$(prompt_secret "Writer API key (required for your hosted API)")" else content_writer_key="$(prompt_secret "Writer API key (Enter for no-auth local endpoints)" true)" fi if [[ "$content_external_api" == true && -z "$content_writer_model" ]]; then content_writer_model_default="" elif [[ "$install_omniroute" == true && "$install_smart_router" != true && -z "$content_writer_model" ]]; then content_writer_model_default="auto/best-chat" else content_writer_model_default="${content_writer_model:-auto-strong}" fi while true; do content_writer_model="$(prompt "Writer model id (as your API expects; local Smart Router: auto-strong)" "$content_writer_model_default")" [[ -n "$content_writer_model" ]] && break warn "A writer model id is required." >&2 done if confirm "Enable the daily editorial proposal scheduler?" "$([[ "$content_scheduler_enabled" == true ]] && printf y || printf n)"; then content_scheduler_enabled=true else content_scheduler_enabled=false fi content_media_port="$(existing_env_value IG_MEDIA_PORT)" content_media_port="${content_media_port:-8099}" content_media_bind="$(existing_env_value IG_MEDIA_BIND_IP)" if [[ -n "$caddy_media_domain" ]]; then content_media_bind="${content_media_bind:-127.0.0.1}" printf '%s\n' "Instagram media: Caddy in this stack fronts $caddy_media_domain, so the media host stays on $content_media_bind:$content_media_port." else printf '\nInstagram media hosting\n' printf '%s\n' '-------------------------' printf '%s\n' 'Instagram downloads every photo or video from a public HTTPS URL, so the' printf '%s\n' 'media directory needs a public address. A reverse proxy on the router (or' printf '%s\n' 'another host) can front it over the LAN; otherwise use a tunnel later with' printf '%s\n' './manage.sh instagram-media-enable.' if confirm "Will a reverse proxy on another host publish the media directory?" n; then while true; do content_media_bind="$(prompt "Media host bind address (LAN address that proxy connects to)" "${content_media_bind:-${lan_ip:-127.0.0.1}}")" valid_bind_ip "$content_media_bind" && break warn "Enter an IPv4 address, for example 192.168.1.50." done [[ ",$profiles," == *,ig-media,* ]] || profiles="${profiles:+$profiles,}ig-media" content_media_public_host="$(prompt_domain_optional "Public media hostname (Enter to skip; Instagram needs HTTPS)" "$(suggest_host media)")" if [[ -n "$content_media_public_host" ]]; then content_media_public_url="https://$content_media_public_host" print_public_route "$content_media_public_host" "$content_media_bind:$content_media_port" \ 'Keep only /media public; INSTAGRAM_MEDIA_PUBLIC_BASE_URL will use this host.' else printf '%s\n' "Point that proxy at http://$content_media_bind:$content_media_port and keep only /media public." printf '%s\n' 'Add the hostname later with ./manage.sh instagram-media-enable (set INSTAGRAM_MEDIA_PUBLIC_BASE_URL).' fi elif [[ -n "$content_media_bind" && "$content_media_bind" != 127.0.0.1 ]]; then printf '%s\n' "Keeping the existing media bind address $content_media_bind:$content_media_port." printf '%s\n' 'Public route reminder: point the media hostname at the router public IP,' printf '%s\n' 'proxy it to this bind address, and set INSTAGRAM_MEDIA_PUBLIC_BASE_URL.' else content_media_bind="127.0.0.1" fi fi fi # Media Studio settings. The api-image and api-video drivers reuse the writer # endpoint selected above (Smart Router, 9router/OmniRoute, or an external # hosted API) so Media Studio works without any Google subscription. flow-video # and gemini-image are optional and need a signed-in Google session; video-edit # normalises an operator-recorded clip locally with ffmpeg. media_drivers="$(existing_env_value MEDIA_STUDIO_DRIVERS)" media_drivers="${media_drivers:-api-image,api-video,flow-video,video-edit}" media_api_token="$(existing_env_value MEDIA_STUDIO_API_TOKEN)" media_writer_url="$(existing_env_value MEDIA_STUDIO_WRITER_BASE_URL)" media_writer_key="$(existing_env_value MEDIA_STUDIO_WRITER_API_KEY)" media_writer_model="$(existing_env_value MEDIA_STUDIO_WRITER_MODEL)" media_video_url="$(existing_env_value MEDIA_STUDIO_VIDEO_BASE_URL)" media_video_key="$(existing_env_value MEDIA_STUDIO_VIDEO_API_KEY)" media_video_model="$(existing_env_value MEDIA_STUDIO_VIDEO_MODEL)" media_block_geo="$(existing_env_value MEDIA_STUDIO_BLOCK_GEO_REDIRECT)" media_block_geo="${media_block_geo:-true}" media_freeze="$(existing_env_value MEDIA_STUDIO_FREEZE_ON_READY)" media_freeze="${media_freeze:-true}" if [[ "$install_media" == true && "$configure_media" == true ]]; then printf '\nMedia Studio settings\n' printf '%s\n' '------------------------' while true; do media_drivers="$(prompt "Enabled drivers, comma-separated (api-image, api-video, flow-video, gemini-image, video-edit)" "$media_drivers")" media_drivers="$(printf '%s' "$media_drivers" | tr ',' ' ' | tr -s ' ' | tr ' ' ',')" media_drivers_valid=true if IFS=',' read -r -a driver_list <<< "$media_drivers"; then for driver in "${driver_list[@]}"; do case "$driver" in api-image|api-video|flow-video|gemini-image|video-edit) ;; *) media_drivers_valid=false ;; esac done fi [[ "$media_drivers_valid" == true && -n "$media_drivers" ]] && break warn "Choose from api-image, api-video, flow-video, gemini-image, and video-edit only." >&2 done if [[ ",$media_drivers," == *,api-image,* ]]; then media_writer_default="$media_writer_url" [[ -n "$media_writer_default" ]] || media_writer_default="$content_writer_url" while true; do media_writer_url="$(prompt "Image API base URL (OpenAI-compatible, include /v1; Enter for no image API)" "$media_writer_default")" case "$media_writer_url" in "") break ;; http://*|https://*) break ;; *) warn "The image API base URL must start with http:// or https://." >&2 ;; esac done if [[ -n "$media_writer_url" ]]; then media_writer_key="$(prompt_secret "Image API key (Enter to keep current or skip)" true)" [[ -n "$media_writer_key" ]] || media_writer_key="$(existing_env_value MEDIA_STUDIO_WRITER_API_KEY)" [[ -n "$media_writer_key" ]] || media_writer_key="$content_writer_key" media_writer_model="$(prompt "Image model id (as your API expects)" "${media_writer_model:-${content_writer_model:-auto}}")" fi fi if [[ ",$media_drivers," == *,api-video,* ]]; then media_video_default="$media_video_url" [[ -n "$media_video_default" ]] || media_video_default="$media_writer_url" while true; do media_video_url="$(prompt "Video API base URL (Enter to reuse the image endpoint, or for no video API)" "$media_video_default")" case "$media_video_url" in "") break ;; http://*|https://*) break ;; *) warn "The video API base URL must start with http:// or https://." >&2 ;; esac done if [[ -n "$media_video_url" ]]; then media_video_model="$(prompt "Video model id (a provider model such as xai/grok-imagine-video; combos cannot generate video)" "${media_video_model:-}")" if [[ -n "$media_video_model" ]]; then media_video_key="$(prompt_secret "Video API key (Enter to reuse the image key)" true)" [[ -n "$media_video_key" ]] || media_video_key="$(existing_env_value MEDIA_STUDIO_VIDEO_API_KEY)" fi fi fi if [[ ",$media_drivers," == *,flow-video,* || ",$media_drivers," == *,gemini-image,* ]]; then printf '%s\n' 'Google drivers need a signed-in Google session:' printf '%s\n' ' - cdp mode attaches to Chrome on the operator desktop (default)' printf '%s\n' ' - persistent mode keeps its own Chromium profile in data/media-studio' fi if confirm "Block the Google unsupported-country redirect for Flow (region unlock)?" "$([[ "$media_block_geo" == true ]] && printf y || printf n)"; then media_block_geo=true else media_block_geo=false fi if confirm "Apply the page freeze after the Flow project button appears?" "$([[ "$media_freeze" == true ]] && printf y || printf n)"; then media_freeze=true else media_freeze=false fi if confirm "Protect the local Media Studio API with a token?" "$([[ -n "$media_api_token" ]] && printf y || printf n)"; then while true; do media_api_token="$(prompt_secret "Media Studio API token (used as Authorization: Bearer)")" [[ -n "$media_api_token" ]] && break warn "An API token cannot be empty when protection is enabled." >&2 done else media_api_token="" fi if [[ -n "${base_domain:-}" ]]; then media_public_port="$(existing_env_value MEDIA_STUDIO_PORT)" media_public_port="${media_public_port:-8850}" info "Optional public studio API: proxy $(suggest_host studio) to this host (port $media_public_port), then record MEDIA_STUDIO_PUBLIC_URL=https://$(suggest_host studio) so the operator console links through the domain; ./manage.sh domains prints the block." fi fi invoking_uid="${SUDO_UID:-$(id -u)}" invoking_gid="${SUDO_GID:-$(id -g)}" # The Hermes image refuses to run its gateway as root. A direct-root install has # no SUDO_UID/SUDO_GID to map, so retain a valid existing identity or use the # image's 10000:10000 default instead of emitting HERMES_UID=0/HERMES_GID=0. existing_hermes_uid="$(existing_env_value HERMES_UID)" existing_hermes_gid="$(existing_env_value HERMES_GID)" if [[ "$invoking_uid" == 0 ]]; then hermes_uid="${existing_hermes_uid:-10000}" hermes_gid="${existing_hermes_gid:-10000}" [[ "$hermes_uid" != 0 ]] || hermes_uid=10000 [[ "$hermes_gid" != 0 ]] || hermes_gid=10000 else hermes_uid="$invoking_uid" hermes_gid="$invoking_gid" fi # The Content Bot container runs unprivileged; root installs map it to the # isolated 10004:10004 identity so it can write its own state directory. if [[ "$install_content" == true ]]; then if [[ "$invoking_uid" == 0 ]]; then content_bot_uid=10004 content_bot_gid=10004 content_bot_run_as="10004:10004" else content_bot_uid="$invoking_uid" content_bot_gid="$invoking_gid" content_bot_run_as="$invoking_uid:$invoking_gid" fi else content_bot_run_as="$(existing_env_value CONTENT_BOT_RUN_AS)" content_bot_run_as="${content_bot_run_as:-10004:10004}" fi # The Media Studio container runs unprivileged; root installs map it to the # isolated 10005:10005 identity so Chromium can write its profile and jobs. if [[ "$install_media" == true ]]; then if [[ "$invoking_uid" == 0 ]]; then media_uid=10005 media_gid=10005 media_run_as="10005:10005" else media_uid="$invoking_uid" media_gid="$invoking_gid" media_run_as="$invoking_uid:$invoking_gid" fi else media_run_as="$(existing_env_value MEDIA_STUDIO_RUN_AS)" media_run_as="${media_run_as:-10005:10005}" fi # The RustFS image runs as its own unprivileged user. Root installs keep the # image identity (10001:10001) so the bind-mounted data stays private, while # unprivileged installs map the container to the invoking user. if [[ "$install_rustfs" == true ]]; then if [[ "$invoking_uid" == 0 ]]; then rustfs_uid=10001 rustfs_gid=10001 else rustfs_uid="$invoking_uid" rustfs_gid="$invoking_gid" fi else rustfs_uid="$(existing_env_value RUSTFS_UID)" rustfs_uid="${rustfs_uid:-10001}" rustfs_gid="$(existing_env_value RUSTFS_GID)" rustfs_gid="${rustfs_gid:-10001}" fi # Object-storage credentials: generated once, then preserved on every rerun. rustfs_access_key="$(existing_env_value RUSTFS_ACCESS_KEY)" case "$rustfs_access_key" in ""|CHANGE_ME) rustfs_access_key="locallab-$(random_hex 6)" ;; esac rustfs_secret_key="$(existing_env_value RUSTFS_SECRET_KEY)" case "$rustfs_secret_key" in ""|CHANGE_ME) rustfs_secret_key="$(random_hex 16)" ;; esac if [[ "$hermes_dashboard" == 1 ]]; then [[ -n "$hermes_dashboard_username" && -n "$hermes_dashboard_password" \ && -n "$hermes_dashboard_secret" ]] \ || die "Hermes dashboard authentication is incomplete; reconfigure Hermes to provision credentials." if [[ -z "$hermes_dashboard_password_hash" || "$hermes_dashboard_credentials_created" == true ]]; then hermes_dashboard_password_hash="$(printf '%s' "$hermes_dashboard_password" | hash_hermes_dashboard_password)" fi [[ "$hermes_dashboard_password_hash" == scrypt\$* ]] \ || die "Hermes dashboard password hash is invalid; reconfigure Hermes to rotate credentials." dashboard_access_tmp="$(mktemp "$ROOT_DIR/data/stack-secrets/hermes-dashboard-access.env.tmp.XXXXXX")" { printf 'HERMES_DASHBOARD_USERNAME=%s\n' "$(dotenv_quote "$hermes_dashboard_username")" printf 'HERMES_DASHBOARD_PASSWORD=%s\n' "$(dotenv_quote "$hermes_dashboard_password")" } > "$dashboard_access_tmp" chmod 600 "$dashboard_access_tmp" chown "$execution_owner_uid:$execution_owner_gid" "$dashboard_access_tmp" mv "$dashboard_access_tmp" "$HERMES_DASHBOARD_ACCESS_FILE" fi tmp_env="$(mktemp "$ROOT_DIR/.env.tmp.XXXXXX")" if [[ -f "$ENV_FILE" ]]; then cp "$ENV_FILE" "$tmp_env" else cp "$ROOT_DIR/.env.example" "$tmp_env" fi chmod 600 "$tmp_env" # Preserve every v0.5.x setting and update only values owned by this wizard. replace_env_value "$tmp_env" COMPOSE_PROFILES "$profiles" replace_env_value "$tmp_env" STACK_BASE_DOMAIN "$(dotenv_quote "$base_domain")" if [[ -n "$caddy_media_domain" ]]; then replace_env_value "$tmp_env" INSTAGRAM_MEDIA_PUBLIC_BASE_URL "https://$caddy_media_domain" fi if [[ -n "$content_media_public_url" ]]; then replace_env_value "$tmp_env" INSTAGRAM_MEDIA_PUBLIC_BASE_URL "$content_media_public_url" fi if [[ "$install_content" == true && -n "$content_media_bind" ]]; then replace_env_value "$tmp_env" IG_MEDIA_BIND_IP "$content_media_bind" replace_env_value "$tmp_env" IG_MEDIA_PORT "${content_media_port:-8099}" fi replace_env_value "$tmp_env" NINEROUTER_BIND_IP "$nine_bind" replace_env_value "$tmp_env" NINEROUTER_PORT "$nine_port" replace_env_value "$tmp_env" NINEROUTER_INITIAL_PASSWORD "$(dotenv_quote "$nine_password")" replace_env_value "$tmp_env" NINEROUTER_JWT_SECRET "$nine_jwt" replace_env_value "$tmp_env" NINEROUTER_API_KEY_SECRET "$nine_key_secret" replace_env_value "$tmp_env" NINEROUTER_MACHINE_ID_SALT "$nine_salt" replace_env_value "$tmp_env" NINEROUTER_REQUIRE_API_KEY "$nine_require_key" replace_env_value "$tmp_env" NINEROUTER_AUTH_COOKIE_SECURE "$nine_cookie_secure" replace_env_value "$tmp_env" NINEROUTER_PUBLIC_BASE_URL "$(dotenv_quote "$nine_public_url")" replace_env_value "$tmp_env" OMNIROUTE_IMAGE_REPOSITORY "diegosouzapw/omniroute" replace_env_value "$tmp_env" OMNIROUTE_IMAGE_TAG "latest" replace_env_value "$tmp_env" OMNIROUTE_BIND_IP "$omni_bind" replace_env_value "$tmp_env" OMNIROUTE_PORT "$omni_port" replace_env_value "$tmp_env" OMNIROUTE_API_BIND_IP "$omni_api_bind" replace_env_value "$tmp_env" OMNIROUTE_API_PORT "$omni_api_port" replace_env_value "$tmp_env" OMNIROUTE_INITIAL_PASSWORD "$(dotenv_quote "$omni_password")" replace_env_value "$tmp_env" OMNIROUTE_JWT_SECRET "$omni_jwt" replace_env_value "$tmp_env" OMNIROUTE_API_KEY_SECRET "$omni_key_secret" replace_env_value "$tmp_env" OMNIROUTE_MANAGEMENT_API_KEY "${omni_management_key:-CHANGE_ME}" replace_env_value "$tmp_env" OMNIROUTE_STORAGE_ENCRYPTION_KEY "${omni_storage_key:-CHANGE_ME}" replace_env_value "$tmp_env" OMNIROUTE_STORAGE_ENCRYPTION_KEY_VERSION "${omni_key_version:-v1}" replace_env_value "$tmp_env" OMNIROUTE_MACHINE_ID_SALT "$omni_salt" replace_env_value "$tmp_env" OMNIROUTE_WS_BRIDGE_SECRET "${omni_ws_secret:-CHANGE_ME}" replace_env_value "$tmp_env" OMNIROUTE_REQUIRE_API_KEY "$omni_require_key" replace_env_value "$tmp_env" OMNIROUTE_AUTH_COOKIE_SECURE "$omni_cookie_secure" replace_env_value "$tmp_env" OMNIROUTE_PUBLIC_BASE_URL "$(dotenv_quote "$omni_public_url")" replace_env_value "$tmp_env" OMNIROUTE_ALLOW_API_KEY_REVEAL "${omni_allow_key_reveal:-false}" replace_env_value "$tmp_env" OMNIROUTE_MEMORY_MB "${omni_memory_mb:-512}" replace_env_value "$tmp_env" HERMES_BIND_IP "$hermes_bind" replace_env_value "$tmp_env" HERMES_API_PORT "$hermes_api_port" replace_env_value "$tmp_env" HERMES_DASHBOARD_PORT "$hermes_dashboard_port" replace_env_value "$tmp_env" HERMES_DASHBOARD "$hermes_dashboard" replace_env_value "$tmp_env" HERMES_DASHBOARD_BASIC_AUTH_USERNAME "$(dotenv_quote "$hermes_dashboard_username")" replace_env_value "$tmp_env" HERMES_DASHBOARD_BASIC_AUTH_PASSWORD_HASH "$(dotenv_literal_quote "$hermes_dashboard_password_hash")" replace_env_value "$tmp_env" HERMES_DASHBOARD_BASIC_AUTH_SECRET "$(dotenv_quote "$hermes_dashboard_secret")" replace_env_value "$tmp_env" HERMES_UID "$hermes_uid" replace_env_value "$tmp_env" HERMES_GID "$hermes_gid" replace_env_value "$tmp_env" EXECUTION_FEATURES "$execution_features" replace_env_value "$tmp_env" EXECUTION_POLICY_GENERATION "$execution_generation" replace_env_value "$tmp_env" EXECUTION_WORKSPACE_GENERATION "$execution_workspace_generation" replace_env_value "$tmp_env" EXECUTION_BROKER_IMAGE "$execution_broker_image" replace_env_value "$tmp_env" EXECUTION_SANDBOX_IMAGE "$execution_sandbox_image" replace_env_value "$tmp_env" EXECUTION_RUN_AS "$execution_run_as" replace_env_value "$tmp_env" EXECUTION_DOCKER_GID "$execution_docker_gid" replace_env_value "$tmp_env" EXECUTION_WORKSPACE_HOST_PATH "$execution_workspace" replace_env_value "$tmp_env" SMART_ROUTER_IMAGE_REPOSITORY "$smart_router_image_repository" replace_env_value "$tmp_env" SMART_ROUTER_IMAGE_TAG "$smart_router_image_tag" replace_env_value "$tmp_env" SMART_ROUTER_BIND_IP "$smart_router_bind" replace_env_value "$tmp_env" SMART_ROUTER_PORT "$smart_router_port" replace_env_value "$tmp_env" SMART_ROUTER_MODE "$smart_router_mode" replace_env_value "$tmp_env" SMART_ROUTER_POLICY "$smart_router_policy" replace_env_value "$tmp_env" SMART_ROUTER_ALLOW_TIER_OVERRIDES "$smart_router_allow_tier_overrides" replace_env_value "$tmp_env" SMART_ROUTER_DASHBOARD_ENABLED "$smart_router_dashboard_enabled" replace_env_value "$tmp_env" SMART_ROUTER_CONTROL_PLANE_ENABLED "$smart_router_control_plane_enabled" replace_env_value "$tmp_env" SMART_ROUTER_REQUIRE_AUTH "$smart_router_require_auth" replace_env_value "$tmp_env" SMART_ROUTER_PROVIDER_HEALTH_ENABLED "$smart_router_provider_health_enabled" replace_env_value "$tmp_env" SMART_ROUTER_FAST_MODEL "$smart_router_fast_model" replace_env_value "$tmp_env" SMART_ROUTER_STANDARD_MODEL "$smart_router_standard_model" replace_env_value "$tmp_env" SMART_ROUTER_STRONG_MODEL "$smart_router_strong_model" replace_env_value "$tmp_env" SMART_ROUTER_CODING_MODEL "$smart_router_coding_model" replace_env_value "$tmp_env" SMART_ROUTER_VISION_MODEL "$smart_router_vision_model" replace_env_value "$tmp_env" SMART_ROUTER_OBSERVE_MODEL "$smart_router_observe_model" if [[ "$install_smart_router" == true ]]; then # Point the Smart Router upstream at the active backend. Manual overrides are # preserved unless the backend itself changed in this run. existing_upstream="$(read_unique_env_value "$tmp_env" SMART_ROUTER_UPSTREAM_BASE_URL)" if [[ "$router_backend_changed" == true || -z "$existing_upstream" ]]; then if [[ "$install_omniroute" == true ]]; then replace_env_value "$tmp_env" SMART_ROUTER_UPSTREAM_BASE_URL "http://omniroute:20129/v1" replace_env_value "$tmp_env" SMART_ROUTER_UPSTREAM_HEALTH_URL "http://omniroute:20128/api/monitoring/health" else replace_env_value "$tmp_env" SMART_ROUTER_UPSTREAM_BASE_URL "http://nine-router:20128/v1" replace_env_value "$tmp_env" SMART_ROUTER_UPSTREAM_HEALTH_URL "http://nine-router:20128/api/health" fi fi if [[ "$router_backend_changed" == true ]]; then # A backend key from the previous backend cannot authenticate upstream. replace_env_value "$tmp_env" SMART_ROUTER_UPSTREAM_API_KEY "" fi fi replace_env_value "$tmp_env" OPENWEBUI_BIND_IP "$openwebui_bind" replace_env_value "$tmp_env" OPENWEBUI_PORT "$openwebui_port" replace_env_value "$tmp_env" OPENWEBUI_URL "$(dotenv_quote "$openwebui_url")" replace_env_value "$tmp_env" OPENWEBUI_SECRET_KEY "$openwebui_secret" replace_env_value "$tmp_env" OPENWEBUI_OPENAI_BASE_URL "$(dotenv_quote "$openwebui_api_url")" replace_env_value "$tmp_env" OPENWEBUI_ENABLE_SIGNUP "$openwebui_signup" replace_env_value "$tmp_env" N8N_MCP_MODE "$n8n_mcp_mode" replace_env_value "$tmp_env" N8N_BIND_IP "$n8n_bind" replace_env_value "$tmp_env" N8N_PORT "$n8n_port" replace_env_value "$tmp_env" N8N_HOSTNAME "$n8n_hostname" replace_env_value "$tmp_env" N8N_PROTOCOL "$n8n_protocol" replace_env_value "$tmp_env" N8N_PUBLIC_URL "$(dotenv_quote "$n8n_public_url")" replace_env_value "$tmp_env" N8N_SECURE_COOKIE "$n8n_secure_cookie" replace_env_value "$tmp_env" N8N_PROXY_HOPS "$n8n_proxy_hops" replace_env_value "$tmp_env" N8N_TIMEZONE "$n8n_timezone" replace_env_value "$tmp_env" N8N_DIAGNOSTICS_ENABLED "$n8n_diagnostics" replace_env_value "$tmp_env" N8N_VERSION_NOTIFICATIONS_ENABLED "$n8n_version_notifications" replace_env_value "$tmp_env" N8N_ENCRYPTION_KEY "$n8n_encryption_key" replace_env_value "$tmp_env" CADDY_BIND_IP "$caddy_bind" replace_env_value "$tmp_env" CONTENT_BOT_RUN_AS "$content_bot_run_as" replace_env_value "$tmp_env" CONTENT_BOT_TOKEN "$(dotenv_quote "$content_bot_token")" replace_env_value "$tmp_env" CONTENT_TELEGRAM_CHANNEL "$(dotenv_quote "$content_channel")" replace_env_value "$tmp_env" CONTENT_TELEGRAM_USERS "$(dotenv_quote "$content_users")" replace_env_value "$tmp_env" CONTENT_WRITER_BASE_URL "$(dotenv_quote "$content_writer_url")" replace_env_value "$tmp_env" CONTENT_WRITER_API_KEY "$(dotenv_quote "$content_writer_key")" replace_env_value "$tmp_env" CONTENT_WRITER_MODEL "$(dotenv_quote "$content_writer_model")" replace_env_value "$tmp_env" CONTENT_SCHEDULER_ENABLED "$content_scheduler_enabled" # The image follows .env.example (the repository default) so a release bumps it # in one place; a tag already pinned in an existing .env is preserved. media_studio_image_repository="$(existing_env_value MEDIA_STUDIO_IMAGE_REPOSITORY)" media_studio_image_repository="${media_studio_image_repository:-afsharidevops/media-studio}" media_studio_image_tag="$(existing_env_value MEDIA_STUDIO_IMAGE_TAG)" media_studio_image_tag="${media_studio_image_tag:-0.4.0}" replace_env_value "$tmp_env" MEDIA_STUDIO_IMAGE_REPOSITORY "$media_studio_image_repository" replace_env_value "$tmp_env" MEDIA_STUDIO_IMAGE_TAG "$media_studio_image_tag" replace_env_value "$tmp_env" MEDIA_STUDIO_RUN_AS "$media_run_as" replace_env_value "$tmp_env" MEDIA_STUDIO_DRIVERS "$media_drivers" replace_env_value "$tmp_env" MEDIA_STUDIO_WRITER_BASE_URL "$(dotenv_quote "$media_writer_url")" replace_env_value "$tmp_env" MEDIA_STUDIO_WRITER_API_KEY "$(dotenv_quote "$media_writer_key")" replace_env_value "$tmp_env" MEDIA_STUDIO_WRITER_MODEL "$(dotenv_quote "$media_writer_model")" replace_env_value "$tmp_env" MEDIA_STUDIO_VIDEO_BASE_URL "$(dotenv_quote "$media_video_url")" replace_env_value "$tmp_env" MEDIA_STUDIO_VIDEO_API_KEY "$(dotenv_quote "$media_video_key")" replace_env_value "$tmp_env" MEDIA_STUDIO_VIDEO_MODEL "$(dotenv_quote "$media_video_model")" replace_env_value "$tmp_env" MEDIA_STUDIO_API_TOKEN "$(dotenv_quote "$media_api_token")" replace_env_value "$tmp_env" MEDIA_STUDIO_BLOCK_GEO_REDIRECT "$media_block_geo" replace_env_value "$tmp_env" MEDIA_STUDIO_FREEZE_ON_READY "$media_freeze" # A combined Content Bot + Media Studio install wires the bot to the local # Media Studio API: the same API token and job drivers that match the enabled # Media Studio drivers. A pre-existing CONTENT_MEDIA_STUDIO_URL (for example a # remote Media Studio used by a content-only server) is preserved. if [[ "$install_content" == true && "$install_media" == true ]]; then if [[ -z "$(read_unique_env_value "$tmp_env" CONTENT_MEDIA_STUDIO_URL)" ]]; then replace_env_value "$tmp_env" CONTENT_MEDIA_STUDIO_URL "http://media-studio:8850" fi replace_env_value "$tmp_env" CONTENT_MEDIA_STUDIO_TOKEN "$(dotenv_quote "$media_api_token")" if [[ ",$media_drivers," == *,api-image,* ]]; then replace_env_value "$tmp_env" CONTENT_MEDIA_IMAGE_DRIVER "api-image" elif [[ ",$media_drivers," == *,gemini-image,* ]]; then replace_env_value "$tmp_env" CONTENT_MEDIA_IMAGE_DRIVER "gemini-image" fi if [[ ",$media_drivers," == *,api-video,* ]]; then replace_env_value "$tmp_env" CONTENT_MEDIA_VIDEO_DRIVER "api-video" elif [[ ",$media_drivers," == *,flow-video,* ]]; then replace_env_value "$tmp_env" CONTENT_MEDIA_VIDEO_DRIVER "flow-video" fi # video-edit normalises an operator-recorded clip with ffmpeg; it is local # to Media Studio and needs no Google session. if [[ ",$media_drivers," == *,video-edit,* ]]; then replace_env_value "$tmp_env" CONTENT_MEDIA_VIDEO_EDIT_DRIVER "video-edit" fi fi if [[ "$install_content" == true && "$profiles" == *notebooklm* ]]; then notebooklm_api_token="$(read_unique_env_value "$tmp_env" NOTEBOOKLM_API_TOKEN)" if [[ -n "$notebooklm_api_token" ]]; then replace_env_value "$tmp_env" CONTENT_NOTEBOOKLM_TOKEN "$(dotenv_quote "$notebooklm_api_token")" fi if [[ -z "$(read_unique_env_value "$tmp_env" CONTENT_NOTEBOOKLM_URL)" ]]; then replace_env_value "$tmp_env" CONTENT_NOTEBOOKLM_URL "http://notebooklm-worker:8860" fi replace_env_value "$tmp_env" CONTENT_NOTEBOOKLM_ENABLED true fi # When Media Studio is disabled while the Content Bot stays enabled, drop the # auto-wired local link so the bot stops offering media jobs. A custom remote # Media Studio URL is preserved for split deployments. if [[ "$install_content" == true && "$install_media" != true && "$media_was_enabled" == true ]]; then if [[ "$(read_unique_env_value "$tmp_env" CONTENT_MEDIA_STUDIO_URL)" == "http://media-studio:8850" ]]; then replace_env_value "$tmp_env" CONTENT_MEDIA_STUDIO_URL "" replace_env_value "$tmp_env" CONTENT_MEDIA_STUDIO_TOKEN "" fi fi # Shared S3-compatible object storage. Values are only rewritten when the # operator added or replaced the backend, so hand-edited endpoints and # external credentials survive reconfigure runs. if [[ "$s3_change" == true ]]; then case "$s3_choice" in rustfs) replace_env_value "$tmp_env" S3_STORAGE_BACKEND rustfs replace_env_value "$tmp_env" S3_ENDPOINT_URL "http://rustfs:9000" replace_env_value "$tmp_env" S3_ACCESS_KEY_ID "$(dotenv_quote "$rustfs_access_key")" replace_env_value "$tmp_env" S3_SECRET_ACCESS_KEY "$(dotenv_quote "$rustfs_secret_key")" replace_env_value "$tmp_env" S3_BUCKET "$(dotenv_quote "$s3_bucket")" replace_env_value "$tmp_env" S3_REGION "$(dotenv_quote "$s3_region")" replace_env_value "$tmp_env" S3_FORCE_PATH_STYLE true replace_env_value "$tmp_env" S3_HOST_ENDPOINT_URL "http://${rustfs_bind}:${rustfs_port}" replace_env_value "$tmp_env" RUSTFS_ACCESS_KEY "$(dotenv_quote "$rustfs_access_key")" replace_env_value "$tmp_env" RUSTFS_SECRET_KEY "$(dotenv_quote "$rustfs_secret_key")" replace_env_value "$tmp_env" RUSTFS_BIND_IP "$rustfs_bind" replace_env_value "$tmp_env" RUSTFS_PORT "$rustfs_port" replace_env_value "$tmp_env" RUSTFS_CONSOLE_BIND_IP "$rustfs_console_bind" replace_env_value "$tmp_env" RUSTFS_CONSOLE_PORT "$rustfs_console_port" replace_env_value "$tmp_env" RUSTFS_UID "$rustfs_uid" replace_env_value "$tmp_env" RUSTFS_GID "$rustfs_gid" replace_env_value "$tmp_env" RUSTFS_REGION "$s3_region" replace_env_value "$tmp_env" S3_PUBLIC_BASE_URL "$(dotenv_quote "$rustfs_public_url")" replace_env_value "$tmp_env" S3_PUBLIC_CONSOLE_URL "$(dotenv_quote "$rustfs_public_console_url")" ;; external) replace_env_value "$tmp_env" S3_STORAGE_BACKEND external replace_env_value "$tmp_env" S3_ENDPOINT_URL "$(dotenv_quote "$s3_endpoint")" replace_env_value "$tmp_env" S3_ACCESS_KEY_ID "$(dotenv_quote "$s3_access_key")" replace_env_value "$tmp_env" S3_SECRET_ACCESS_KEY "$(dotenv_quote "$s3_secret_key")" replace_env_value "$tmp_env" S3_BUCKET "$(dotenv_quote "$s3_bucket")" replace_env_value "$tmp_env" S3_REGION "$(dotenv_quote "$s3_region")" replace_env_value "$tmp_env" S3_HOST_ENDPOINT_URL "" ;; off) replace_env_value "$tmp_env" S3_STORAGE_BACKEND off ;; esac if [[ "$s3_choice" == off ]]; then replace_env_value "$tmp_env" OPENWEBUI_STORAGE_PROVIDER local else replace_env_value "$tmp_env" OPENWEBUI_STORAGE_PROVIDER s3 fi fi # The operator panel runs as the stack owner and joins the Docker socket # group so the mounted socket stays root:docker 0660. if [[ "$install_panel" == true ]]; then panel_docker_gid="$(stat -c '%g' /var/run/docker.sock 2>/dev/null || printf '984')" replace_env_value "$tmp_env" PANEL_RUN_AS "$execution_owner_uid:$execution_owner_gid" replace_env_value "$tmp_env" PANEL_DOCKER_GID "$panel_docker_gid" replace_env_value "$tmp_env" PANEL_BIND_IP "${panel_bind:-127.0.0.1}" replace_env_value "$tmp_env" PANEL_PORT "${panel_port:-8899}" # The recorded origin is what the console links to instead of the bind # address; an empty value keeps the LAN fallback. if [[ -n "$panel_public_host" ]]; then replace_env_value "$tmp_env" PANEL_PUBLIC_URL "https://$panel_public_host" fi [[ "$panel_cookie_secure" == true ]] \ && replace_env_value "$tmp_env" PANEL_COOKIE_SECURE true fi mv "$tmp_env" "$ENV_FILE" # Generate any v0.5.x placeholder secrets without rotating existing values. python3 - "$ENV_FILE" <<'PYV052' import secrets, sys p=sys.argv[1] lines=open(p,encoding='utf-8').read().splitlines(); out=[] force={'OMNIROUTE_MANAGEMENT_API_KEY','SMART_ROUTER_HMAC_SECRET','SMART_ROUTER_ADMIN_API_KEY','SMART_ROUTER_BOOTSTRAP_ADMIN_PASSWORD','SMART_ROUTER_PG_PASSWORD','SMART_ROUTER_CLIENT_API_KEY','OPENWEBUI_SECRET_KEY','N8N_ENCRYPTION_KEY'} for line in lines: if '=' not in line or line.lstrip().startswith('#'): out.append(line); continue k,v=line.split('=',1) raw=v.strip().strip('"') if k.endswith('_FILE'): out.append(line); continue if k == 'SMART_ROUTER_BOOTSTRAP_ADMIN_USER' and raw == 'admin': v=secrets.token_urlsafe(16).replace('-','_').replace('_','')[:20] elif raw.startswith('CHANGE_ME') or (k in force and not raw): n=24 if k.endswith('INITIAL_PASSWORD') or k.endswith('ADMIN_PASSWORD') else 48 v=secrets.token_urlsafe(n) out.append(f'{k}={v}') open(p,'w',encoding='utf-8').write('\n'.join(out)+'\n') PYV052 chmod 600 "$ENV_FILE" client_key="$(existing_env_value SMART_ROUTER_CLIENT_API_KEY)" if [[ "$install_smart_router" == true ]]; then # Trusted local clients authenticate to Smart Router, not directly to 9router. replace_env_value "$ENV_FILE" OPENWEBUI_OPENAI_API_KEY "$client_key" fi if [[ "$install_content" == true && "$install_smart_router" == true ]]; then # The Content Bot writer authenticates with the trusted Smart Router client key. replace_env_value "$ENV_FILE" CONTENT_WRITER_API_KEY "$(dotenv_quote "$client_key")" fi hermes_backend_key="$provider_key" [[ "$install_smart_router" == true ]] && hermes_backend_key="$client_key" if [[ "$configure_hermes" == true ]]; then config="$(<"$ROOT_DIR/templates/hermes-config.yaml.template")" config="${config//__PROVIDER_ID__/$(yaml_quote "custom:$provider_name")}" config="${config//__PROVIDER_NAME__/$(yaml_quote "$provider_name")}" config="${config//__PROVIDER_BASE_URL__/$(yaml_quote "$provider_url")}" config="${config//__MODEL_NAME__/$(yaml_quote "$model_name")}" config="${config//__MCP_SERVERS_BLOCK__/$(render_mcp_block)}" printf '%s\n' "$config" > "$HERMES_DIR/config.yaml" { printf 'TELEGRAM_BOT_TOKEN=%s\n' "$(dotenv_quote "$telegram_token")" printf 'TELEGRAM_ALLOWED_USERS=%s\n' "$telegram_ids" printf 'NINEROUTER_API_KEY=%s\n' "$(dotenv_quote "$hermes_backend_key")" if [[ "$install_nine" == true ]]; then printf 'NINEROUTER_URL=%s\n' "$(dotenv_quote "http://nine-router:20128")" printf 'NINEROUTER_KEY=%s\n' "$(dotenv_quote "$provider_key")" elif [[ "$install_omniroute" == true ]]; then printf 'NINEROUTER_URL=%s\n' "$(dotenv_quote "http://omniroute:20129")" printf 'NINEROUTER_KEY=%s\n' "$(dotenv_quote "$provider_key")" fi [[ -n "$telegram_home" ]] && printf 'TELEGRAM_HOME_CHANNEL=%s\n' "$telegram_home" printf 'API_SERVER_ENABLED=%s\n' "$api_enabled" if [[ "$api_enabled" == true ]]; then printf 'API_SERVER_HOST=0.0.0.0\n' printf 'API_SERVER_KEY=%s\n' "$api_key" printf 'API_SERVER_CORS_ORIGINS=[]\n' fi [[ -n "$n8n_trigger_mcp_token" ]] && printf 'N8N_TRIGGER_MCP_TOKEN=%s\n' "$n8n_trigger_mcp_token" [[ -n "$n8n_instance_mcp_token" ]] && printf 'N8N_INSTANCE_MCP_TOKEN=%s\n' "$n8n_instance_mcp_token" printf 'N8N_TRIGGER_MCP_URL=%s\n' "$(dotenv_quote "http://n8n:5678/mcp/hermes")" printf 'N8N_INSTANCE_MCP_URL=%s\n' "$(dotenv_quote "http://n8n:5678/mcp-server/http")" } > "$HERMES_DIR/.env" chmod 600 "$HERMES_DIR/.env" chmod 640 "$HERMES_DIR/config.yaml" chown "$hermes_uid:$hermes_gid" "$HERMES_DIR/.env" "$HERMES_DIR/config.yaml" fi # Enabling or disabling n8n on a later run must not force a full Hermes # reconfiguration, so patch the sentinel-delimited block in place instead. if [[ "$configure_hermes" != true && -f "$HERMES_DIR/config.yaml" \ && ( "$configure_n8n" == true \ || "$n8n_was_enabled" != "$install_n8n" \ || "$n8n_mcp_was_enabled" != "$install_n8n_mcp" \ || "$n8n_mcp_previous_mode" != "$n8n_mcp_mode" ) ]]; then tmp_config="$(mktemp "$HERMES_DIR/config.yaml.tmp.XXXXXX")" top_open_count="$(grep -c '^# >>> hermes-stack n8n mcp (managed) >>>$' "$HERMES_DIR/config.yaml" || true)" top_close_count="$(grep -c '^# <<< hermes-stack n8n mcp (managed) <<<$' "$HERMES_DIR/config.yaml" || true)" entry_open_count="$(grep -c '^ # >>> hermes-stack n8n mcp (managed) >>>$' "$HERMES_DIR/config.yaml" || true)" entry_close_count="$(grep -c '^ # <<< hermes-stack n8n mcp (managed) <<<$' "$HERMES_DIR/config.yaml" || true)" if (( top_open_count != top_close_count || entry_open_count != entry_close_count \ || top_open_count > 1 || entry_open_count > 1 \ || (top_open_count > 0 && entry_open_count > 0) )); then rm -f "$tmp_config" die "Hermes config has incomplete or duplicate managed n8n MCP markers; restore its backup before reconfiguring." fi if (( top_open_count == 1 )); then awk -v block="$(render_mcp_block)" ' BEGIN { skipping = 0 } $0 == "# >>> hermes-stack n8n mcp (managed) >>>" { skipping = 1; next } $0 == "# <<< hermes-stack n8n mcp (managed) <<<" { skipping = 0 if (block != "") print block next } skipping == 0 { print } ' "$HERMES_DIR/config.yaml" > "$tmp_config" elif (( entry_open_count == 1 )); then # An existing top-level mcp_servers map belongs to the user. Replace only # the installer-owned n8n entry inside it, preserving every other server. awk -v entry="$(render_mcp_entry)" ' BEGIN { skipping = 0 } $0 == " # >>> hermes-stack n8n mcp (managed) >>>" { skipping = 1; next } $0 == " # <<< hermes-stack n8n mcp (managed) <<<" { skipping = 0 if (entry != "") print entry next } skipping == 0 { print } ' "$HERMES_DIR/config.yaml" > "$tmp_config" elif [[ "$install_n8n_mcp" == true ]] && grep -q '^mcp_servers:[[:space:]]*$' "$HERMES_DIR/config.yaml"; then awk -v entry="$(render_mcp_entry)" ' { print } !inserted && /^mcp_servers:[[:space:]]*$/ { print entry; inserted = 1 } ' "$HERMES_DIR/config.yaml" > "$tmp_config" else cp "$HERMES_DIR/config.yaml" "$tmp_config" if [[ "$install_n8n_mcp" == true ]]; then printf '\n%s\n' "$(render_mcp_block)" >> "$tmp_config" fi fi chmod 640 "$tmp_config" chown "$hermes_uid:$hermes_gid" "$tmp_config" mv "$tmp_config" "$HERMES_DIR/config.yaml" if [[ -f "$HERMES_DIR/.env" ]]; then tmp_hermes_env="$(mktemp "$HERMES_DIR/.env.tmp.XXXXXX")" grep -v -E '^N8N_(MCP_|TRIGGER_MCP_|INSTANCE_MCP_)' "$HERMES_DIR/.env" > "$tmp_hermes_env" || true { printf 'N8N_TRIGGER_MCP_URL=%s\n' "$(dotenv_quote "http://n8n:5678/mcp/hermes")" printf 'N8N_INSTANCE_MCP_URL=%s\n' "$(dotenv_quote "http://n8n:5678/mcp-server/http")" [[ -n "$n8n_trigger_mcp_token" ]] && printf 'N8N_TRIGGER_MCP_TOKEN=%s\n' "$n8n_trigger_mcp_token" [[ -n "$n8n_instance_mcp_token" ]] && printf 'N8N_INSTANCE_MCP_TOKEN=%s\n' "$n8n_instance_mcp_token" } >> "$tmp_hermes_env" chmod 600 "$tmp_hermes_env" chown "$hermes_uid:$hermes_gid" "$tmp_hermes_env" mv "$tmp_hermes_env" "$HERMES_DIR/.env" fi fi if [[ "$configure_smart_router" == true && "$configure_hermes" != true \ && -f "$HERMES_DIR/config.yaml" ]]; then # Update only the active provider's base_url and the model default so Telegram # secrets and any additional custom providers stay untouched. tmp_config="$(mktemp "$HERMES_DIR/config.yaml.tmp.XXXXXX")" awk -v model="$model_name" -v url="$provider_url" -v provider="$provider_name" ' BEGIN { in_model = 0; in_providers = 0; active = 0 } /^[^[:space:]#]/ { in_model = ($0 ~ /^model:/) in_providers = ($0 ~ /^custom_providers:/) active = 0 } in_model && /^ default:/ { print " default: '\''" model "'\''"; next } in_providers && /^ - name:/ { line = $0 gsub(/^ - name:[[:space:]]*/, "", line) gsub(/^'\''|'\''$|^"|"$/, "", line) active = (line == provider) } in_providers && active && /^ base_url:/ { print " base_url: '\''" url "'\''" next } { print } ' "$HERMES_DIR/config.yaml" > "$tmp_config" chmod 640 "$tmp_config" chown "$hermes_uid:$hermes_gid" "$tmp_config" mv "$tmp_config" "$HERMES_DIR/config.yaml" fi # Normalize ownership even when Hermes itself was not reconfigured. This repairs # files previously rewritten by a root-run installer and keeps the uid/gid used # by the gateway process aligned with its bind-mounted configuration. if [[ "$install_hermes" == true ]]; then chown "$hermes_uid:$hermes_gid" "$HERMES_DIR" ensure_hermes_policy_config "$HERMES_DIR/config.yaml" if [[ -f "$HERMES_DIR/config.yaml" ]]; then chmod 640 "$HERMES_DIR/config.yaml" chown "$hermes_uid:$hermes_gid" "$HERMES_DIR/config.yaml" fi if [[ -f "$HERMES_DIR/.env" ]]; then chmod 600 "$HERMES_DIR/.env" chown "$hermes_uid:$hermes_gid" "$HERMES_DIR/.env" fi install -d -m 0750 -o "$hermes_uid" -g "$hermes_gid" \ "$HERMES_DIR/lazy-packages" "$HERMES_DIR/npm-packages" fi if [[ "$install_omniroute" == true && "$install_smart_router" == true ]]; then # OmniRoute has no 9router-style SQLite provisioning surface. Local clients # authenticate to the Smart Router client key, and OmniRoute endpoint keys are # operator-managed from its dashboard (./manage.sh set-backend-api-key KEY). [[ "$install_webui" == true ]] \ && replace_env_value "$ENV_FILE" OPENWEBUI_OPENAI_API_KEY "$(dotenv_quote "$client_key")" if [[ "$install_hermes" == true ]]; then replace_env_value "$HERMES_DIR/.env" NINEROUTER_API_KEY "$(dotenv_quote "$client_key")" replace_env_value "$HERMES_DIR/.env" NINEROUTER_URL "$(dotenv_quote "http://smart-router:8080")" replace_env_value "$HERMES_DIR/.env" NINEROUTER_KEY "$(dotenv_quote "$client_key")" fi fi if [[ "$install_omniroute" == true && "$install_smart_router" != true ]]; then if [[ "$install_hermes" == true ]]; then replace_env_value "$HERMES_DIR/.env" NINEROUTER_API_KEY "$(dotenv_quote "$provider_key")" replace_env_value "$HERMES_DIR/.env" NINEROUTER_URL "$(dotenv_quote "http://omniroute:20129")" replace_env_value "$HERMES_DIR/.env" NINEROUTER_KEY "$(dotenv_quote "$provider_key")" fi [[ "$install_webui" == true ]] \ && replace_env_value "$ENV_FILE" OPENWEBUI_OPENAI_API_KEY "$(dotenv_quote "${openwebui_api_key:-local-no-auth}")" fi if [[ "$configure_caddy" == true && "$install_caddy" == true ]]; then { if [[ -n "$caddy_email" ]]; then printf '{\n\temail %s\n}\n\n' "$caddy_email" fi if [[ -n "$caddy_nine_domain" ]]; then if [[ "$install_omniroute" == true ]]; then # OmniRoute splits the dashboard (20128) from its OpenAI-compatible API # (20129); /v1 is routed to the API port and everything else to the # dashboard on the same domain. printf '%s {\n' "$caddy_nine_domain" printf '\t@api path /v1*\n' printf '\tencode zstd gzip\n' printf '\treverse_proxy @api omniroute:20129\n' printf '\treverse_proxy omniroute:20128\n' printf '}\n\n' else printf '%s {\n\tencode zstd gzip\n\treverse_proxy nine-router:20128\n}\n\n' "$caddy_nine_domain" fi fi if [[ -n "$caddy_webui_domain" ]]; then printf '%s {\n\tencode zstd gzip\n\treverse_proxy open-webui:8080\n}\n\n' "$caddy_webui_domain" fi if [[ -n "$caddy_hermes_dashboard_domain" ]]; then printf '%s {\n\tencode zstd gzip\n\treverse_proxy hermes:9119\n}\n\n' "$caddy_hermes_dashboard_domain" fi if [[ -n "$caddy_hermes_api_domain" ]]; then printf '%s {\n\tencode zstd gzip\n\treverse_proxy hermes:8642\n}\n\n' "$caddy_hermes_api_domain" fi if [[ -n "$caddy_media_domain" ]]; then # Only the media subtree is published; nginx answers 404 for # everything else, including directory listings. printf '%s {\n\tencode zstd gzip\n\treverse_proxy ig-media:80\n}\n\n' "$caddy_media_domain" fi if [[ -n "$caddy_n8n_domain" ]]; then # MCP needs unbuffered streaming, so /mcp* is proxied without compression. printf '%s {\n' "$caddy_n8n_domain" printf '\t@mcp path /mcp*\n' printf '\treverse_proxy @mcp n8n:5678 {\n\t\tflush_interval -1\n\t}\n' printf '\tencode zstd gzip\n' printf '\treverse_proxy n8n:5678\n' printf '}\n\n' fi } > "$CADDY_DIR/Caddyfile" sed -i '${/^$/d;}' "$CADDY_DIR/Caddyfile" chmod 644 "$CADDY_DIR/Caddyfile" fi ok "Configuration generated." # Provision the Content Manager workspace. The repository's content/config/ is # the canonical source of truth; a working copy is seeded once so operator edits # survive later install runs. The directory is gitignored at runtime. if [[ "$DRY_RUN" != true ]] && [[ -d "$ROOT_DIR/content/config" ]]; then mkdir -p "$ROOT_DIR/data/content-manager/config" "$ROOT_DIR/data/content-manager/runtime" "$ROOT_DIR/data/content-manager/runtime-locks" chmod 1777 "$ROOT_DIR/data/content-manager/runtime-locks" 2>/dev/null || true : > "$ROOT_DIR/data/content-manager/runtime-locks/runtime-config.lock" chmod 666 "$ROOT_DIR/data/content-manager/runtime-locks/runtime-config.lock" 2>/dev/null || true seeded_content_config=false for policy_file in editorial-policy.yaml categories.yaml sources.yaml tools.json; do if [[ -f "$ROOT_DIR/content/config/$policy_file" \ && ! -f "$ROOT_DIR/data/content-manager/config/$policy_file" ]]; then cp "$ROOT_DIR/content/config/$policy_file" "$ROOT_DIR/data/content-manager/config/$policy_file" seeded_content_config=true fi done if [[ "$install_content" == true ]]; then install -d -m 0700 -o "$content_bot_uid" -g "$content_bot_gid" \ "$ROOT_DIR/data/content-bot" fi if [[ "$seeded_content_config" == true ]]; then info "Content Manager: seeded policy working copy under data/content-manager/config" fi if [[ -f "$ROOT_DIR/data/content-manager/config/runtime-config.json" \ && ! -f "$ROOT_DIR/data/content-manager/runtime/runtime-config.json" ]]; then cp "$ROOT_DIR/data/content-manager/config/runtime-config.json" \ "$ROOT_DIR/data/content-manager/runtime/runtime-config.json" chmod 644 "$ROOT_DIR/data/content-manager/runtime/runtime-config.json" 2>/dev/null || true info "Content Manager: migrated runtime config to data/content-manager/runtime" fi if [[ -f "$ROOT_DIR/data/content-manager/config/runtime-config.secrets.json" \ && ! -f "$ROOT_DIR/data/content-manager/runtime/runtime-config.secrets.json" ]]; then cp "$ROOT_DIR/data/content-manager/config/runtime-config.secrets.json" \ "$ROOT_DIR/data/content-manager/runtime/runtime-config.secrets.json" chmod 600 "$ROOT_DIR/data/content-manager/runtime/runtime-config.secrets.json" 2>/dev/null || true fi fi # The panel token is the only credential for the console; it lives outside # the repository tree and the panel stays on loopback until published. if [[ "$DRY_RUN" != true && "$install_panel" == true ]]; then install -d -m 0700 "$ROOT_DIR/data/panel" "$ROOT_DIR/data/panel/backups" # The panel mounts the stack backup directory so its Backups view can list # and create archives; create it as the stack owner before Docker does. if [[ ! -d "$ROOT_DIR-backups" ]]; then install -d -m 0700 -o "$execution_owner_uid" -g "$execution_owner_gid" "$ROOT_DIR-backups" fi if [[ ! -s "$ROOT_DIR/data/panel/token" ]]; then ( umask 077; random_hex 32 > "$ROOT_DIR/data/panel/token" ) info "Operator panel: generated data/panel/token (print it with ./manage.sh panel-token)" fi chmod 600 "$ROOT_DIR/data/panel/token" 2>/dev/null || true fi if [[ "$DRY_RUN" != true && "$install_media" == true ]]; then install -d -m 0700 -o "$media_uid" -g "$media_gid" \ "$ROOT_DIR/data/media-studio" fi # The NotebookLM worker is enabled by ./manage.sh; keep its data directory # owned by the identity that container runs as. if [[ "$DRY_RUN" != true ]] && profile_enabled notebooklm; then notebooklm_run_as="$(existing_env_value NOTEBOOKLM_RUN_AS)" if [[ -z "$notebooklm_run_as" ]]; then if [[ "$invoking_uid" == 0 ]]; then notebooklm_run_as="10006:10006" else notebooklm_run_as="$invoking_uid:$invoking_gid" fi fi install -d -m 0700 -o "${notebooklm_run_as%%:*}" -g "${notebooklm_run_as##*:}" \ "$ROOT_DIR/data/notebooklm-worker" fi # RustFS keeps objects and logs in one owner-only tree. The container runs as # RUSTFS_UID/RUSTFS_GID, so the bind mounts must already belong to that user. if [[ "$DRY_RUN" != true && "$install_rustfs" == true ]]; then install -d -m 0700 -o "$rustfs_uid" -g "$rustfs_gid" \ "$ROOT_DIR/data/rustfs/data" "$ROOT_DIR/data/rustfs/logs" fi if [[ "$DRY_RUN" == true ]]; then ok "Dry run complete. Docker was not changed." exit 0 fi if [[ "$NO_START" == true ]]; then if command -v docker >/dev/null 2>&1 && docker compose version >/dev/null 2>&1; then docker compose -f "$ROOT_DIR/docker-compose.yml" --env-file "$ENV_FILE" config --quiet \ && ok "Docker Compose configuration is valid." \ || warn "Docker Compose validation failed; review the generated configuration before starting." else warn "Docker is not available; skipped Compose validation because --no-start was requested." fi if [[ "$install_nine" == true && ( "$install_hermes" == true || "$install_webui" == true ) ]]; then warn "Automatic 9router key/model provisioning requires a normal installer run when services are started." fi ok "Configuration complete. Start later with ./manage.sh start" exit 0 fi detect_docker "${DOCKER[@]}" compose -f "$ROOT_DIR/docker-compose.yml" --env-file "$ENV_FILE" config --quiet ok "Docker Compose configuration is valid." info "Pulling selected container images..." "${DOCKER[@]}" compose -f "$ROOT_DIR/docker-compose.yml" --env-file "$ENV_FILE" pull openwebui_key_status="" hermes_key_status="" opencode_combo_status="not-requested" ai_combo_status="not-requested" opencode_free_model_count="0" if [[ "$install_nine" == true && ( "$install_hermes" == true || "$install_webui" == true ) ]]; then openwebui_db_preexisting=false [[ -f "$OPENWEBUI_DIR/webui.db" ]] && openwebui_db_preexisting=true info "Starting 9router to provision service access..." "${DOCKER[@]}" compose -f "$ROOT_DIR/docker-compose.yml" --env-file "$ENV_FILE" up -d --no-deps nine-router # 9router answers /api/health before it has created and migrated its SQLite # database, so a health probe alone lets the installer race ahead and fail in # the provisioning script. Also require the tables that script writes to. nine_ready=false for _ in {1..90}; do if "${DOCKER[@]}" exec nine-router node -e \ 'const Database=require("node:module").createRequire("/app/package.json")("better-sqlite3"); const db=new Database("/app/data/db/data.sqlite",{readonly:true,fileMustExist:true}); try{db.prepare("SELECT 1 FROM apiKeys LIMIT 1").get();db.prepare("SELECT 1 FROM combos LIMIT 1").get();}finally{db.close();} fetch("http://127.0.0.1:20128/api/health").then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))' \ >/dev/null 2>&1; then nine_ready=true break fi sleep 2 done [[ "$nine_ready" == true ]] || die "9router did not become ready for service provisioning." info "Generating/reusing dedicated 9router keys and configuring model combos..." bootstrap_output="$("${DOCKER[@]}" exec -i \ -e PROVISION_HERMES="$install_hermes" \ -e PROVISION_OPENWEBUI="$install_webui" \ -e PROVISION_SMART_ROUTER="$install_smart_router" \ -e SMART_ROUTER_FAST_MODEL="$smart_router_fast_model" \ -e SMART_ROUTER_STANDARD_MODEL="$smart_router_standard_model" \ -e SMART_ROUTER_STRONG_MODEL="$smart_router_strong_model" \ -e SMART_ROUTER_VISION_MODEL="$smart_router_vision_model" \ -e HERMES_MODEL_NAME="$model_name" \ nine-router node --input-type=module < "$ROOT_DIR/scripts/bootstrap-openwebui.mjs")" openwebui_api_key="$(sed -n 's/^OPENWEBUI_API_KEY=//p' <<< "$bootstrap_output" | tail -n1)" openwebui_key_status="$(sed -n 's/^OPENWEBUI_KEY_STATUS=//p' <<< "$bootstrap_output" | tail -n1)" hermes_api_key="$(sed -n 's/^HERMES_API_KEY=//p' <<< "$bootstrap_output" | tail -n1)" hermes_key_status="$(sed -n 's/^HERMES_KEY_STATUS=//p' <<< "$bootstrap_output" | tail -n1)" opencode_combo_status="$(sed -n 's/^OPENCODE_COMBO_STATUS=//p' <<< "$bootstrap_output" | tail -n1)" ai_combo_status="$(sed -n 's/^AI_COMBO_STATUS=//p' <<< "$bootstrap_output" | tail -n1)" smart_router_combo_status="$(sed -n 's/^SMART_ROUTER_COMBO_STATUS=//p' <<< "$bootstrap_output" | tail -n1)" opencode_free_model_count="$(sed -n 's/^OPENCODE_FREE_MODEL_COUNT=//p' <<< "$bootstrap_output" | tail -n1)" if [[ "$install_smart_router" == true ]]; then # One backend key is used only by Smart Router upstream. Local clients use the router client key. [[ -n "$hermes_api_key" ]] || die "9router did not return an upstream API key for Smart Router." replace_env_value "$ENV_FILE" SMART_ROUTER_UPSTREAM_API_KEY "$(dotenv_quote "$hermes_api_key")" [[ "$install_webui" == true ]] && replace_env_value "$ENV_FILE" OPENWEBUI_OPENAI_API_KEY "$(dotenv_quote "$client_key")" if [[ "$install_hermes" == true ]]; then replace_env_value "$HERMES_DIR/.env" NINEROUTER_API_KEY "$(dotenv_quote "$client_key")" replace_env_value "$HERMES_DIR/.env" NINEROUTER_URL "$(dotenv_quote "http://smart-router:8080")" replace_env_value "$HERMES_DIR/.env" NINEROUTER_KEY "$(dotenv_quote "$client_key")" fi else if [[ "$install_webui" == true ]]; then [[ -n "$openwebui_api_key" ]] || die "9router did not return the generated Open WebUI API key." replace_env_value "$ENV_FILE" OPENWEBUI_OPENAI_API_KEY "$(dotenv_quote "$openwebui_api_key")" fi if [[ "$install_hermes" == true ]]; then [[ -n "$hermes_api_key" ]] || die "9router did not return the generated Hermes API key." replace_env_value "$HERMES_DIR/.env" NINEROUTER_API_KEY "$(dotenv_quote "$hermes_api_key")" replace_env_value "$HERMES_DIR/.env" NINEROUTER_URL "$(dotenv_quote "http://nine-router:20128")" replace_env_value "$HERMES_DIR/.env" NINEROUTER_KEY "$(dotenv_quote "$hermes_api_key")" fi fi fi if [[ "$configure_caddy" == true && "$install_caddy" == true ]]; then info "Validating generated Caddy configuration..." "${DOCKER[@]}" compose -f "$ROOT_DIR/docker-compose.yml" --env-file "$ENV_FILE" run --rm --no-deps caddy caddy validate --config /etc/caddy/Caddyfile fi if [[ "$smart_router_was_enabled" == true && "$install_smart_router" != true ]]; then info "Stopping the disabled Hermes Smart Router..." COMPOSE_PROFILES=smart-router "${DOCKER[@]}" compose \ -f "$ROOT_DIR/docker-compose.yml" --env-file "$ENV_FILE" \ rm -sf smart-router smart-router-init router-upstream-probe fi if [[ "$nine_was_enabled" == true && "$install_nine" != true ]]; then info "Removing the disabled 9router backend (data/9router is preserved)..." COMPOSE_PROFILES=9router "${DOCKER[@]}" compose \ -f "$ROOT_DIR/docker-compose.yml" --env-file "$ENV_FILE" \ rm -sf nine-router fi if [[ "$omniroute_was_enabled" == true && "$install_omniroute" != true ]]; then info "Removing the disabled OmniRoute backend (data/omniroute is preserved)..." COMPOSE_PROFILES=omniroute "${DOCKER[@]}" compose \ -f "$ROOT_DIR/docker-compose.yml" --env-file "$ENV_FILE" \ rm -sf omniroute fi if [[ "$n8n_was_enabled" == true && "$install_n8n" != true ]]; then info "Stopping disabled n8n (data/n8n is preserved)..." COMPOSE_PROFILES=n8n "${DOCKER[@]}" compose \ -f "$ROOT_DIR/docker-compose.yml" --env-file "$ENV_FILE" \ rm -sf n8n n8n-init fi if [[ "$content_was_enabled" == true && "$install_content" != true ]]; then info "Stopping disabled Content Bot (data/content-bot is preserved)..." COMPOSE_PROFILES=content "${DOCKER[@]}" compose \ -f "$ROOT_DIR/docker-compose.yml" --env-file "$ENV_FILE" \ rm -sf content-bot fi if [[ "$media_was_enabled" == true && "$install_media" != true ]]; then info "Stopping disabled Media Studio (data/media-studio is preserved)..." COMPOSE_PROFILES=media "${DOCKER[@]}" compose \ -f "$ROOT_DIR/docker-compose.yml" --env-file "$ENV_FILE" \ rm -sf media-studio fi info "Starting selected services..." "${DOCKER[@]}" compose -f "$ROOT_DIR/docker-compose.yml" --env-file "$ENV_FILE" up -d --build --remove-orphans if [[ "$CONTENT_RECONFIGURE" != true && "$MEDIA_RECONFIGURE" != true ]]; then enable_hermes_telegram_policy_toolsets fi if [[ "$install_smart_router" == true ]]; then info "Waiting for the Hermes Smart Router to become ready..." smart_router_ready=false for _ in {1..60}; do if "${DOCKER[@]}" exec hermes-smart-router python -c \ 'import urllib.request; urllib.request.urlopen("http://127.0.0.1:8080/ready", timeout=5)' \ >/dev/null 2>&1; then smart_router_ready=true break fi sleep 2 done [[ "$smart_router_ready" == true ]] || die "Hermes Smart Router did not become ready." fi if [[ "$install_n8n" == true ]]; then info "Waiting for n8n to become ready..." n8n_ready=false for _ in {1..60}; do if "${DOCKER[@]}" exec hermes-n8n node -e \ "fetch('http://127.0.0.1:5678/healthz').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))" \ >/dev/null 2>&1; then n8n_ready=true break fi sleep 2 done [[ "$n8n_ready" == true ]] || warn "n8n did not report healthy yet; check ./manage.sh logs n8n." fi # The owner/API/MCP credentials are user-bound credentials n8n creates only # after its first owner/admin account exists. Start n8n first, then keep this # install session open while the operator creates them in the browser and pastes # them into hidden validation prompts. if [[ "$install_n8n" == true && "$install_hermes" == true && "${n8n_ready:-false}" == true \ && "${n8n_mcp_mode:-off}" != off && ( "$configure_n8n" == true || "$configure_hermes" == true ) ]]; then printf '\nn8n post-start provisioning\n' printf '%s\n' '---------------------------' printf 'n8n is running at: %s\n' "$n8n_public_url" printf '%s\n' 'The owner API key and Instance MCP token are created by n8n only after the owner/admin account exists.' if confirm "Finish n8n owner/API/MCP provisioning now in this wizard?" y; then printf '%s\n' '1. Open the n8n URL above and create/confirm the owner account.' printf '%s\n' '2. Create an owner API key in n8n for stack-managed workflow reconciliation.' read -r -p 'Press Enter when the owner account and API key are ready... ' _ if "$ROOT_DIR/manage.sh" set-n8n-api-key; then n8n_api_ready=true else n8n_api_ready=false warn "The n8n owner API key was not stored. Retry later with ./manage.sh n8n-menu." fi if [[ "$n8n_mcp_mode" == instance ]]; then printf '%s\n' '3. In n8n: Settings -> Instance-level MCP -> enable MCP access.' printf '%s\n' '4. Open Connection details -> Access Token, generate/copy the personal MCP token.' read -r -p 'Press Enter when the Instance MCP token is ready... ' _ if [[ "$n8n_api_ready" == true ]]; then if "$ROOT_DIR/manage.sh" set-n8n-instance-mcp-token; then ok "n8n Instance MCP token stored; managed n8n objects reconciled and verified." else warn "Instance MCP setup is incomplete. Retry with ./manage.sh n8n-menu." fi else warn "Skipping Instance token activation until the owner API key is stored, avoiding a half-reconciled managed n8n state." fi elif [[ "$n8n_mcp_mode" == trigger && "$n8n_api_ready" == true ]]; then if "$ROOT_DIR/manage.sh" bootstrap-n8n; then ok "Managed n8n hosted chat and Trigger MCP workflow reconciled and verified." else warn "Managed n8n provisioning is incomplete. Retry with ./manage.sh n8n-menu." fi fi else warn "n8n post-start provisioning skipped. Resume later with ./manage.sh n8n-menu." fi fi if [[ "$install_nine" == true && "$install_webui" == true ]]; then if [[ "$openwebui_db_preexisting" == true || "$configure_smart_router" == true ]]; then info "Synchronizing the Open WebUI backend connection without deleting its data..." webui_db_ready=false for _ in {1..60}; do if "${DOCKER[@]}" exec open-webui python -c \ 'import sqlite3; db=sqlite3.connect("/app/backend/data/webui.db"); db.execute("select 1 from config limit 1").fetchone(); db.close()' \ >/dev/null 2>&1; then webui_db_ready=true break fi sleep 2 done [[ "$webui_db_ready" == true ]] || die "Open WebUI database did not become ready for connection migration." "${DOCKER[@]}" exec -i open-webui python < "$ROOT_DIR/scripts/sync-openwebui-config.py" "${DOCKER[@]}" compose -f "$ROOT_DIR/docker-compose.yml" --env-file "$ENV_FILE" restart open-webui fi if [[ "$opencode_combo_status" != unavailable ]]; then info "Verifying Open WebUI can authenticate to 9router and discover OpenCode-Free..." "${DOCKER[@]}" exec -i open-webui python < "$ROOT_DIR/scripts/verify-openwebui-backend.py" else warn "The 9router key is configured, but OpenCode-Free could not be refreshed from the upstream catalog." fi fi printf '\n' ok "Installation complete." if [[ -n "${base_domain:-}" ]]; then printf '%s\n' "Public domain zone: $base_domain (services are named .$base_domain)" printf '%s\n' 'Public route checklist: ./manage.sh domains (host, .env key, LAN target and proxy block per service)' fi [[ "$install_nine" == true ]] && printf '9router dashboard: %s\n' "$nine_public_url" [[ "$install_omniroute" == true ]] && printf 'OmniRoute dashboard: %s\n' "$omni_public_url" [[ "$install_omniroute" == true ]] && printf 'OmniRoute OpenAI API: http://%s:%s/v1\n' "$(service_url_host "$omni_api_bind")" "$omni_api_port" if [[ -n "$hermes_key_status" ]]; then printf 'Hermes 9router key: %s (stored securely; not printed)\n' "$hermes_key_status" if [[ "$model_name" == ai || "$install_smart_router" == true ]]; then printf 'Hermes ai combo: %s (%s free upstream models)\n' "$ai_combo_status" "$opencode_free_model_count" fi fi if [[ "$install_smart_router" == true ]]; then printf 'Hermes Smart Router: enabled (%s mode, %s policy)\n' "$smart_router_mode" "$smart_router_policy" printf 'Smart Router API: http://%s:%s/v1\n' "$(service_url_host "$smart_router_bind")" "$smart_router_port" [[ "$smart_router_dashboard_enabled" == true ]] && printf 'Smart Router dashboard: http://%s:%s/dashboard\n' "$(service_url_host "$smart_router_bind")" "$smart_router_port" [[ "$smart_router_control_plane_enabled" == true ]] && printf 'Smart Router control plane: http://%s:%s/control/\n' "$(service_url_host "$smart_router_bind")" "$smart_router_port" [[ "$smart_router_control_plane_enabled" == true ]] && printf 'Control-plane access: ./manage.sh router-access\n' printf '%s\n' 'Smart Router public ingress (optional, needs a domain): docs/SMART-ROUTER-PUBLIC-INGRESS.md' if [[ -n "$smart_router_combo_status" ]]; then printf 'Smart Router tier combos: %s (initially cloned from ai)\n' "$smart_router_combo_status" fi if [[ "$install_omniroute" == true ]]; then printf 'Smart Router route profiles: %s / %s / %s\n' \ "$smart_router_fast_model" "$smart_router_standard_model" "$smart_router_strong_model" info "Resolve auto/best-* aliases in the OmniRoute dashboard before enabling route mode." else warn "Customize combo-fast, combo-standard, combo-strong, and combo-vision in 9router before enabling route mode." fi fi if [[ "$install_hermes" == true && -n "$telegram_token" ]]; then printf '%s\n' 'Telegram: open your bot and send /start' fi if [[ "$hermes_dashboard" == 1 ]]; then printf 'Hermes dashboard: http://%s:%s\n' "$(service_url_host "$hermes_bind")" "$hermes_dashboard_port" printf 'Hermes dashboard username: %s\n' "$hermes_dashboard_username" if [[ "$hermes_dashboard_credentials_created" == true ]]; then printf 'Hermes dashboard password (save now): %s\n' "$hermes_dashboard_password" else printf '%s\n' 'Hermes dashboard password: preserved (not printed again)' fi printf '%s\n' 'Later access: ./manage.sh dashboard-access (add --show-password on a trusted terminal)' if [[ "$hermes_bind" == 127.0.0.1 ]]; then printf 'Remote access tunnel: ssh -L %s:127.0.0.1:%s USER@SERVER\n' \ "$hermes_dashboard_port" "$hermes_dashboard_port" else warn "Basic Auth protects the dashboard, but plain HTTP does not encrypt credentials. Prefer HTTPS through Caddy or restrict access to a trusted LAN." fi fi [[ "$configure_hermes" == true && "$api_enabled" == true ]] && printf 'Hermes API key (save now): %s\n' "$api_key" if [[ "$install_webui" == true ]]; then printf 'Open WebUI: %s\n' "$openwebui_url" if [[ -n "$openwebui_key_status" ]]; then printf 'Open WebUI 9router key: %s (stored securely; not printed)\n' "$openwebui_key_status" printf 'OpenCode-Free model: %s (%s free upstream models)\n' "$opencode_combo_status" "$opencode_free_model_count" fi [[ "$openwebui_signup" == true ]] && printf '%s\n' 'Open WebUI: the first registered account becomes administrator; disable signup afterward.' fi if [[ "$install_n8n" == true ]]; then printf 'n8n: %s\n' "$n8n_public_url" printf '%s\n' 'n8n: the first visitor claims the owner account; create it now.' printf '%s\n' 'n8n encryption key: stored in .env as N8N_ENCRYPTION_KEY (back it up with data/n8n).' case "$n8n_mcp_mode" in instance) printf '%s\n' 'Hermes n8n MCP mode: Instance-level (http://n8n:5678/mcp-server/http)' if [[ -n "$(sed -n 's/^N8N_INSTANCE_MCP_TOKEN=//p' "$HERMES_DIR/.env" 2>/dev/null | tail -n1)" ]]; then printf '%s\n' 'Instance MCP token: configured (secret not printed)' else printf '%s\n' 'Instance MCP token: pending; use ./manage.sh n8n-menu to finish setup.' fi ;; trigger) printf '%s\n' 'Hermes n8n MCP mode: MCP Server Trigger (http://n8n:5678/mcp/hermes)' ;; esac if [[ -z "${caddy_n8n_domain:-}" ]]; then printf '%s\n' 'n8n webhooks: no public domain is configured. External triggers need a public' printf '%s\n' 'HTTPS URL; add one later with ./manage.sh configure (Caddy domains) and keep N8N_PUBLIC_URL in sync.' fi printf '%s\n' 'n8n provisioning manager: ./manage.sh n8n-menu' fi if [[ "$install_caddy" == true ]]; then [[ -n "$caddy_nine_domain" ]] && printf '%s HTTPS: https://%s\n' "$(router_backend_label)" "$caddy_nine_domain" [[ -n "$caddy_webui_domain" ]] && printf 'Open WebUI HTTPS: https://%s\n' "$caddy_webui_domain" [[ -n "$caddy_hermes_dashboard_domain" ]] && printf 'Hermes dashboard HTTPS: https://%s\n' "$caddy_hermes_dashboard_domain" [[ -n "$caddy_hermes_api_domain" ]] && printf 'Hermes API HTTPS: https://%s\n' "$caddy_hermes_api_domain" [[ -n "$caddy_n8n_domain" ]] && printf 'n8n HTTPS: https://%s\n' "$caddy_n8n_domain" printf '%s\n' 'Caddy needs public DNS plus inbound TCP 80/443 and UDP 443.' if [[ -n "$caddy_nine_domain$caddy_webui_domain$caddy_hermes_dashboard_domain$caddy_hermes_api_domain$caddy_n8n_domain$caddy_media_domain" ]]; then printf 'DNS: point these hostnames at the router public IP (or this host) and route 80/443 here:' for caddy_domain in "$caddy_nine_domain" "$caddy_webui_domain" "$caddy_hermes_dashboard_domain" \ "$caddy_hermes_api_domain" "$caddy_n8n_domain" "$caddy_media_domain"; do [[ -n "$caddy_domain" ]] && printf ' %s' "$caddy_domain" done printf '\n' fi fi if [[ -d "$ROOT_DIR/content/config" ]]; then printf '%s\n' 'Content Manager: editorial policy working copy -> data/content-manager/config' printf '%s\n' 'Content Manager: pipeline source, policy, and operations notes -> content/README.md' fi if [[ "$install_content" == true ]]; then printf '%s\n' 'Content Bot: Telegram approve/publish bot enabled' printf '%s\n' 'Content Bot: send a link to the bot in Telegram to draft an approved post' printf '%s\n' 'Content Bot guide: docs/CONTENT-PRODUCTION-GUIDE.md' printf '%s\n' 'Instagram/Meta setup (optional, pending): docs/INSTAGRAM-SETUP.md' if [[ -n "$caddy_media_domain" ]]; then printf '%s\n' "Instagram media URL: https://$caddy_media_domain/media/" elif [[ -n "$content_media_public_url" ]]; then printf '%s\n' "Instagram media URL: $content_media_public_url/media/" printf '%s\n' "Instagram media DNS: point ${content_media_public_url#*//} at the router public IP and route it to http://$content_media_bind:$content_media_port" elif [[ -n "$content_media_bind" && "$content_media_bind" != 127.0.0.1 ]]; then printf '%s\n' "Instagram media origin: http://$content_media_bind:$content_media_port (public URL comes from the proxy in front)" printf '%s\n' 'Instagram media: set INSTAGRAM_MEDIA_PUBLIC_BASE_URL once the public hostname exists (./manage.sh instagram-media-enable).' else printf '%s\n' 'Instagram media host (optional): ./manage.sh instagram-media-enable' fi printf '%s\n' 'Content Bot status: ./manage.sh content-status' fi if [[ "$install_media" == true ]]; then printf '%s\n' 'Media Studio: media generation worker enabled' printf '%s\n' 'Media Studio guide: docs/MEDIA-STUDIO.md' printf '%s\n' 'Media Studio status: ./manage.sh media-status' fi if [[ "$install_content" == true && "$install_media" == true ]]; then printf '%s\n' 'Content pipeline: Content Bot and Media Studio are wired on this server' printf '%s\n' 'Content pipeline status: ./manage.sh pipeline-status' fi if [[ "$install_rustfs" == true ]]; then printf '%s\n' 'Object storage: RustFS (S3-compatible) is bundled with this stack' printf '%s\n' "Object storage API: http://${rustfs_bind:-127.0.0.1}:${rustfs_port:-9000}" if [[ -n "${rustfs_public_url:-}" ]]; then printf '%s\n' "Object storage public origin: $rustfs_public_url (proxy target http://${rustfs_bind}:${rustfs_port})" printf '%s\n' "Object storage DNS: point ${rustfs_public_url#*//} at the router public IP and route it to http://${rustfs_bind}:${rustfs_port}" fi printf '%s\n' "RustFS console: http://${rustfs_console_bind:-127.0.0.1}:${rustfs_console_port:-9001}/rustfs/console/" if [[ -n "${rustfs_public_console_url:-}" ]]; then printf '%s\n' "RustFS console public origin: $rustfs_public_console_url (proxy target http://${rustfs_console_bind}:${rustfs_console_port})" fi printf '%s\n' 'Object storage bucket: '"${s3_bucket:-locallab}"' (credentials live in .env)' printf '%s\n' 'Object storage status: ./manage.sh s3-status' printf '%s\n' 'Object storage guide: docs/S3-STORAGE.md' fi if [[ "$s3_change" == true && "$s3_choice" == external ]]; then printf '%s\n' "Object storage: external S3 endpoint ${s3_endpoint:-} (bucket ${s3_bucket:-})" printf '%s\n' 'Object storage status: ./manage.sh s3-status' printf '%s\n' 'Object storage guide: docs/S3-STORAGE.md' fi if [[ "$install_panel" == true ]]; then printf '%s\n' 'Operator panel: web console for stack status, configuration, logs and actions' printf '%s\n' "Operator panel URL: http://${panel_bind:-127.0.0.1}:${panel_port:-8899}/" if [[ -n "${panel_bind:-}" && "${panel_bind}" != 127.0.0.1 ]]; then printf '%s\n' "Operator panel reverse proxy target: http://${panel_bind}:${panel_port:-8899}" fi if [[ -n "${panel_public_host:-}" ]]; then printf '%s\n' "Operator panel public origin: https://$panel_public_host (route it to http://${panel_bind}:${panel_port:-8899})" printf '%s\n' 'Operator panel DNS: create the A record and keep the console behind its token (PANEL_COOKIE_SECURE=true).' fi printf '%s\n' 'Operator panel token: ./manage.sh panel-token' printf '%s\n' 'Operator panel guide: docs/PANEL.md' fi printf '%s\n' 'Status: ./manage.sh status' printf '%s\n' 'Logs: ./manage.sh logs'