install.sh
Rama main · réplica de lectura del repositorio original.
#!/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 >/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
# <service>.<base domain>, 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 <service>.<base domain>, 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 <service>.$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/<file>"
elif [[ -n "$content_media_public_url" ]]; then
printf '%s\n' "Instagram media URL: $content_media_public_url/media/<file>"
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:-<unset>} (bucket ${s3_bucket:-<unset>})"
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'