#!/usr/bin/env bash set -Eeuo pipefail ROOT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" ENV_FILE="$ROOT_DIR/.env" HERMES_ENV="$ROOT_DIR/data/hermes/.env" STACK_SECRETS_DIR="$ROOT_DIR/data/stack-secrets" N8N_BOOTSTRAP_ENV="$STACK_SECRETS_DIR/n8n-bootstrap.env" N8N_BOOTSTRAP_STATE="$STACK_SECRETS_DIR/n8n-bootstrap-state.json" OMNIROUTE_N8N_KEY_ENV="$STACK_SECRETS_DIR/omniroute-n8n-router.env" HERMES_DASHBOARD_ACCESS_FILE="$STACK_SECRETS_DIR/hermes-dashboard-access.env" TEMP_SECRET_FILES=() # The operator panel mounts the repository at the identical host path so the # Docker CLI inside the container resolves compose bind mounts exactly like a # host invocation, and it writes .env/config files as the invoking user. export PANEL_STACK_PATH="${PANEL_STACK_PATH:-$ROOT_DIR}" export PANEL_RUN_AS="${PANEL_RUN_AS:-$(id -u):$(id -g)}" if [[ -z "${PANEL_DOCKER_GID:-}" && -S /var/run/docker.sock ]]; then PANEL_DOCKER_GID="$(stat -c '%g' /var/run/docker.sock 2>/dev/null || printf '984')" export PANEL_DOCKER_GID fi cleanup_temp_secrets() { local file for file in "${TEMP_SECRET_FILES[@]}"; do rm -rf -- "$file" done } trap cleanup_temp_secrets EXIT usage() { cat <<'EOF' Hermes Linux Stack Manager v0.5.9 Usage: ./manage.sh Open the interactive manager ./manage.sh menu Open the interactive manager ./manage.sh help Show this grouped command reference Interactive groups: services Containers, status, start/stop/restart, logs router Smart Router dashboard, routing, health, policy hermes Hermes Agent, Telegram, API and agent settings n8n n8n provisioning and MCP integration content Content Bot status, publishing docs and platform setup media Media Studio status, jobs, logs and reconfiguration execution Sandbox, Docker execution, SSH and approvals maintenance Update, backups, restore and rollback panel Operator console: status, config, logs, actions security Diagnostics, image integrity and access info Common direct commands: status Show container status health [--json] Show per-service health logs [SERVICE] Follow logs (hermes/9router/omniroute/smart-router/webui/n8n/content/media/notebooklm/caddy/rustfs) doctor Run diagnostics and hardening checks migrate-hermes-permissions [--dry-run] Repair Hermes log ownership/mode under data/hermes/logs dashboard-access [--show-password] Hermes dashboard URL, username and optional password configure Re-run the interactive installer uninstall [--purge] Remove containers; --purge also removes local runtime data Smart Router automation: set-router-mode MODE observe | route router-policy POLICY heuristic | calibrated | learned router-status Mode, policy, active features and URLs router-access [--show-secrets] Dashboard/control URLs and local credentials router-summary [HOURS] Authenticated telemetry summary router-routes Route profiles router-provider-health Provider/model health and circuit state router-system Operations Center system/feature state router-info Runtime router information router-calibrate FILE Build calibrated policy from labeled JSONL router-report FILE Evaluate policy against labeled JSONL router-replay FILE [OUT] Replay requests offline n8n automation: n8n-status Provisioning/MCP status without secrets set-n8n-api-key Store owner API key set-n8n-instance-mcp-token Store/validate Instance MCP token set-n8n-mcp-mode MODE instance | trigger | off bootstrap-n8n Bootstrap/reconcile managed n8n objects reconcile-n8n Reconcile managed n8n objects verify-n8n Verify hosted chat and MCP integration rotate-n8n-trigger-token Rotate Trigger-mode bearer token Content Bot automation: content-status Content Bot configuration summary (no secrets) content-connect-instagram Print the pending Instagram/Meta setup checklist content-connect-bale Store and verify the Bale bot token and channel content-connect-eitaa Store and verify the Eitaa bot token and channel content-connect-linkedin Store and verify the LinkedIn token and author content-aparat-check Probe the stored Aparat browser session content-channels Show the automatic channel (Bale/Eitaa/LinkedIn/Aparat) state content-configure Reconfigure Content Bot settings (installer wizard) Operator panel: panel-status Panel URL, profile and token state (no secrets) panel-enable Enable the panel profile and start the console panel-disable Stop the panel and disable its profile panel-token Print the operator token (creates one if missing) panel-rotate-token Replace the operator token and restart the panel panel-build Build the panel image locally from panel/Dockerfile domains Public host names, .env keys, LAN targets and proxy blocks Shared object storage (S3): s3-status Backend, endpoints, bucket and per-service state (no secrets) s3-enable [--rustfs|--external] [--bind-ip IP] Enable the shared S3 block and start the bundled RustFS server s3-disable Stop the bundled server and switch services back to local storage s3-verify [--create-bucket] Prove the endpoint, credentials and bucket with signed requests s3-keys [--show-secrets|--rotate] Show or rotate the RustFS credentials shared with the stack s3-guide Public-domain route and external-provider checklist Instagram media host (public address the Meta Graph API downloads media from): instagram-media-status Show the public media URL and profile state instagram-media-enable [--nginx-only|--named|--quick] Serve data/content-bot/media publicly (profile "ig-media") instagram-media-disable Stop the public media host and disable its profile instagram-media-tunnel-off Stop only the public tunnel (keep nginx for a proxy) instagram-media-verify Check that the media URL is downloadable, including by Meta Media Studio automation: media-status Media Studio configuration summary (no secrets) media-guide Print the Media Studio setup and API guide pointer media-configure Reconfigure Media Studio settings (installer wizard) NotebookLM video automation: notebooklm-status NotebookLM worker, session mode and bot link (no secrets) notebooklm-guide Print the NotebookLM guide pointer notebooklm-login [SECONDS] Open NotebookLM in the worker browser and verify the sign-in notebooklm-login-google Attempt automated sign-in with Google credentials from .env notebooklm-enable Enable the profile, store the shared token and start the worker notebooklm-disable Stop the worker and disable its profile notebooklm-import-session Import a session JSON file into the browser profile Backup and restore automation: backup [--only SECTION[,...]] [--destination DIR] [--label NAME] Full-stack or per-section backup archive backup-sections List the section names accepted by --only backup-list List archives with the sections they contain restore ARCHIVE [--no-start] [--no-relocate] Restore a full or partial archive; host paths in a restored .env are rewritten to this checkout Content pipeline automation (Content Bot + Media Studio on one server): pipeline-status Combined Content Bot, Media Studio, and API link status Advanced commands remain backward compatible. Use the interactive groups for normal administration; use direct commands for automation and scripts. EOF } case "${1:-}" in -h|--help|help) usage; exit 0 ;; "") set -- menu ;; esac [[ -f "$ENV_FILE" ]] || { printf 'Not configured. Run ./install.sh first.\n' >&2 exit 1 } if [[ "${1:-}" == migrate-hermes-permissions ]]; then # This host-side recovery must remain usable while Docker or Hermes is down. DOCKER=(docker) elif 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 printf 'Cannot access the Docker daemon.\n' >&2 exit 1 fi compose() { "${DOCKER[@]}" compose -f "$ROOT_DIR/docker-compose.yml" --env-file "$ENV_FILE" "$@" } ops() { "$ROOT_DIR/scripts/stack-ops.sh" "$@"; } uninstall_stack() { local purge=false answer dir case "${1:-}" in "") ;; --purge) purge=true ;; *) printf 'Usage: ./manage.sh uninstall [--purge]\n' >&2 return 2 ;; esac if [[ "$purge" == true ]]; then printf '%s\n' 'WARNING: --purge permanently deletes local stack configuration, secrets, and runtime data.' printf '%s\n' 'The Git/source directory and backups stored outside this repository are kept.' read -r -p 'Type PURGE to continue: ' answer [[ "$answer" == PURGE ]] || { printf 'Uninstall cancelled.\n'; return 0; } else printf '%s\n' 'This removes stack containers and the Compose network.' printf '%s\n' 'Your .env, Hermes/n8n/OpenWebUI data, secrets, and source files will be kept.' read -r -p 'Continue? [y/N]: ' answer [[ "$answer" =~ ^[Yy]$ ]] || { printf 'Uninstall cancelled.\n'; return 0; } fi # Enable every Compose profile so services started under optional profiles are # included even if COMPOSE_PROFILES has changed since installation. compose --profile '*' down --remove-orphans printf 'Stack containers and network removed.\n' if [[ "$purge" == true ]]; then rm -f -- "$ENV_FILE" for dir in 9router omniroute caddy hermes n8n open-webui rustfs stack-secrets; do if [[ -d "$ROOT_DIR/data/$dir" ]]; then find "$ROOT_DIR/data/$dir" -mindepth 1 -maxdepth 1 ! -name '.gitkeep' -exec rm -rf -- {} + fi done rm -rf -- "$ROOT_DIR/data/smart-router" if [[ -d "$ROOT_DIR/data/stack-state" ]]; then find "$ROOT_DIR/data/stack-state" -mindepth 1 -maxdepth 1 ! -name 'ops.lock' -exec rm -rf -- {} + : > "$ROOT_DIR/data/stack-state/ops.lock" fi printf '%s\n' 'Local stack configuration, secrets, and runtime data purged.' printf '%s\n' 'Source files were kept. Run ./install.sh to install again.' else printf '%s\n' 'Local data was preserved. Run ./manage.sh start to bring the stack back.' fi } valid_ids() { [[ "$1" =~ ^[0-9]+(,[0-9]+)*$ ]]; } router_local_base_url() { local bind port bind="$(env_value "$ENV_FILE" SMART_ROUTER_BIND_IP)"; bind="${bind:-127.0.0.1}" port="$(env_value "$ENV_FILE" SMART_ROUTER_PORT)"; port="${port:-8787}" case "$bind" in 0.0.0.0|'') bind=127.0.0.1 ;; esac printf 'http://%s:%s' "$bind" "$port" } router_api_get() { local path="$1" key="${2:-}" base base="$(router_local_base_url)" if command -v curl >/dev/null 2>&1; then if [[ -n "$key" ]]; then curl -fsS --connect-timeout 3 --max-time 10 -H "Authorization: Bearer $key" "$base$path" else curl -fsS --connect-timeout 3 --max-time 10 "$base$path" fi else # urllib fallback keeps management usable on minimal hosts. ROUTER_URL="$base$path" ROUTER_KEY="$key" python3 - <<'PYROUTER' import os, urllib.request req=urllib.request.Request(os.environ['ROUTER_URL']) if os.environ.get('ROUTER_KEY'): req.add_header('Authorization','Bearer '+os.environ['ROUTER_KEY']) with urllib.request.urlopen(req, timeout=10) as r: print(r.read().decode(), end='') PYROUTER fi } router_api_request() { local method="$1" path="$2" key="${3:-}" data="${4:-}" base base="$(router_local_base_url)" if command -v curl >/dev/null 2>&1; then local args=(-fsS --connect-timeout 3 --max-time 10 -X "$method") [[ -n "$key" ]] && args+=(-H "Authorization: Bearer $key") if [[ -n "$data" ]]; then args+=(-H 'Content-Type: application/json' --data "$data") fi curl "${args[@]}" "$base$path" else ROUTER_URL="$base$path" ROUTER_KEY="$key" ROUTER_METHOD="$method" ROUTER_DATA="$data" python3 - <<'PYROUTER_MUTATE' import os, urllib.request data=os.environ.get('ROUTER_DATA','').encode() or None req=urllib.request.Request(os.environ['ROUTER_URL'], data=data, method=os.environ['ROUTER_METHOD']) if os.environ.get('ROUTER_KEY'): req.add_header('Authorization','Bearer '+os.environ['ROUTER_KEY']) if data is not None: req.add_header('Content-Type','application/json') with urllib.request.urlopen(req, timeout=10) as r: print(r.read().decode(), end='') PYROUTER_MUTATE fi } router_runtime_set() { local json="$1" key response key="$(env_value "$ENV_FILE" SMART_ROUTER_ADMIN_API_KEY)" for _ in {1..30}; do if response="$(router_api_request PUT /control/api/system "$key" "$json" 2>/dev/null)"; then printf '%s' "$response" return 0 fi sleep 1 done return 1 } pretty_json() { python3 -m json.tool 2>/dev/null || cat } menu_title() { printf '\n\033[1;36m%s\033[0m\n' "$1" printf '%s\n' '============================================================' } menu_pause() { [[ -r /dev/tty ]] || return 0 read -r -p 'Press Enter to continue...' _ &2 ;; esac ;; 0) return 0 ;; *) printf 'Unknown choice.\n' >&2 ;; esac done } router_menu() { local choice value file while true; do menu_title 'Hermes Smart Router v0.5.9' printf '%s\n' 'Observe & access' printf '%s\n' ' 1) Status & URLs Mode, policy, features and endpoints' printf '%s\n' ' 2) Dashboard / Control URLs and credential guidance' printf '%s\n' ' 3) Telemetry summary Choose 1h / 24h / 7d / 30d' printf '%s\n' ' 4) Provider health Health scores and circuit state' printf '%s\n' 'Routing' printf '%s\n' ' 5) Route profiles fast / standard / strong / coding / vision' printf '%s\n' ' 6) Change mode observe | route' printf '%s\n' ' 7) Change policy heuristic | calibrated | learned' printf '%s\n' ' 8) Operations Center system DB, HA, auth, OIDC and upstream state' printf '%s\n' 'Evaluate / tune' printf '%s\n' ' 9) Router runtime info' printf '%s\n' ' 10) Calibrate from JSONL' printf '%s\n' ' 11) Evaluate/report JSONL' printf '%s\n' ' 12) Replay requests JSONL' printf '%s\n' ' 0) Back' read -r -p 'Choose [0]: ' choice case "${choice:-0}" in 1) "$ROOT_DIR/manage.sh" router-status; menu_pause ;; 2) "$ROOT_DIR/manage.sh" router-access; menu_pause ;; 3) printf '%s\n' 'Telemetry window: 1) 1 hour 2) 24 hours 3) 7 days 4) 30 days' read -r -p 'Choose [2]: ' value case "${value:-2}" in 1) value=1;; 2) value=24;; 3) value=168;; 4) value=720;; *) printf 'Unknown window.\n' >&2; continue;; esac "$ROOT_DIR/manage.sh" router-summary "$value"; menu_pause ;; 4) "$ROOT_DIR/manage.sh" router-provider-health; menu_pause ;; 5) "$ROOT_DIR/manage.sh" router-routes; menu_pause ;; 6) printf '%s\n' 'Mode choices:' printf '%s\n' ' 1) observe Evaluate model=auto decisions but dispatch through the observe model' printf '%s\n' ' 2) route Apply route profiles to model=auto requests' printf '%s\n' 'Explicit upstream model names pass through in both modes.' read -r -p 'Choose [1]: ' value case "${value:-1}" in 1) value=observe;; 2) value=route;; *) printf 'Unknown mode.\n' >&2; continue;; esac "$ROOT_DIR/manage.sh" set-router-mode "$value"; menu_pause ;; 7) printf '%s\n' 'Policy choices:' printf '%s\n' ' 1) heuristic Built-in deterministic policy; safest default' printf '%s\n' ' 2) calibrated Uses policy/calibrated.json from your labeled data' printf '%s\n' ' 3) learned Uses trained learned-routing artifacts with fallback' read -r -p 'Choose [1]: ' value case "${value:-1}" in 1) value=heuristic;; 2) value=calibrated;; 3) value=learned;; *) printf 'Unknown policy.\n' >&2; continue;; esac "$ROOT_DIR/manage.sh" router-policy "$value"; menu_pause ;; 8) "$ROOT_DIR/manage.sh" router-system; menu_pause ;; 9) "$ROOT_DIR/manage.sh" router-info; menu_pause ;; 10) read -r -p 'Labeled calibration JSONL path: ' file [[ -n "$file" ]] && "$ROOT_DIR/manage.sh" router-calibrate "$file" menu_pause ;; 11) read -r -p 'Labeled evaluation JSONL path: ' file [[ -n "$file" ]] && "$ROOT_DIR/manage.sh" router-report "$file" menu_pause ;; 12) read -r -p 'Requests JSONL path: ' file [[ -n "$file" ]] && "$ROOT_DIR/manage.sh" router-replay "$file" menu_pause ;; 0) return 0 ;; *) printf 'Unknown Smart Router choice.\n' >&2 ;; esac done } hermes_menu() { local choice value while true; do menu_title 'Hermes Agent & Telegram' printf '%s\n' 'Agent' printf '%s\n' ' 1) Restart Hermes Agent' printf '%s\n' ' 2) Set max agent turns 10-500; 90 recommended' printf '%s\n' ' 3) Upstream terminal tools enable | disable' printf '%s\n' ' 4) Update backend API key' printf '%s\n' 'Telegram' printf '%s\n' ' 5) Show allowed users' printf '%s\n' ' 6) Add allowed user' printf '%s\n' ' 7) Replace allowed users' printf '%s\n' 'Dashboard' printf '%s\n' ' 8) Show dashboard access URL and username' printf '%s\n' ' 9) Reveal dashboard password Trusted terminal confirmation required' printf '%s\n' '0) Back' read -r -p 'Choose [0]: ' choice case "${choice:-0}" in 1) "$ROOT_DIR/manage.sh" restart-hermes; menu_pause ;; 2) read -r -p 'Max turns [90]: ' value; "$ROOT_DIR/manage.sh" set-agent-max-turns "${value:-90}"; menu_pause ;; 3) printf '%s\n' '1) enable terminal/code_execution 2) disable terminal/code_execution' read -r -p 'Choose [2]: ' value case "${value:-2}" in 1) value=enable;; 2) value=disable;; *) printf 'Unknown choice.\n' >&2; continue;; esac "$ROOT_DIR/manage.sh" set-upstream-terminal "$value"; menu_pause ;; 4) read -r -s -p 'New backend API key: ' value; printf '\n' [[ -n "$value" ]] && "$ROOT_DIR/manage.sh" set-backend-api-key "$value" menu_pause ;; 5) "$ROOT_DIR/manage.sh" show-telegram-users; menu_pause ;; 6) read -r -p 'Numeric Telegram user ID: ' value; "$ROOT_DIR/manage.sh" add-telegram-user "$value"; menu_pause ;; 7) read -r -p 'Complete comma-separated ID list: ' value; "$ROOT_DIR/manage.sh" set-telegram-users "$value"; menu_pause ;; 8) "$ROOT_DIR/manage.sh" dashboard-access; menu_pause ;; 9) "$ROOT_DIR/manage.sh" dashboard-access --show-password; menu_pause ;; 0) return 0 ;; *) printf 'Unknown Hermes choice.\n' >&2 ;; esac done } execution_menu() { local choice value feature name while true; do menu_title 'Execution, Sandbox & SSH' printf '%s\n' '1) Execution status' printf '%s\n' '2) Enable feature sandbox | ssh | docker | all' printf '%s\n' '3) Disable feature sandbox | ssh | docker | all' printf '%s\n' '4) Set execution users' printf '%s\n' '5) Add execution user' printf '%s\n' '6) Remove execution user' printf '%s\n' '7) Add SSH profile' printf '%s\n' '8) Verify SSH profile' printf '%s\n' '9) Change SSH profile password' printf '%s\n' '10) Remove SSH profile' printf '%s\n' '11) Configure approval bot token' printf '%s\n' '12) Rotate execution broker secret' printf '%s\n' '13) Enable Execution Admin UI' printf '%s\n' '14) Execution Admin status' printf '%s\n' '15) Show Execution Admin key (trusted terminal)' printf '%s\n' '16) Rotate Execution Admin key' printf '%s\n' '17) Disable Execution Admin UI' printf '%s\n' '18) PURGE execution state / SSH keys' printf '%s\n' '0) Back' read -r -p 'Choose [0]: ' choice case "${choice:-0}" in 1) "$ROOT_DIR/manage.sh" execution-status; menu_pause ;; 2|3) printf '%s\n' 'Feature: 1) sandbox 2) ssh 3) docker 4) all' read -r -p 'Choose [1]: ' value case "${value:-1}" in 1) feature=sandbox;; 2) feature=ssh;; 3) feature=docker;; 4) feature=all;; *) printf 'Unknown feature.\n' >&2; continue;; esac if [[ "$choice" == 2 ]]; then "$ROOT_DIR/manage.sh" enable-execution "$feature"; else "$ROOT_DIR/manage.sh" disable-execution "$feature"; fi menu_pause ;; 4) read -r -p 'Comma-separated Telegram IDs: ' value; "$ROOT_DIR/manage.sh" set-execution-users "$value"; menu_pause ;; 5) read -r -p 'Telegram ID: ' value; "$ROOT_DIR/manage.sh" add-execution-user "$value"; menu_pause ;; 6) read -r -p 'Telegram ID: ' value; "$ROOT_DIR/manage.sh" remove-execution-user "$value"; menu_pause ;; 7) read -r -p 'SSH profile name: ' name; "$ROOT_DIR/manage.sh" add-ssh-profile "$name"; menu_pause ;; 8) read -r -p 'SSH profile name: ' name; "$ROOT_DIR/manage.sh" verify-ssh-profile "$name"; menu_pause ;; 9) read -r -p 'SSH profile name: ' name; "$ROOT_DIR/manage.sh" set-ssh-profile-password "$name"; menu_pause ;; 10) read -r -p 'SSH profile name: ' name; "$ROOT_DIR/manage.sh" remove-ssh-profile "$name"; menu_pause ;; 11) "$ROOT_DIR/manage.sh" set-execution-approval-bot-token; menu_pause ;; 12) "$ROOT_DIR/manage.sh" rotate-execution-broker-secret; menu_pause ;; 13) "$ROOT_DIR/manage.sh" enable-execution-admin; menu_pause ;; 14) "$ROOT_DIR/manage.sh" execution-admin-status; menu_pause ;; 15) "$ROOT_DIR/manage.sh" show-execution-admin-key; menu_pause ;; 16) "$ROOT_DIR/manage.sh" rotate-execution-admin-key; menu_pause ;; 17) "$ROOT_DIR/manage.sh" disable-execution-admin; menu_pause ;; 18) "$ROOT_DIR/manage.sh" purge-execution; menu_pause ;; 0) return 0 ;; *) printf 'Unknown execution choice.\n' >&2 ;; esac done } maintenance_menu() { local choice value while true; do menu_title 'Maintenance, Backup & Recovery' printf '%s\n' '1) Update official images and recreate services' printf '%s\n' '2) Create backup' printf '%s\n' '3) Create a section backup (env, content, panel, ...)' printf '%s\n' '4) List backups' printf '%s\n' '5) Restore backup archive' printf '%s\n' '6) Roll back last update/state' printf '%s\n' '7) Version information' printf '%s\n' '0) Back' read -r -p 'Choose [0]: ' choice case "${choice:-0}" in 1) read -r -p 'Pull and recreate selected services? [y/N]: ' value; [[ "$value" =~ ^[Yy]$ ]] && "$ROOT_DIR/manage.sh" update; menu_pause ;; 2) "$ROOT_DIR/manage.sh" backup; menu_pause ;; 3) "$ROOT_DIR/manage.sh" backup-sections; read -r -p 'Sections (comma separated): ' value; [[ -n "$value" ]] && "$ROOT_DIR/manage.sh" backup --only "$value"; menu_pause ;; 4) "$ROOT_DIR/manage.sh" backup-list; menu_pause ;; 5) read -r -p 'Backup archive path: ' value; [[ -n "$value" ]] && "$ROOT_DIR/manage.sh" restore "$value"; menu_pause ;; 6) read -r -p 'State ID (Enter = latest): ' value; if [[ -n "$value" ]]; then "$ROOT_DIR/manage.sh" rollback "$value"; else "$ROOT_DIR/manage.sh" rollback; fi; menu_pause ;; 7) "$ROOT_DIR/manage.sh" version; menu_pause ;; 0) return 0 ;; *) printf 'Unknown maintenance choice.\n' >&2 ;; esac done } security_menu() { local choice while true; do menu_title 'Security & Integrity' printf '%s\n' '1) Doctor / diagnostics' printf '%s\n' '2) Lock current image digests' printf '%s\n' '3) Verify pinned image policy' printf '%s\n' '4) Smart Router access guidance' printf '%s\n' '5) Reveal Smart Router local secrets (confirmation required)' printf '%s\n' '0) Back' read -r -p 'Choose [0]: ' choice case "${choice:-0}" in 1) "$ROOT_DIR/manage.sh" doctor; menu_pause ;; 2) "$ROOT_DIR/manage.sh" lock-images; menu_pause ;; 3) "$ROOT_DIR/manage.sh" verify-images; menu_pause ;; 4) "$ROOT_DIR/manage.sh" router-access; menu_pause ;; 5) "$ROOT_DIR/manage.sh" router-access --show-secrets; menu_pause ;; 0) return 0 ;; *) printf 'Unknown security choice.\n' >&2 ;; esac done } uninstall_menu() { local value menu_title 'Uninstall' printf '%s\n' '1) Remove containers/network only' printf '%s\n' ' KEEP .env, secrets, Hermes/n8n/OpenWebUI data and source files.' printf '%s\n' '2) PURGE local runtime data and secrets' printf '%s\n' ' Source files and external backups are kept; PURGE confirmation is required.' printf '%s\n' '0) Cancel' read -r -p 'Choose [0]: ' value case "${value:-0}" in 1) "$ROOT_DIR/manage.sh" uninstall ;; 2) "$ROOT_DIR/manage.sh" uninstall --purge ;; 0) ;; *) printf 'Unknown uninstall choice.\n' >&2 ;; esac } interactive_menu() { local choice while true; do menu_title 'Hermes Linux Stack Manager v0.5.9' printf '%s\n' 'Quick administration — choose a group; direct CLI commands still work.' printf '\n%s\n' '1) Overview & health Status, health, version, diagnostics' printf '%s\n' '2) Services & logs Start/stop/restart and service logs' printf '%s\n' '3) Smart Router Routing, dashboard, profiles, policy, health' printf '%s\n' '4) Hermes Agent & Telegram Agent and messaging settings' printf '%s\n' '5) n8n & MCP Provisioning, Instance MCP, Trigger MCP' printf '%s\n' '6) Execution & SSH Sandbox, Docker, SSH profiles, approvals' printf '%s\n' '7) Content Bot Status, publishing docs, platform setup' printf '%s\n' '8) Media Studio Media jobs, sessions and Google setup' printf '%s\n' '9) Maintenance & recovery Updates, backup, restore, rollback' printf '%s\n' '10) Security & integrity Doctor, image pins, access credentials' printf '%s\n' '11) Reconfigure installation Run the v0.5.9 wizard again' printf '%s\n' '12) Operator panel Web console for status, config, logs, actions' printf '%s\n' '13) Object storage (S3) RustFS or an external S3 endpoint' printf '%s\n' '14) NotebookLM video Worker status, sign-in and enable/disable' printf '%s\n' '15) Uninstall Safe remove or explicit purge' printf '%s\n' '0) Exit' read -r -p 'Choose [0]: ' choice case "${choice:-0}" in 1) "$ROOT_DIR/manage.sh" status printf '\n' "$ROOT_DIR/manage.sh" health || true printf '\n' "$ROOT_DIR/manage.sh" version || true menu_pause ;; 2) services_menu ;; 3) router_menu ;; 4) hermes_menu ;; 5) n8n_menu ;; 6) execution_menu ;; 7) content_menu ;; 8) media_menu ;; 9) maintenance_menu ;; 10) security_menu ;; 11) exec "$ROOT_DIR/install.sh" ;; 12) panel_menu ;; 13) storage_menu ;; 14) notebooklm_menu ;; 15) uninstall_menu ;; 0) return 0 ;; *) printf 'Unknown choice.\n' >&2 ;; esac done } replace_env_value() { local file="$1" key="$2" value="$3" tmp [[ "$key" =~ ^[A-Z][A-Z0-9_]*$ ]] || { printf 'Unsafe environment key: %s\n' "$key" >&2 return 1 } tmp="$(mktemp "$file.tmp.XXXXXX")" if ! printf '%s' "$value" | python3 /dev/fd/3 "$file" "$key" 3<<'PY' > "$tmp" import sys path, key = sys.argv[1:] value = sys.stdin.read() lines = open(path, encoding="utf-8").read().splitlines() indexes = [index for index, line in enumerate(lines) if line.startswith(f"{key}=")] if len(indexes) > 1: raise SystemExit(f"Duplicate {key} entries are unsafe") replacement = f"{key}={value}" if indexes: lines[indexes[0]] = replacement else: lines.append(replacement) print("\n".join(lines) + "\n", end="") PY then rm -f -- "$tmp" return 1 fi chmod --reference="$file" "$tmp" || { rm -f -- "$tmp"; return 1; } chown --reference="$file" "$tmp" || { rm -f -- "$tmp"; return 1; } mv "$tmp" "$file" } remove_env_values() { local file="$1" tmp key pattern="" shift for key in "$@"; do [[ -z "$pattern" ]] || pattern+="|" pattern+="${key}=" done tmp="$(mktemp "$file.tmp.XXXXXX")" grep -v -E "^(${pattern})" "$file" > "$tmp" || true chmod --reference="$file" "$tmp" mv "$tmp" "$file" } n8n_mcp_mode() { local mode legacy mode="$(env_value "$ENV_FILE" N8N_MCP_MODE)" || return 1 if [[ -z "$mode" ]]; then legacy="$(env_value "$HERMES_ENV" N8N_MCP_TOKEN)" || return 1 if [[ -n "$legacy" ]]; then mode=trigger; else mode=off; fi fi [[ "$mode" == instance || "$mode" == trigger || "$mode" == off ]] || { printf 'N8N_MCP_MODE must be instance, trigger, or off.\n' >&2 return 1 } printf '%s' "$mode" } migrate_legacy_trigger_env() { local legacy legacy="$(env_value "$HERMES_ENV" N8N_MCP_TOKEN)" || return 1 if [[ -n "$legacy" && -z "$(env_value "$HERMES_ENV" N8N_TRIGGER_MCP_TOKEN)" ]]; then replace_env_value "$HERMES_ENV" N8N_TRIGGER_MCP_TOKEN "$legacy" fi replace_env_value "$HERMES_ENV" N8N_TRIGGER_MCP_URL '"http://n8n:5678/mcp/hermes"' replace_env_value "$HERMES_ENV" N8N_INSTANCE_MCP_URL '"http://n8n:5678/mcp-server/http"' } finish_legacy_trigger_env_migration() { remove_env_values "$HERMES_ENV" N8N_MCP_URL N8N_MCP_PATH N8N_MCP_TOKEN } render_managed_n8n_mcp_entry() { local mode="$1" case "$mode" 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 ;; off) 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) <<<' } set_hermes_n8n_mcp_entry() { local mode="$1" file="$ROOT_DIR/data/hermes/config.yaml" tmp entry [[ -f "$file" ]] || { printf 'Hermes config is missing.\n' >&2; return 1; } entry="$(render_managed_n8n_mcp_entry "$mode")" tmp="$(mktemp "$file.tmp.XXXXXX")" if ! python3 - "$file" "$mode" "$entry" > "$tmp" <<'PY' import re import sys path, mode, entry = sys.argv[1:] lines = open(path, encoding="utf-8").read().splitlines() opens = [i for i, line in enumerate(lines) if line == " # >>> hermes-stack n8n mcp (managed) >>>"] closes = [i for i, line in enumerate(lines) if line == " # <<< hermes-stack n8n mcp (managed) <<<"] top_opens = [i for i, line in enumerate(lines) if line == "# >>> hermes-stack n8n mcp (managed) >>>"] top_closes = [i for i, line in enumerate(lines) if line == "# <<< hermes-stack n8n mcp (managed) <<<"] if len(opens) != len(closes) or len(top_opens) != len(top_closes) or len(opens) + len(top_opens) > 1: raise SystemExit("Hermes config has incomplete or duplicate managed n8n MCP markers") replacement = entry.splitlines() if entry else [] if top_opens: start, end = top_opens[0], top_closes[0] block = ["mcp_servers:", *replacement] if replacement else [] lines[start:end + 1] = block elif opens: start, end = opens[0], closes[0] lines[start:end + 1] = replacement elif replacement: roots = [i for i, line in enumerate(lines) if re.fullmatch(r"mcp_servers:\s*", line)] if len(roots) > 1: raise SystemExit("Duplicate top-level mcp_servers sections are unsafe") if roots: lines[roots[0] + 1:roots[0] + 1] = replacement else: if lines and lines[-1]: lines.append("") lines.extend(["mcp_servers:", *replacement]) print("\n".join(lines) + "\n", end="") PY then rm -f -- "$tmp" return 1 fi chmod --reference="$file" "$tmp" || { rm -f -- "$tmp"; return 1; } chown --reference="$file" "$tmp" || { rm -f -- "$tmp"; return 1; } mv "$tmp" "$file" } enable_hermes_execution_plugin() { local file="$ROOT_DIR/data/hermes/config.yaml" tmp [[ -f "$file" ]] || { printf 'Hermes config is missing.\n' >&2; return 1; } tmp="$(mktemp "$file.tmp.XXXXXX")" if ! python3 - "$file" > "$tmp" <<'PY' import re import sys lines = open(sys.argv[1], encoding="utf-8").read().splitlines() starts = [i for i, line in enumerate(lines) if re.fullmatch(r"plugins:\s*", line)] if len(starts) > 1: raise SystemExit("Duplicate top-level plugins sections are unsafe") if not starts: if lines and lines[-1]: lines.append("") lines.extend(["plugins:", " enabled:", " - stack-execution-policy"]) else: start = starts[0] end = next((i for i in range(start + 1, len(lines)) if re.match(r"^[^\s#]", lines[i])), len(lines)) if not any(re.fullmatch(r"\s*-\s*stack-execution-policy\s*", line) for line in lines[start + 1:end]): 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:", " - stack-execution-policy"] else: lines.insert(enabled + 1, " - stack-execution-policy") print("\n".join(lines) + "\n", end="") PY then rm -f -- "$tmp" return 1 fi chmod --reference="$file" "$tmp" || { rm -f -- "$tmp"; return 1; } chown --reference="$file" "$tmp" || { rm -f -- "$tmp"; return 1; } mv "$tmp" "$file" } restart_hermes() { compose up -d --force-recreate hermes } hermes_dashboard_url() { local bind port bind="$(env_value "$ENV_FILE" HERMES_BIND_IP)"; bind="${bind:-127.0.0.1}" port="$(env_value "$ENV_FILE" HERMES_DASHBOARD_PORT)"; port="${port:-9119}" [[ "$bind" != 0.0.0.0 ]] || bind=127.0.0.1 printf 'http://%s:%s' "$bind" "$port" } hermes_dashboard_access() { local show_password="${1:-}" enabled username password answer mode [[ -z "$show_password" || "$show_password" == --show-password ]] || { printf 'Usage: ./manage.sh dashboard-access [--show-password]\n' >&2 return 2 } enabled="$(env_value "$ENV_FILE" HERMES_DASHBOARD)" [[ "$enabled" == 1 ]] || { printf 'Hermes dashboard is disabled. Run ./manage.sh configure to enable it.\n' >&2 return 1 } username="$(env_value "$ENV_FILE" HERMES_DASHBOARD_BASIC_AUTH_USERNAME)" printf 'Dashboard: %s\n' "$(hermes_dashboard_url)" printf 'Username: %s\n' "${username:-NOT CONFIGURED}" if [[ -z "$show_password" ]]; then printf 'Use ./manage.sh dashboard-access --show-password only on a trusted terminal.\n' return 0 fi [[ -r /dev/tty && -w /dev/tty ]] || { printf 'A controlling terminal is required to reveal the dashboard password.\n' >&2 return 1 } [[ -f "$HERMES_DASHBOARD_ACCESS_FILE" && ! -L "$HERMES_DASHBOARD_ACCESS_FILE" ]] || { printf 'Dashboard credential file is missing or unsafe; reconfigure Hermes to rotate credentials.\n' >&2 return 1 } mode="$(stat -c '%a' "$HERMES_DASHBOARD_ACCESS_FILE")" [[ "$mode" == 600 ]] || { printf 'Refusing to read dashboard credentials with unsafe mode %s; expected 600.\n' "$mode" >&2 return 1 } read -r -p 'Reveal the Hermes dashboard password on this terminal? [y/N]: ' answer &2; return 1; } printf 'Password: %s\n' "$password" } hermes_uid_gid() { local uid gid uid="$(env_value "$ENV_FILE" HERMES_UID)" gid="$(env_value "$ENV_FILE" HERMES_GID)" uid="${uid:-10000}" gid="${gid:-10000}" [[ "$uid" =~ ^[1-9][0-9]*$ ]] || { printf 'HERMES_UID must be a non-root numeric uid in %s.\n' "$ENV_FILE" >&2 return 1 } [[ "$gid" =~ ^[1-9][0-9]*$ ]] || { printf 'HERMES_GID must be a non-root numeric gid in %s.\n' "$ENV_FILE" >&2 return 1 } printf '%s:%s' "$uid" "$gid" } hermes_permission_error() { printf 'Could not repair data/hermes/logs for the Hermes gateway uid.\n' >&2 if [[ "$(id -u)" != 0 ]]; then printf 'Rerun with: sudo ./manage.sh migrate-hermes-permissions\n' >&2 fi return 1 } migrate_hermes_permissions() { local dry_run="$1" logs_dir desired uid gid changed=false path owner mode target_mode label logs_dir="$ROOT_DIR/data/hermes/logs" desired="$(hermes_uid_gid)" || return 1 uid="${desired%%:*}" gid="${desired##*:}" if [[ -L "$logs_dir" ]]; then printf 'Refusing unsafe Hermes logs symlink: %s\n' "$logs_dir" >&2 return 1 fi if [[ ! -d "$logs_dir" ]]; then if [[ "$dry_run" == true ]]; then printf '[dry-run] would create %s with owner %s and mode 0700\n' "$logs_dir" "$desired" return 0 fi install -d -m 0700 "$logs_dir" || hermes_permission_error chown "$uid:$gid" "$logs_dir" || hermes_permission_error changed=true fi while IFS= read -r -d '' path; do if [[ -d "$path" ]]; then target_mode=700 label=directory else target_mode=600 label=file fi owner="$(stat -c '%u:%g' "$path")" mode="$(stat -c '%a' "$path")" if [[ "$owner" != "$desired" ]]; then if [[ "$dry_run" == true ]]; then printf '[dry-run] would chown %s to %s (current %s)\n' "${path#$ROOT_DIR/}" "$desired" "$owner" else chown "$uid:$gid" "$path" || hermes_permission_error changed=true fi fi if [[ "$mode" != "$target_mode" ]]; then if [[ "$dry_run" == true ]]; then printf '[dry-run] would chmod %s %s to 0%s (current %s)\n' \ "$label" "${path#$ROOT_DIR/}" "$target_mode" "$mode" else chmod "$target_mode" "$path" || hermes_permission_error changed=true fi fi done < <(find "$logs_dir" \( -type d -o -type f \) -print0) if [[ "$dry_run" == true ]]; then printf '[dry-run] checked %s for owner %s and restrictive modes\n' "$logs_dir" "$desired" elif [[ "$changed" == true ]]; then printf 'Hermes log permissions repaired under %s (owner %s, directories 0700, files 0600).\n' \ "$logs_dir" "$desired" else printf 'Hermes log permissions already correct under %s.\n' "$logs_dir" fi } check_hermes_log_permissions() { local logs_dir="$ROOT_DIR/data/hermes/logs" desired issue_count=0 path owner mode expected_mode desired="$(hermes_uid_gid)" || return 1 if [[ -L "$logs_dir" ]]; then printf 'WARNING: data/hermes/logs is an unsafe symlink. Replace it with a directory.\n' return 0 fi if [[ ! -e "$logs_dir" ]]; then printf 'Hermes logs: not created yet (hermes-init will prepare it on startup)\n' return 0 fi if [[ ! -d "$logs_dir" ]]; then printf 'WARNING: data/hermes/logs is not a directory.\n' printf ' Remove or relocate it, then run ./manage.sh migrate-hermes-permissions.\n' return 0 fi while IFS= read -r -d '' path; do if [[ -d "$path" ]]; then expected_mode=700; else expected_mode=600; fi owner="$(stat -c '%u:%g' "$path")" mode="$(stat -c '%a' "$path")" if [[ "$owner" != "$desired" || "$mode" != "$expected_mode" ]]; then printf 'WARNING: %s has owner/mode %s/%s; expected %s/%s.\n' \ "${path#$ROOT_DIR/}" "$owner" "$mode" "$desired" "$expected_mode" issue_count=$((issue_count + 1)) fi done < <(find "$logs_dir" \( -type d -o -type f \) -print0) if (( issue_count > 0 )); then printf ' Run ./manage.sh migrate-hermes-permissions to repair data/hermes/logs.\n' else printf 'Hermes logs: owner/mode is compatible with Hermes gateway uid %s\n' "$desired" fi } # agent.max_turns in config.yaml is authoritative: the gateway bridges it into # HERMES_MAX_ITERATIONS, which produces the "Iteration budget exhausted" notice. AGENT_MAX_TURNS_MIN=10 AGENT_MAX_TURNS_MAX=500 hermes_agent_max_turns() { local file="$ROOT_DIR/data/hermes/config.yaml" [[ -f "$file" ]] || return 0 python3 - "$file" <<'PY' import re, sys lines = open(sys.argv[1], encoding="utf-8").read().splitlines() in_agent = False for line in lines: if re.fullmatch(r"agent:\s*", line): in_agent = True continue if in_agent: if line and not line[0].isspace(): in_agent = False continue match = re.fullmatch(r"\s+max_turns:\s*(\d+)\s*", line) if match: print(match.group(1)) break PY } set_hermes_agent_max_turns() { local value="$1" file="$ROOT_DIR/data/hermes/config.yaml" tmp [[ -f "$file" && ! -L "$file" ]] || { printf 'Hermes config is missing or unsafe.\n' >&2 return 1 } tmp="$(mktemp "$file.tmp.XXXXXX")" if ! python3 - "$file" "$value" > "$tmp" <<'PY' import re, sys path, value = sys.argv[1:] lines = open(path, encoding="utf-8").read().splitlines() roots = [i for i, line in enumerate(lines) if re.fullmatch(r"agent:\s*", line)] if len(roots) > 1: raise SystemExit("Duplicate top-level agent sections are unsafe") if roots: start = roots[0] end = start + 1 while end < len(lines) and (not lines[end] or lines[end][0].isspace()): end += 1 body = lines[start + 1:end] replaced = False for i, line in enumerate(body): if re.fullmatch(r"(\s+)max_turns:\s*\d+\s*", line): indent = re.match(r"\s+", line).group(0) body[i] = f"{indent}max_turns: {value}" replaced = True break if not replaced: body.insert(0, f" max_turns: {value}") lines[start + 1:end] = body else: if lines and lines[-1]: lines.append("") lines.extend(["agent:", f" max_turns: {value}"]) print("\n".join(lines) + "\n", end="") PY then rm -f -- "$tmp" return 1 fi chmod --reference="$file" "$tmp" || { rm -f -- "$tmp"; return 1; } chown --reference="$file" "$tmp" || { rm -f -- "$tmp"; return 1; } mv "$tmp" "$file" } # Upstream terminal/code_execution run as the gateway uid inside hermes-agent, # which owns /opt/data/.env. Enabling them is a deliberate local trade of # isolation for capability, so it lives behind an explicit command. set_upstream_terminal() { local state="$1" file="$ROOT_DIR/data/hermes/config.yaml" tmp [[ -f "$file" && ! -L "$file" ]] || { printf 'Hermes config is missing or unsafe.\n' >&2 return 1 } tmp="$(mktemp "$file.tmp.XXXXXX")" if ! python3 - "$file" "$state" > "$tmp" <<'PY' import re, sys path, state = sys.argv[1:] lines = open(path, encoding="utf-8").read().splitlines() roots = [i for i, line in enumerate(lines) if re.fullmatch(r"agent:\s*", line)] if len(roots) > 1: raise SystemExit("Duplicate top-level agent sections are unsafe") names = ("terminal", "code_execution") if roots: start = roots[0] end = start + 1 while end < len(lines) and (not lines[end] or lines[end][0].isspace()): end += 1 else: if lines and lines[-1]: lines.append("") lines.append("agent:") start, end = len(lines) - 1, len(lines) # Drop any existing disabled_toolsets block, preserving every other agent key. key = next((i for i in range(start + 1, end) if re.fullmatch(r"\s+disabled_toolsets:.*", lines[i])), None) if key is not None: stop = key + 1 while stop < end and re.fullmatch(r"\s+-\s*[A-Za-z0-9_-]+\s*", lines[stop]): stop += 1 del lines[key:stop] end -= stop - key block = [" disabled_toolsets: []"] if state == "enabled" else \ [" disabled_toolsets:", *(f" - {name}" for name in names)] # Append after the section's last real key, not after the blank lines that # separate it from the next section, so the file stays readable. insert_at = end while insert_at > start + 1 and not lines[insert_at - 1].strip(): insert_at -= 1 lines[insert_at:insert_at] = block print("\n".join(lines) + "\n", end="") PY then rm -f -- "$tmp" return 1 fi chmod --reference="$file" "$tmp" || { rm -f -- "$tmp"; return 1; } chown --reference="$file" "$tmp" || { rm -f -- "$tmp"; return 1; } mv "$tmp" "$file" } content_status() { local profiles token channel users writer model scheduler daily_time ig_id ig_url profiles="$(env_value "$ENV_FILE" COMPOSE_PROFILES)" if [[ ",$profiles," != *,content,* ]]; then printf 'Content Bot is not enabled in COMPOSE_PROFILES. Run ./manage.sh content-configure to enable it.\n' return 1 fi token="$(env_value "$ENV_FILE" CONTENT_BOT_TOKEN)" channel="$(env_value "$ENV_FILE" CONTENT_TELEGRAM_CHANNEL)" users="$(env_value "$ENV_FILE" CONTENT_TELEGRAM_USERS)" writer="$(env_value "$ENV_FILE" CONTENT_WRITER_BASE_URL)" model="$(env_value "$ENV_FILE" CONTENT_WRITER_MODEL)" scheduler="$(env_value "$ENV_FILE" CONTENT_SCHEDULER_ENABLED)" ig_id="$(env_value "$ENV_FILE" INSTAGRAM_BUSINESS_ID)" ig_url="$(ig_media_public_url)" daily_time="$(sed -n 's/^ daily_proposal_time: //p' \ "$ROOT_DIR/data/content-manager/config/editorial-policy.yaml" 2>/dev/null | head -n1 | tr -d '"')" printf 'Content Bot status\n' if [[ -n "$token" ]]; then printf ' Telegram bot token: stored (secret not shown)\n' else printf ' Telegram bot token: NOT stored; reconfigure with ./manage.sh content-configure\n' fi printf ' Publish channel: %s\n' "${channel:-not configured}" printf ' Operator users: %s\n' "${users:-none}" printf ' Writer endpoint: %s\n' "${writer:-not configured}" printf ' Writer model: %s\n' "${model:-auto}" printf ' Daily scheduler: %s at %s\n' "${scheduler:-true}" "${daily_time:-08:00}" if [[ -n "$ig_id" && -n "$(env_value "$ENV_FILE" INSTAGRAM_ACCESS_TOKEN)" ]]; then printf ' Instagram: enabled (business %s, public media URL %s)\n' "$ig_id" "${ig_url:-not set}" else printf ' Instagram: not configured (set INSTAGRAM_BUSINESS_ID / INSTAGRAM_ACCESS_TOKEN)\n' fi ig_auto="$(env_value "$ENV_FILE" INSTAGRAM_AUTO_PUBLISH)" case "$(printf '%s' "$ig_auto" | tr '[:upper:]' '[:lower:]')" in 0|false|no|off) printf ' Instagram auto publish: off (manual Post package flow)\n' ;; *) printf ' Instagram auto publish: on\n' ;; esac printf ' Automatic channels:\n' printf ' %s\n' "$(content_channel_state bale)" printf ' %s\n' "$(content_channel_state eitaa)" printf ' %s\n' "$(content_linkedin_state)" printf ' Editorial policy: data/content-manager/config/editorial-policy.yaml\n' printf ' Discovery sources: data/content-manager/config/sources.yaml\n' printf ' Guide: docs/CONTENT-PRODUCTION-GUIDE.md\n' } content_linkedin_author() { # Builds the author URN the same way the bot builds it. local kind="$1" urn="$2" person="$3" org="$4" if [[ -n "$urn" ]]; then if [[ "$urn" == urn:* ]]; then printf '%s' "$urn" else printf 'urn:li:%s:%s' "$kind" "$urn" fi return 0 fi if [[ "$kind" == organization ]]; then [[ -n "$org" ]] && printf 'urn:li:organization:%s' "$org" return 0 fi [[ -n "$person" ]] && printf 'urn:li:person:%s' "$person" return 0 } content_linkedin_state() { # One line describing the LinkedIn account (no secrets). local token kind account author person org token="$(env_value "$ENV_FILE" CONTENT_LINKEDIN_ACCESS_TOKEN)" kind="$(env_value "$ENV_FILE" CONTENT_LINKEDIN_ACCOUNT_TYPE)" account="$(env_value "$ENV_FILE" CONTENT_LINKEDIN_ACCOUNT)" author="$(env_value "$ENV_FILE" CONTENT_LINKEDIN_AUTHOR_URN)" person="$(env_value "$ENV_FILE" CONTENT_LINKEDIN_PERSON_ID)" org="$(env_value "$ENV_FILE" CONTENT_LINKEDIN_ORGANIZATION_ID)" if [[ -n "$token" ]] && [[ -n "$author" || -n "$person" || -n "$org" ]]; then printf 'LinkedIn: automatic publishing ready (account %s, %s)' \ "${account:-personal}" "${kind:-person}" elif [[ -n "$token" ]]; then printf 'LinkedIn: token stored, the author URN or id is still empty' else printf 'LinkedIn: not configured (CONTENT_LINKEDIN_ACCESS_TOKEN is empty)' fi } content_linkedin_verify() { # Reserves one image upload slot: validates the token, the pinned version, # and the author permission without publishing anything. local base="$1" version="$2" token="$3" author="$4" kind="$5" python3 - "$base" "$version" "$token" "$author" "$kind" <<'PY' import json import sys import urllib.error import urllib.request base, version, token, author, kind = sys.argv[1:6] url = f"{base.rstrip('/')}/rest/images?action=initializeUpload" payload = json.dumps({"initializeUploadRequest": {"owner": author}}).encode("utf-8") request = urllib.request.Request( url, data=payload, method="POST", headers={ "Authorization": f"Bearer {token}", "LinkedIn-Version": version, "X-Restli-Protocol-Version": "2.0.0", "Content-Type": "application/json", }, ) try: with urllib.request.urlopen(request, timeout=30) as response: status = response.status body = response.read().decode("utf-8", "replace") except urllib.error.HTTPError as error: status = error.code body = error.read().decode("utf-8", "replace") except Exception as error: # noqa: BLE001 - any transport failure is reported print(f"unreachable ({error})") raise SystemExit(1) try: data = json.loads(body or "{}") except ValueError: data = {} if status < 300 and (data.get("value") or {}).get("uploadUrl"): print(f"HTTP {status}: token and author accepted ({author})") raise SystemExit(0) note = str(data.get("message") or "").strip() if status == 401: print("HTTP 401: the token is expired or revoked; mint a new one.") elif status == 403 and kind == "organization": print("HTTP 403: the token cannot post as this page (missing") print("w_organization_social, or the member is not one of its admins).") elif status == 403: print("HTTP 403: the token cannot post as this member (missing") print("w_member_social, or the id is not the token owner's sub).") else: print(f"HTTP {status}: {note or 'unexpected answer'}") raise SystemExit(1) PY } content_connect_linkedin() { local usage="Usage: ./manage.sh content-connect-linkedin [--token TOKEN] [--type person|organization] [--account KEY] [--person-id ID] [--organization-id ID] [--author-urn URN] [--verify] [--no-apply]" local token="" kind="" account="" person="" org="" urn="" base="" version="" local token_arg="" kind_arg="" account_arg="" person_arg="" org_arg="" urn_arg="" answer local verify=false apply=true arg author waited while (($#)); do arg="$1"; shift case "$arg" in --token) (($#)) || { printf '%s\n' "$usage" >&2; return 2; } token_arg="$1"; shift ;; --type|--kind) (($#)) || { printf '%s\n' "$usage" >&2; return 2; } kind_arg="$1"; shift ;; --account|--name) (($#)) || { printf '%s\n' "$usage" >&2; return 2; } account_arg="$1"; shift ;; --person-id) (($#)) || { printf '%s\n' "$usage" >&2; return 2; } person_arg="$1"; shift ;; --organization-id|--org-id) (($#)) || { printf '%s\n' "$usage" >&2; return 2; } org_arg="$1"; shift ;; --author-urn|--author) (($#)) || { printf '%s\n' "$usage" >&2; return 2; } urn_arg="$1"; shift ;; --verify) verify=true ;; --no-apply) apply=false ;; -h|--help) printf '%s\n' "$usage"; return 0 ;; *) printf 'Unknown option: %s\n%s\n' "$arg" "$usage" >&2; return 2 ;; esac done token="$(env_value "$ENV_FILE" CONTENT_LINKEDIN_ACCESS_TOKEN)" kind="$(env_value "$ENV_FILE" CONTENT_LINKEDIN_ACCOUNT_TYPE)"; kind="${kind:-person}" account="$(env_value "$ENV_FILE" CONTENT_LINKEDIN_ACCOUNT)"; account="${account:-personal}" person="$(env_value "$ENV_FILE" CONTENT_LINKEDIN_PERSON_ID)" org="$(env_value "$ENV_FILE" CONTENT_LINKEDIN_ORGANIZATION_ID)" urn="$(env_value "$ENV_FILE" CONTENT_LINKEDIN_AUTHOR_URN)" base="$(env_value "$ENV_FILE" CONTENT_LINKEDIN_API_BASE)"; base="${base:-https://api.linkedin.com}" version="$(env_value "$ENV_FILE" CONTENT_LINKEDIN_API_VERSION)"; version="${version:-202601}" printf 'LinkedIn setup (automatic publishing)\n' printf ' 1. Create the app and request Share on LinkedIn (personal) or the\n' printf ' Community Management API (company page).\n' printf ' 2. Mint the 3-legged token; it lives about 60 days.\n' printf ' 3. Find the author: urn:li:person: from /v2/userinfo, or\n' printf ' urn:li:organization: from the page admin URL.\n' printf ' Guide: docs/LINKEDIN-SETUP.md\n' [[ -n "$token_arg" ]] && token="$token_arg" [[ -n "$kind_arg" ]] && kind="$kind_arg" [[ -n "$account_arg" ]] && account="$account_arg" [[ -n "$person_arg" ]] && person="$person_arg" [[ -n "$org_arg" ]] && org="$org_arg" [[ -n "$urn_arg" ]] && urn="$urn_arg" if [[ -z "$token_arg$kind_arg$account_arg$person_arg$org_arg$urn_arg" && -t 0 ]]; then printf 'Leave an answer empty to keep the stored value.\n' read -r -s -p 'LinkedIn access token: ' answer printf '\n' [[ -n "$answer" ]] && token="$answer" read -r -p "Account type person/organization [${kind}]: " answer [[ -n "$answer" ]] && kind="$answer" read -r -p "Account key (button label suffix) [${account}]: " answer [[ -n "$answer" ]] && account="$answer" read -r -p 'Author URN, person id, or organization id: ' answer if [[ -n "$answer" ]]; then if [[ "$answer" == urn:* ]]; then urn="$answer" elif [[ "$kind" == organization ]]; then org="$answer" else person="$answer" fi fi fi case "$kind" in person|organization) ;; *) printf 'Unknown account type: %s (use person or organization).\n' "$kind" >&2 return 2 ;; esac if [[ -z "$token" ]]; then printf ' Current state: %s\n' "$(content_linkedin_state)" printf 'Nothing stored yet. Run one of:\n' printf ' ./manage.sh content-connect-linkedin --token --person-id \n' printf ' ./manage.sh content-connect-linkedin --token --organization-id --type organization\n' return 1 fi author="$(content_linkedin_author "$kind" "$urn" "$person" "$org")" if [[ -z "$author" ]]; then printf 'The author is still empty: pass --person-id, --organization-id, or --author-urn.\n' >&2 return 1 fi replace_env_value "$ENV_FILE" CONTENT_LINKEDIN_ACCESS_TOKEN "$token" replace_env_value "$ENV_FILE" CONTENT_LINKEDIN_ACCOUNT "$account" replace_env_value "$ENV_FILE" CONTENT_LINKEDIN_ACCOUNT_TYPE "$kind" replace_env_value "$ENV_FILE" CONTENT_LINKEDIN_AUTHOR_URN "$urn" replace_env_value "$ENV_FILE" CONTENT_LINKEDIN_PERSON_ID "$person" replace_env_value "$ENV_FILE" CONTENT_LINKEDIN_ORGANIZATION_ID "$org" replace_env_value "$ENV_FILE" CONTENT_LINKEDIN_API_BASE "$base" replace_env_value "$ENV_FILE" CONTENT_LINKEDIN_API_VERSION "$version" printf 'Stored the LinkedIn token, account %s, and author %s in .env.\n' "$account" "$author" printf ' Current state: %s\n' "$(content_linkedin_state)" if [[ "$verify" == true ]]; then printf 'Token check: ' content_linkedin_verify "$base" "$version" "$token" "$author" "$kind" \ || printf 'The check did not pass; see docs/LINKEDIN-SETUP.md.\n' fi if [[ "$apply" == true ]]; then if content_container_running; then printf 'Applying the configuration to content-bot...\n' compose up -d content-bot >/dev/null waited=0 while ((waited < 30)) && ! content_container_running; do sleep 2 waited=$((waited + 2)) done else printf 'content-bot is not running; it reads the new values on its next start.\n' fi fi printf 'Open a draft in Telegram: "More platforms..." shows LinkedIn (auto) once the\n' printf 'bot restarted with the new values. Check with ./manage.sh content-channels\n' } content_connect_instagram() { printf '%s\n' 'Instagram/Meta setup checklist (official Graph API):' printf '%s\n' ' 1. Convert the Instagram account to Business/Creator and link it to a Facebook Page.' printf '%s\n' ' 2. Create a Meta Business app: https://developers.facebook.com/apps/creation/' printf '%s\n' ' 3. Add the Instagram Graph API product and connect the Instagram account.' printf '%s\n' ' 4. Grant instagram_basic and instagram_content_publish and generate a long-lived token.' printf '%s\n' 'Step-by-step guide: docs/INSTAGRAM-SETUP.md' printf '%s\n' 'Then set INSTAGRAM_BUSINESS_ID and INSTAGRAM_ACCESS_TOKEN in .env, give the media' printf '%s\n' 'files a public address with ./manage.sh instagram-media-enable (or pin a stable' printf '%s\n' 'hostname in data/content-bot/media-base-url.txt), and restart the bot:' printf '%s\n' ' ./manage.sh restart content' printf '%s\n' 'While the Graph API cannot publish, set INSTAGRAM_AUTO_PUBLISH=false and use the' printf '%s\n' 'Post package action in the operator console for a manual upload from the app.' } content_configure() { printf 'Reconfiguring Content Bot settings. Existing components, data, and bind IPs are preserved.\n' exec "$ROOT_DIR/install.sh" --content-reconfigure } content_container_running() { [[ "$("${DOCKER[@]}" inspect -f '{{.State.Running}}' content-bot 2>/dev/null)" == true ]] } content_channel_keys() { # Emits: