trustgraph/install_trustgraph.sh

2604 lines
80 KiB
Bash
Raw Normal View History

#!/usr/bin/env bash
set -Eeuo pipefail
APP_NAME="TrustGraph"
DEFAULT_API_URL="http://localhost:8088/"
DEFAULT_UI_URL="http://localhost:8888"
DEFAULT_INSTALL_DIR="trustgraph-deploy"
DEFAULT_OLLAMA_MODEL="granite4:350m"
DEFAULT_OLLAMA_EMBEDDINGS_MODEL="mxbai-embed-large"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
INSTALL_DIR="${TG_INSTALL_DIR:-$SCRIPT_DIR/$DEFAULT_INSTALL_DIR}"
VENV_DIR="${TG_VENV_DIR:-$INSTALL_DIR/.venv}"
NLTK_DATA_DIR="${TG_NLTK_DATA_DIR:-$INSTALL_DIR/nltk_data}"
TIKTOKEN_CACHE_DIR_VALUE="${TIKTOKEN_CACHE_DIR:-$INSTALL_DIR/tiktoken_cache}"
PYTHON_BIN="python3"
API_URL="${TRUSTGRAPH_URL:-$DEFAULT_API_URL}"
UI_URL="${TRUSTGRAPH_UI_URL:-$DEFAULT_UI_URL}"
RUN_TESTS=1
AUTO_LAUNCH=1
NON_INTERACTIVE=0
DRY_RUN=0
YES=0
FRESH_INSTALL=0
REMOVE_ALL=0
USE_EXISTING_COMPOSE=""
HEALTH_TIMEOUT="${TG_HEALTH_TIMEOUT:-240}"
AUTH_CHECK_TIMEOUT="${TG_AUTH_CHECK_TIMEOUT:-45}"
AUTH_TOKEN="${TRUSTGRAPH_TOKEN:-}"
LLM_MODE=""
OPENAI_TOKEN_VALUE="${OPENAI_TOKEN:-}"
OPENAI_BASE_URL_VALUE="${OPENAI_BASE_URL:-https://api.openai.com/v1}"
OLLAMA_BASE_URL_VALUE="${OLLAMA_HOST:-${OLLAMA_BASE_URL:-}}"
OLLAMA_MODEL="${OLLAMA_MODEL:-$DEFAULT_OLLAMA_MODEL}"
OLLAMA_EMBEDDINGS_MODEL="${OLLAMA_EMBEDDINGS_MODEL:-$DEFAULT_OLLAMA_EMBEDDINGS_MODEL}"
HW_OS=""
HW_ARCH=""
HW_CPU_CORES="unknown"
HW_MEMORY_GB="unknown"
HW_GPU="none detected"
HW_CONTAINER_HINT=""
RECOMMENDED_LLM_MODE="openai"
RECOMMENDATION_REASON=""
COMPOSE_CMD=()
COLOR_RESET=""
COLOR_HEADING=""
COLOR_INFO=""
COLOR_WARN=""
COLOR_ERROR=""
COLOR_ACCENT=""
usage() {
cat <<'USAGE'
Usage: ./install_trustgraph.sh [options]
Guided local installer for TrustGraph. It detects the machine hardware,
recommends a local or hosted LLM path, asks for the few required values,
enumerates local Ollama models when relevant, runs the repo tests, generates
a deployment with the existing config tool, starts the stack, checks health,
and opens the Workbench UI.
Options:
--install-dir PATH Directory for generated deployment files.
--api-url URL API gateway URL for health checks.
--ui-url URL Workbench UI URL to open.
--use-existing-compose F Skip config generation and start this compose file.
--skip-tests Do not run the full pytest suite.
--no-launch Do not open the Workbench UI at the end.
--non-interactive Use defaults where possible. Best with --dry-run or
--use-existing-compose.
--yes Accept confirmation prompts.
--fresh Remove installer-managed files in --install-dir
before generating a new deployment.
--remove-all Uninstall the installer-managed deployment:
stop containers, remove compose volumes, and
delete only installer-managed files.
--dry-run Show detected hardware and planned defaults only.
-h, --help Show this help.
Environment defaults:
TRUSTGRAPH_TOKEN, TRUSTGRAPH_URL, OPENAI_TOKEN, OPENAI_BASE_URL,
OLLAMA_HOST, OLLAMA_BASE_URL, OLLAMA_MODEL, OLLAMA_EMBEDDINGS_MODEL,
TG_INSTALL_DIR, TG_VENV_DIR, TG_NLTK_DATA_DIR, TIKTOKEN_CACHE_DIR,
TG_HEALTH_TIMEOUT
USAGE
}
say() {
printf '\n%b%s%b\n' "$COLOR_HEADING" "$*" "$COLOR_RESET"
}
info() {
printf ' %b%s%b\n' "$COLOR_INFO" "$*" "$COLOR_RESET"
}
warn() {
printf '%bWarning:%b %s\n' "$COLOR_WARN" "$COLOR_RESET" "$*" >&2
}
die() {
printf '%bError:%b %s\n' "$COLOR_ERROR" "$COLOR_RESET" "$*" >&2
exit 1
}
command_exists() {
command -v "$1" >/dev/null 2>&1
}
spinner_enabled() {
[[ "${TG_NO_SPINNER:-0}" != "1" ]] && { [[ -t 2 ]] || [[ "${TG_FORCE_SPINNER:-0}" == "1" ]]; }
}
clear_spinner_line() {
printf '\r\033[K' >&2
}
run_with_spinner() {
local message="$1"
shift
local frames=('|' '/' '-' '\')
local frame=0
local pid
local status
if ! spinner_enabled; then
"$@"
return
fi
"$@" &
pid=$!
while kill -0 "$pid" 2>/dev/null; do
printf '\r %b%s%b %s' "$COLOR_ACCENT" "${frames[$frame]}" "$COLOR_RESET" "$message" >&2
frame=$(((frame + 1) % ${#frames[@]}))
sleep 0.2
done
if wait "$pid"; then
status=0
else
status=$?
fi
clear_spinner_line
if [[ "$status" -eq 0 ]]; then
info "Done: $message"
else
warn "Failed: $message"
fi
return "$status"
}
run_with_spinner_logged() {
local message="$1"
local log_file="$2"
shift 2
local frames=('|' '/' '-' '\')
local frame=0
local pid
local status
if ! spinner_enabled; then
"$@"
return
fi
mkdir -p "$(dirname "$log_file")"
"$@" >"$log_file" 2>&1 &
pid=$!
while kill -0 "$pid" 2>/dev/null; do
printf '\r %b%s%b %s' "$COLOR_ACCENT" "${frames[$frame]}" "$COLOR_RESET" "$message" >&2
frame=$(((frame + 1) % ${#frames[@]}))
sleep 0.2
done
if wait "$pid"; then
status=0
else
status=$?
fi
clear_spinner_line
if [[ "$status" -eq 0 ]]; then
info "Done: $message"
else
warn "Failed: $message"
warn "Last log lines from $log_file:"
tail -n 40 "$log_file" >&2 || true
fi
return "$status"
}
installer_log_file() {
local name="$1"
mkdir -p "$INSTALL_DIR/logs"
printf '%s/logs/%s.log\n' "$INSTALL_DIR" "$name"
}
command_to_text() {
local arg
local out=""
for arg in "$@"; do
if [[ -n "$out" ]]; then
out="$out "
fi
out="$out$(printf '%q' "$arg")"
done
printf '%s\n' "$out"
}
root_command_to_text() {
if [[ "${EUID:-$(id -u)}" -eq 0 ]]; then
command_to_text "$@"
elif command_exists sudo; then
command_to_text sudo "$@"
else
command_to_text "$@"
fi
}
run_root_command() {
if [[ "${EUID:-$(id -u)}" -eq 0 ]]; then
"$@"
elif command_exists sudo; then
sudo "$@"
else
warn "Could not find sudo. Run this installer as an administrator or install the prerequisite manually."
return 1
fi
}
confirm_install_command() {
local question="$1"
local command_text="$2"
info "Command: $command_text"
if [[ "$YES" -eq 1 ]]; then
return 0
fi
if [[ "$NON_INTERACTIVE" -eq 1 ]]; then
return 1
fi
confirm "$question" 1
}
init_colors() {
if [[ -n "${NO_COLOR:-}" || ! -t 1 ]]; then
return
fi
if command_exists tput && tput colors >/dev/null 2>&1 && [[ "$(tput colors)" -ge 8 ]]; then
COLOR_RESET="$(tput sgr0)"
COLOR_HEADING="$(tput bold)$(tput setaf 6)"
COLOR_INFO="$(tput setaf 2)"
COLOR_WARN="$(tput setaf 3)"
COLOR_ERROR="$(tput bold)$(tput setaf 1)"
COLOR_ACCENT="$(tput bold)$(tput setaf 5)"
fi
}
print_banner() {
printf '\n%b+---------------------------+%b\n' "$COLOR_ACCENT" "$COLOR_RESET"
printf '%b| Touchgraph Easy Installer |%b\n' "$COLOR_ACCENT" "$COLOR_RESET"
printf '%b+---------------------------+%b\n' "$COLOR_ACCENT" "$COLOR_RESET"
}
parse_args() {
while [[ $# -gt 0 ]]; do
case "$1" in
--install-dir)
[[ $# -ge 2 ]] || die "--install-dir needs a path"
INSTALL_DIR="$2"
shift 2
;;
--api-url)
[[ $# -ge 2 ]] || die "--api-url needs a URL"
API_URL="$2"
shift 2
;;
--ui-url)
[[ $# -ge 2 ]] || die "--ui-url needs a URL"
UI_URL="$2"
shift 2
;;
--use-existing-compose)
[[ $# -ge 2 ]] || die "--use-existing-compose needs a file path"
USE_EXISTING_COMPOSE="$2"
shift 2
;;
--skip-tests)
RUN_TESTS=0
shift
;;
--no-launch)
AUTO_LAUNCH=0
shift
;;
--non-interactive)
NON_INTERACTIVE=1
shift
;;
--yes)
YES=1
shift
;;
--fresh)
FRESH_INSTALL=1
shift
;;
--remove-all)
REMOVE_ALL=1
shift
;;
--dry-run)
DRY_RUN=1
shift
;;
-h|--help)
usage
exit 0
;;
*)
die "Unknown option: $1"
;;
esac
done
case "$API_URL" in
*/) ;;
*) API_URL="$API_URL/" ;;
esac
}
prompt_value() {
local label="$1"
local default="$2"
local helper="$3"
local answer=""
if [[ -n "$helper" ]]; then
printf ' %s\n' "$helper" >&2
fi
if [[ "$NON_INTERACTIVE" -eq 1 ]]; then
printf '%s\n' "$default"
return
fi
if [[ -n "$default" ]]; then
read -r -p "$label [$default]: " answer
printf '%s\n' "${answer:-$default}"
else
read -r -p "$label: " answer
printf '%s\n' "$answer"
fi
}
looks_like_embedding_ollama_model() {
local model
model="$(printf '%s' "$1" | tr '[:upper:]' '[:lower:]')"
case "$model" in
*embed*|*embedding*|*nomic*|*mxbai*|*bge*|*e5*|*gte*|*minilm*|*snowflake-arctic*)
return 0
;;
*)
return 1
;;
esac
}
ollama_model_candidates() {
local kind="$1"
shift
local model
local selected=()
for model in "$@"; do
case "$kind" in
embeddings)
if looks_like_embedding_ollama_model "$model"; then
selected+=("$model")
fi
;;
chat)
if ! looks_like_embedding_ollama_model "$model"; then
selected+=("$model")
fi
;;
*)
selected+=("$model")
;;
esac
done
if [[ "${#selected[@]}" -eq 0 ]]; then
selected=("$@")
fi
for model in "${selected[@]}"; do
printf '%s\n' "$model"
done
}
ollama_api_bases_for_host() {
local base="${OLLAMA_BASE_URL_VALUE%/}"
base="${base%/v1}"
[[ -n "$base" ]] || base="http://localhost:11434"
printf '%s\n' "$base"
case "$base" in
*host.docker.internal*)
printf '%s\n' "${base//host.docker.internal/localhost}"
;;
*0.0.0.0*)
printf '%s\n' "${base//0.0.0.0/localhost}"
;;
esac
}
list_ollama_models_from_cli_for_host() {
local host="${1:-}"
command_exists ollama || return 0
if [[ -n "$host" ]]; then
OLLAMA_HOST="$host" ollama list 2>/dev/null | awk 'NR > 1 && $1 != "" { print $1 }' || true
else
ollama list 2>/dev/null | awk 'NR > 1 && $1 != "" { print $1 }' || true
fi
}
list_ollama_models_from_cli() {
local base
list_ollama_models_from_cli_for_host
if [[ -n "$OLLAMA_BASE_URL_VALUE" ]]; then
while IFS= read -r base; do
[[ -n "$base" ]] || continue
list_ollama_models_from_cli_for_host "$base"
done < <(ollama_api_bases_for_host)
fi
}
list_ollama_models_from_api() {
command_exists curl || return 0
command_exists python3 || return 0
local base
local response
while IFS= read -r base; do
[[ -n "$base" ]] || continue
response="$(curl -fsS --max-time 2 "${base%/}/api/tags" 2>/dev/null || true)"
[[ -n "$response" ]] || continue
printf '%s' "$response" | python3 -c 'import json, sys
try:
data = json.load(sys.stdin)
except Exception:
raise SystemExit(0)
for model in data.get("models", []):
name = model.get("name") or model.get("model")
if name:
print(name)
' 2>/dev/null || true
done < <(ollama_api_bases_for_host)
}
list_ollama_models() {
{
list_ollama_models_from_cli
list_ollama_models_from_api
} | awk 'NF && !seen[$0]++'
}
ollama_model_name_matches() {
local installed="$1"
local target="$2"
[[ "$installed" == "$target" ]] && return 0
[[ "$target" != *:* && "$installed" == "$target:latest" ]] && return 0
[[ "$installed" != *:* && "$target" == "$installed:latest" ]] && return 0
return 1
}
find_reachable_ollama_cli_host() {
local base
command_exists ollama || return 1
if ollama list >/dev/null 2>&1; then
printf '\n'
return 0
fi
while IFS= read -r base; do
[[ -n "$base" ]] || continue
if OLLAMA_HOST="$base" ollama list >/dev/null 2>&1; then
printf '%s\n' "$base"
return 0
fi
done < <(ollama_api_bases_for_host)
return 1
}
ollama_model_available_via_cli_host() {
local host="$1"
local target="$2"
local model
while IFS= read -r model; do
ollama_model_name_matches "$model" "$target" && return 0
done < <(list_ollama_models_from_cli_for_host "$host")
return 1
}
pull_ollama_model() {
local host="$1"
local model="$2"
local log_file
log_file="$(installer_log_file "ollama-pull-${model//\//-}")"
if [[ -n "$host" ]]; then
run_with_spinner_logged "Downloading Ollama model $model" "$log_file" env OLLAMA_HOST="$host" ollama pull "$model"
else
run_with_spinner_logged "Downloading Ollama model $model" "$log_file" ollama pull "$model"
fi
}
wait_for_ollama_service() {
local timeout="${1:-30}"
local deadline=$((SECONDS + timeout))
while (( SECONDS < deadline )); do
if find_reachable_ollama_cli_host >/dev/null 2>&1; then
return 0
fi
sleep 2
done
return 1
}
start_ollama_service_if_possible() {
local command_text
local log_file="$INSTALL_DIR/ollama.log"
say "Ollama service is not running"
if [[ "$HW_OS" == "Darwin" ]] && command_exists open && [[ -d /Applications/Ollama.app ]]; then
command_text="$(command_to_text open -a Ollama)"
if confirm_install_command "Start the Ollama app now?" "$command_text"; then
open -a Ollama
wait_for_ollama_service 45
return
fi
fi
if command_exists brew; then
command_text="$(command_to_text brew services start ollama)"
if confirm_install_command "Start the Ollama service with Homebrew now?" "$command_text"; then
brew services start ollama
wait_for_ollama_service 45
return
fi
fi
command_text="$(command_to_text ollama serve) > $(printf '%q' "$log_file") 2>&1 &"
if confirm_install_command "Start Ollama in the background now?" "$command_text"; then
mkdir -p "$INSTALL_DIR"
nohup ollama serve > "$log_file" 2>&1 &
wait_for_ollama_service 45
return
fi
return 1
}
offer_single_ollama_model_download() {
local kind="$1"
local default_model="$2"
local selected_model="$3"
local processor_label="$4"
local cli_host="$5"
local question
say "Preparing Ollama $kind model"
info "TrustGraph's Ollama $processor_label default is $default_model."
if ollama_model_available_via_cli_host "$cli_host" "$selected_model"; then
info "Ollama $kind model already available: $selected_model"
return 0
fi
if [[ "$selected_model" == "$default_model" ]]; then
question="Download TrustGraph's preferred Ollama $kind model ($selected_model) now?"
else
question="Download the selected Ollama $kind model ($selected_model) now?"
fi
if confirm "$question" 1; then
info "Downloading $selected_model with Ollama. This may take a while."
if ! pull_ollama_model "$cli_host" "$selected_model"; then
die "Ollama could not download $selected_model. Try running: ollama pull $selected_model"
fi
else
warn "Skipping Ollama $kind model download. TrustGraph's Ollama processor will try to pull $selected_model on first use."
fi
}
offer_ollama_model_downloads() {
local cli_host
[[ "$LLM_MODE" == "ollama" ]] || return 0
if ! command_exists ollama; then
warn "Ollama was selected, but the ollama CLI was not found. Install Ollama and run: ollama pull $OLLAMA_MODEL && ollama pull $OLLAMA_EMBEDDINGS_MODEL"
return 0
fi
if ! cli_host="$(find_reachable_ollama_cli_host)"; then
start_ollama_service_if_possible || true
if ! cli_host="$(find_reachable_ollama_cli_host)"; then
warn "Ollama CLI is installed, but the Ollama service is not reachable. Start Ollama and run: ollama pull $OLLAMA_MODEL && ollama pull $OLLAMA_EMBEDDINGS_MODEL"
return 0
fi
fi
if [[ -n "$cli_host" ]]; then
info "Ollama service: $cli_host"
else
info "Ollama service: local Ollama default"
fi
offer_single_ollama_model_download \
"chat" \
"$DEFAULT_OLLAMA_MODEL" \
"$OLLAMA_MODEL" \
"text-completion" \
"$cli_host"
offer_single_ollama_model_download \
"embeddings" \
"$DEFAULT_OLLAMA_EMBEDDINGS_MODEL" \
"$OLLAMA_EMBEDDINGS_MODEL" \
"embeddings" \
"$cli_host"
}
prompt_ollama_model_choice() {
local label="$1"
local default="$2"
local kind="$3"
local helper="$4"
shift 4
local all_models=("$@")
local candidates=()
local options=()
local model
local answer
local idx
local found_default=0
local detected_default=""
if [[ "$NON_INTERACTIVE" -eq 1 ]]; then
printf '%s\n' "$default"
return
fi
if [[ -n "$helper" ]]; then
printf ' %s\n' "$helper" >&2
fi
if [[ "${#all_models[@]}" -eq 0 ]]; then
prompt_value \
"$label" \
"$default" \
"No local Ollama models were detected. Pull the recommended default with: ollama pull $default"
return
fi
while IFS= read -r model; do
[[ -n "$model" ]] && candidates+=("$model")
done < <(ollama_model_candidates "$kind" "${all_models[@]}")
if [[ "${#candidates[@]}" -eq 0 ]]; then
candidates=("${all_models[@]}")
fi
for model in "${candidates[@]}"; do
if ollama_model_name_matches "$model" "$default"; then
found_default=1
detected_default="$model"
break
fi
done
options+=("$default")
for model in "${candidates[@]}"; do
ollama_model_name_matches "$model" "$default" && continue
options+=("$model")
done
say "Local Ollama ${kind} model choices" >&2
if [[ "$found_default" -eq 1 ]]; then
if [[ "$detected_default" != "$default" ]]; then
info "1) $default (recommended, detected as $detected_default)" >&2
else
info "1) $default (recommended, detected)" >&2
fi
else
info "1) $default (recommended default, not detected locally)" >&2
fi
idx=2
for model in "${options[@]:1}"; do
info "$idx) $model" >&2
idx=$((idx + 1))
done
if [[ "$found_default" -eq 0 ]]; then
info "If you choose a missing model, the installer will offer to download it before startup." >&2
fi
info "Or type another model name, for example one you plan to pull before startup." >&2
read -r -p "$label [1: $default]: " answer
answer="${answer:-1}"
if [[ "$answer" =~ ^[0-9]+$ ]]; then
if (( answer >= 1 && answer <= ${#options[@]} )); then
printf '%s\n' "${options[$((answer - 1))]}"
return
fi
warn "Selection '$answer' is not in the list; using $default."
printf '%s\n' "$default"
return
fi
printf '%s\n' "$answer"
}
prompt_secret() {
local label="$1"
local default="$2"
local helper="$3"
local answer=""
local masked="${4:-}"
if [[ -z "$masked" && -n "$default" ]]; then
masked="set in environment"
elif [[ -z "$masked" ]]; then
masked="blank"
fi
if [[ -n "$helper" ]]; then
printf ' %s\n' "$helper" >&2
fi
if [[ "$NON_INTERACTIVE" -eq 1 ]]; then
printf '%s\n' "$default"
return
fi
read -r -s -p "$label [$masked]: " answer
printf '\n' >&2
printf '%s\n' "${answer:-$default}"
}
confirm() {
local question="$1"
local default_yes="$2"
local answer=""
local prompt="[y/N]"
if [[ "$YES" -eq 1 ]]; then
return 0
fi
if [[ "$NON_INTERACTIVE" -eq 1 ]]; then
[[ "$default_yes" -eq 1 ]]
return
fi
if [[ "$default_yes" -eq 1 ]]; then
prompt="[Y/n]"
fi
read -r -p "$question $prompt " answer
answer="${answer:-}"
if [[ -z "$answer" ]]; then
[[ "$default_yes" -eq 1 ]]
return
fi
[[ "$answer" =~ ^[Yy] ]]
}
path_within_install_dir() {
local path="$1"
case "$path" in
"$INSTALL_DIR"/*) return 0 ;;
*) return 1 ;;
esac
}
safe_existing_path_within_install_dir() {
local path="$1"
local resolved_install
local resolved_parent
local resolved_path
local parent
local base
[[ -d "$INSTALL_DIR" ]] || return 1
[[ -e "$path" || -L "$path" ]] || return 1
resolved_install="$(cd "$INSTALL_DIR" && pwd -P)"
parent="$(dirname "$path")"
base="$(basename "$path")"
[[ -d "$parent" ]] || return 1
resolved_parent="$(cd "$parent" && pwd -P)"
resolved_path="$resolved_parent/$base"
case "$resolved_path" in
"$resolved_install"/*) return 0 ;;
*) return 1 ;;
esac
}
installer_artifact_paths() {
local candidates=(
"$INSTALL_DIR/deploy.zip"
"$INSTALL_DIR/deploy"
"$INSTALL_DIR/INSTALLATION.md"
"$INSTALL_DIR/trustgraph-installer.env"
"$INSTALL_DIR/iam-bootstrap.log"
"$INSTALL_DIR/ollama.log"
"$INSTALL_DIR/logs"
"$INSTALL_DIR/pip_cache"
)
local path
for path in "$VENV_DIR" "$NLTK_DATA_DIR" "$TIKTOKEN_CACHE_DIR_VALUE"; do
if path_within_install_dir "$path"; then
candidates+=("$path")
fi
done
for path in "${candidates[@]}"; do
if [[ -e "$path" || -L "$path" ]]; then
printf '%s\n' "$path"
fi
done
}
installer_artifacts_present() {
local path
while IFS= read -r path; do
[[ -n "$path" ]] && return 0
done < <(installer_artifact_paths)
return 1
}
assert_safe_cleanup_target() {
local resolved_install
local resolved_script
local resolved_home=""
[[ -n "$INSTALL_DIR" ]] || die "Install directory is empty; refusing cleanup."
case "$INSTALL_DIR" in
/|.|..) die "Install directory '$INSTALL_DIR' is too broad; refusing cleanup." ;;
esac
if [[ -d "$INSTALL_DIR" ]]; then
resolved_install="$(cd "$INSTALL_DIR" && pwd -P)"
else
return 0
fi
resolved_script="$(cd "$SCRIPT_DIR" && pwd -P)"
if [[ -n "${HOME:-}" && -d "$HOME" ]]; then
resolved_home="$(cd "$HOME" && pwd -P)"
fi
[[ "$resolved_install" != "$resolved_script" ]] || die "Install directory resolves to the source checkout; refusing cleanup."
[[ -z "$resolved_home" || "$resolved_install" != "$resolved_home" ]] || die "Install directory resolves to your home directory; refusing cleanup."
}
find_existing_compose_file() {
[[ -d "$INSTALL_DIR" ]] || return 0
find "$INSTALL_DIR/deploy" "$INSTALL_DIR" \
\( -name 'docker-compose.yaml' -o -name 'docker-compose.yml' -o -name 'compose.yaml' -o -name 'compose.yml' \) \
-type f 2>/dev/null | head -n 1
}
stop_previous_stack_if_possible() {
local compose_file
compose_file="$(find_existing_compose_file || true)"
[[ -n "$compose_file" ]] || return 0
if ! confirm "Stop any containers from the previous deployment first? Docker volumes will be kept." 1; then
info "Leaving any previous containers untouched."
return
fi
if ! detect_compose_command; then
warn "Could not find Docker Compose or podman-compose, so previous containers were not stopped."
return
fi
if "${COMPOSE_CMD[@]}" -f "$compose_file" down --remove-orphans; then
info "Stopped previous compose deployment using $compose_file"
else
warn "Could not stop previous compose deployment. Continuing with file cleanup only."
fi
}
remove_installer_artifacts_only() {
local path
say "Removing installer-managed files"
while IFS= read -r path; do
[[ -n "$path" ]] || continue
if ! safe_existing_path_within_install_dir "$path"; then
warn "Skipping cleanup path outside install directory: $path"
continue
fi
info "Removing $path"
rm -rf -- "$path"
done < <(installer_artifact_paths)
rmdir "$INSTALL_DIR" 2>/dev/null || true
}
cleanup_installer_artifacts() {
assert_safe_cleanup_target
stop_previous_stack_if_possible
remove_installer_artifacts_only
}
find_uninstall_compose_file() {
if [[ -n "$USE_EXISTING_COMPOSE" ]]; then
[[ -f "$USE_EXISTING_COMPOSE" ]] || die "Compose file does not exist: $USE_EXISTING_COMPOSE"
printf '%s\n' "$USE_EXISTING_COMPOSE"
return
fi
find_existing_compose_file || true
}
print_uninstall_plan() {
local compose_file="$1"
local path
local found=0
say "Uninstall plan"
info "Install directory: $INSTALL_DIR"
if [[ -n "$compose_file" ]]; then
info "Compose file: $compose_file"
info "Will stop containers and remove compose-managed volumes for this deployment."
else
info "No compose file was found, so no containers or volumes can be removed automatically."
fi
info "Installer-managed files to remove:"
while IFS= read -r path; do
[[ -n "$path" ]] || continue
found=1
info "- $path"
done < <(installer_artifact_paths)
if [[ "$found" -eq 0 ]]; then
info "- none found"
fi
info "Will not remove Docker/Podman, container images, external volumes, Ollama, Ollama models, or this source checkout."
}
remove_compose_stack_for_uninstall() {
local compose_file="$1"
[[ -n "$compose_file" ]] || return 0
say "Stopping TrustGraph containers and volumes"
if ! detect_compose_command; then
warn "Could not find Docker Compose or podman-compose, so containers and volumes were not removed."
return
fi
if "${COMPOSE_CMD[@]}" -f "$compose_file" down --remove-orphans --volumes; then
info "Removed compose containers, networks, and compose-managed volumes."
return
fi
warn "Compose did not accept the volume removal command; trying to stop containers without removing volumes."
if "${COMPOSE_CMD[@]}" -f "$compose_file" down --remove-orphans; then
warn "Containers were stopped, but compose volumes may remain."
else
warn "Could not stop the compose deployment. Installer-managed files will still be removed."
fi
}
remove_all_installation() {
local compose_file
assert_safe_cleanup_target
compose_file="$(find_uninstall_compose_file || true)"
print_uninstall_plan "$compose_file"
if [[ "$DRY_RUN" -eq 1 ]]; then
say "Dry run complete"
return 0
fi
if [[ -z "$compose_file" ]] && ! installer_artifacts_present; then
say "Nothing to remove"
info "No installer-managed files or compose deployment were found."
return 0
fi
if ! confirm "Remove the TrustGraph deployment listed above?" 0; then
die "Uninstall cancelled."
fi
remove_compose_stack_for_uninstall "$compose_file"
remove_installer_artifacts_only
say "TrustGraph installer-managed deployment removed"
info "Ollama models were left in place because they may be shared with other tools."
}
handle_existing_install() {
local path
local found=0
[[ -z "$USE_EXISTING_COMPOSE" ]] || return 0
installer_artifacts_present || return 0
say "Existing installer output detected"
info "Install directory: $INSTALL_DIR"
while IFS= read -r path; do
[[ -n "$path" ]] || continue
found=1
info "Found: $path"
done < <(installer_artifact_paths)
[[ "$found" -eq 1 ]] || return 0
if [[ "$DRY_RUN" -eq 1 ]]; then
if [[ "$FRESH_INSTALL" -eq 1 ]]; then
info "Dry run: --fresh would remove the files listed above."
else
info "Dry run: existing files would be kept unless you choose --fresh."
fi
return 0
fi
if [[ "$FRESH_INSTALL" -eq 1 ]]; then
cleanup_installer_artifacts
return 0
fi
if confirm "Treat this as a fresh install and delete only the installer-managed files listed above?" 0; then
cleanup_installer_artifacts
else
info "Continuing with the existing installer output."
fi
}
load_saved_answers() {
local env_file="$INSTALL_DIR/trustgraph-installer.env"
[[ -f "$env_file" ]] || return 0
local current_api_url="$API_URL"
local current_ui_url="$UI_URL"
local current_auth_token="$AUTH_TOKEN"
local current_venv_dir="$VENV_DIR"
local current_nltk_data_dir="$NLTK_DATA_DIR"
local current_tiktoken_cache_dir="$TIKTOKEN_CACHE_DIR_VALUE"
local current_llm_mode="$LLM_MODE"
local current_openai_base_url="$OPENAI_BASE_URL_VALUE"
local current_openai_token="$OPENAI_TOKEN_VALUE"
local current_ollama_base_url="$OLLAMA_BASE_URL_VALUE"
local current_ollama_model="$OLLAMA_MODEL"
local current_ollama_embeddings_model="$OLLAMA_EMBEDDINGS_MODEL"
# The file is generated by this installer with shell-escaped exports and 0600 permissions.
# shellcheck disable=SC1090
source "$env_file"
if [[ "$current_api_url" == "$DEFAULT_API_URL" && -n "${TRUSTGRAPH_URL:-}" ]]; then
API_URL="$TRUSTGRAPH_URL"
else
API_URL="$current_api_url"
fi
if [[ "$current_ui_url" == "$DEFAULT_UI_URL" && -n "${TRUSTGRAPH_UI_URL:-}" ]]; then
UI_URL="$TRUSTGRAPH_UI_URL"
else
UI_URL="$current_ui_url"
fi
if [[ -z "$current_auth_token" && -n "${TRUSTGRAPH_TOKEN:-}" ]]; then
AUTH_TOKEN="$TRUSTGRAPH_TOKEN"
elif [[ -z "$current_auth_token" && -n "${IAM_BOOTSTRAP_TOKEN:-}" ]]; then
AUTH_TOKEN="$IAM_BOOTSTRAP_TOKEN"
else
AUTH_TOKEN="$current_auth_token"
fi
if [[ -z "${TG_VENV_DIR:-}" && -n "${current_venv_dir:-}" ]]; then
VENV_DIR="$current_venv_dir"
elif [[ -n "${TG_VENV_DIR:-}" ]]; then
VENV_DIR="$TG_VENV_DIR"
fi
if [[ -z "${TG_NLTK_DATA_DIR:-}" && -n "$current_nltk_data_dir" ]]; then
NLTK_DATA_DIR="$current_nltk_data_dir"
elif [[ -n "${TG_NLTK_DATA_DIR:-}" ]]; then
NLTK_DATA_DIR="$TG_NLTK_DATA_DIR"
fi
if [[ -z "${TIKTOKEN_CACHE_DIR:-}" && -n "$current_tiktoken_cache_dir" ]]; then
TIKTOKEN_CACHE_DIR_VALUE="$current_tiktoken_cache_dir"
elif [[ -n "${TIKTOKEN_CACHE_DIR:-}" ]]; then
TIKTOKEN_CACHE_DIR_VALUE="$TIKTOKEN_CACHE_DIR"
fi
if [[ -z "$current_llm_mode" && -n "${TRUSTGRAPH_LLM_MODE:-}" ]]; then
LLM_MODE="$TRUSTGRAPH_LLM_MODE"
else
LLM_MODE="$current_llm_mode"
fi
if [[ "$current_openai_base_url" == "https://api.openai.com/v1" && -n "${OPENAI_BASE_URL:-}" ]]; then
OPENAI_BASE_URL_VALUE="$OPENAI_BASE_URL"
else
OPENAI_BASE_URL_VALUE="$current_openai_base_url"
fi
if [[ -z "$current_openai_token" && -n "${OPENAI_TOKEN:-}" ]]; then
OPENAI_TOKEN_VALUE="$OPENAI_TOKEN"
else
OPENAI_TOKEN_VALUE="$current_openai_token"
fi
if [[ -z "$current_ollama_base_url" ]]; then
OLLAMA_BASE_URL_VALUE="${OLLAMA_HOST:-${OLLAMA_BASE_URL:-}}"
else
OLLAMA_BASE_URL_VALUE="$current_ollama_base_url"
fi
if [[ "$current_ollama_model" == "$DEFAULT_OLLAMA_MODEL" && -n "${OLLAMA_MODEL:-}" ]]; then
OLLAMA_MODEL="$OLLAMA_MODEL"
else
OLLAMA_MODEL="$current_ollama_model"
fi
if [[ "$current_ollama_embeddings_model" == "$DEFAULT_OLLAMA_EMBEDDINGS_MODEL" && -n "${OLLAMA_EMBEDDINGS_MODEL:-}" ]]; then
OLLAMA_EMBEDDINGS_MODEL="$OLLAMA_EMBEDDINGS_MODEL"
else
OLLAMA_EMBEDDINGS_MODEL="$current_ollama_embeddings_model"
fi
case "$API_URL" in
*/) ;;
*) API_URL="$API_URL/" ;;
esac
info "Loaded saved answers from $env_file"
}
bytes_to_gb() {
local bytes="$1"
awk "BEGIN { printf \"%.0f\", $bytes / 1024 / 1024 / 1024 }"
}
detect_hardware() {
HW_OS="$(uname -s 2>/dev/null || printf 'unknown')"
HW_ARCH="$(uname -m 2>/dev/null || printf 'unknown')"
if [[ "$HW_OS" == "Darwin" ]]; then
HW_CPU_CORES="$(sysctl -n hw.logicalcpu 2>/dev/null || getconf _NPROCESSORS_ONLN 2>/dev/null || python3 -c 'import os; print(os.cpu_count() or "unknown")' 2>/dev/null || printf 'unknown')"
local mem_bytes
mem_bytes="$(sysctl -n hw.memsize 2>/dev/null || true)"
if [[ -z "$mem_bytes" ]] && command_exists python3; then
mem_bytes="$(python3 -c 'import os; print(os.sysconf("SC_PHYS_PAGES") * os.sysconf("SC_PAGE_SIZE"))' 2>/dev/null || true)"
fi
if [[ -n "$mem_bytes" ]]; then
HW_MEMORY_GB="$(bytes_to_gb "$mem_bytes")"
fi
if [[ "$HW_ARCH" == "arm64" ]]; then
HW_GPU="Apple Silicon unified GPU"
fi
HW_CONTAINER_HINT="Docker Desktop or Podman Desktop works well on macOS."
elif [[ "$HW_OS" == "Linux" ]]; then
HW_CPU_CORES="$(nproc 2>/dev/null || getconf _NPROCESSORS_ONLN 2>/dev/null || python3 -c 'import os; print(os.cpu_count() or "unknown")' 2>/dev/null || printf 'unknown')"
if [[ -r /proc/meminfo ]]; then
local mem_kb
mem_kb="$(awk '/MemTotal/ { print $2 }' /proc/meminfo)"
if [[ -n "$mem_kb" ]]; then
HW_MEMORY_GB="$(awk "BEGIN { printf \"%.0f\", $mem_kb / 1024 / 1024 }")"
fi
fi
if command_exists nvidia-smi; then
HW_GPU="$(nvidia-smi --query-gpu=name,memory.total --format=csv,noheader 2>/dev/null | head -n 1 || true)"
[[ -n "$HW_GPU" ]] || HW_GPU="NVIDIA GPU detected"
elif command_exists lspci; then
HW_GPU="$(lspci 2>/dev/null | awk 'BEGIN{IGNORECASE=1} /VGA|3D|Display/ {print; exit}')"
[[ -n "$HW_GPU" ]] || HW_GPU="none detected"
fi
HW_CONTAINER_HINT="Docker Engine, Docker Desktop, or Podman can run the compose stack."
else
HW_CONTAINER_HINT="Use Docker or Podman with compose support."
fi
}
is_number() {
[[ "$1" =~ ^[0-9]+$ ]]
}
choose_recommendations() {
local mem=0
local cores=0
if is_number "$HW_MEMORY_GB"; then
mem="$HW_MEMORY_GB"
fi
if is_number "$HW_CPU_CORES"; then
cores="$HW_CPU_CORES"
fi
if [[ -z "$OLLAMA_BASE_URL_VALUE" ]]; then
if [[ "$HW_OS" == "Darwin" ]]; then
OLLAMA_BASE_URL_VALUE="http://host.docker.internal:11434"
else
OLLAMA_BASE_URL_VALUE="http://localhost:11434"
fi
fi
if [[ -n "$LLM_MODE" ]]; then
RECOMMENDED_LLM_MODE="$LLM_MODE"
RECOMMENDATION_REASON="Using the LLM provider saved from the previous installer run."
return
fi
if (( mem >= 16 )) && { [[ "$HW_GPU" != "none detected" ]] || (( cores >= 8 )); }; then
RECOMMENDED_LLM_MODE="ollama"
RECOMMENDATION_REASON="This machine looks comfortable for a small local Ollama model."
elif (( mem >= 8 )); then
RECOMMENDED_LLM_MODE="openai"
RECOMMENDATION_REASON="Local Ollama may work with a small model, but a hosted OpenAI-compatible endpoint is smoother on this hardware."
else
RECOMMENDED_LLM_MODE="openai"
RECOMMENDATION_REASON="Memory looks tight for local LLMs, so a hosted OpenAI-compatible endpoint is the friendlier default."
fi
if [[ -n "${OPENAI_TOKEN:-}" && "${OPENAI_TOKEN:-}" != "ollama" ]]; then
RECOMMENDED_LLM_MODE="openai"
RECOMMENDATION_REASON="OPENAI_TOKEN is already set, so the hosted/OpenAI-compatible path is ready to use."
fi
}
print_hardware_summary() {
say "Detected hardware"
info "OS: $HW_OS"
info "Architecture: $HW_ARCH"
info "CPU cores: $HW_CPU_CORES"
info "Memory: $HW_MEMORY_GB GB"
info "GPU: $HW_GPU"
info "$HW_CONTAINER_HINT"
say "Recommended install shape"
info "LLM path: $RECOMMENDED_LLM_MODE"
info "$RECOMMENDATION_REASON"
info "Default Workbench UI: $UI_URL"
info "Default API gateway: $API_URL"
}
generate_token() {
if command_exists openssl; then
printf 'tg_%s\n' "$(openssl rand -base64 24 | tr '+/' '-_' | tr -d '=')"
elif command_exists python3; then
python3 -c 'import secrets; print("tg_" + secrets.token_urlsafe(24))'
else
die "Need openssl or python3 to generate a secure TrustGraph API key."
fi
}
ensure_compliant_api_key() {
local token="$1"
if [[ "$token" == tg_* ]]; then
printf '%s\n' "$token"
return
fi
warn "TrustGraph API keys must start with 'tg_'; the provided value will not authenticate at the gateway."
if [[ "$NON_INTERACTIVE" -eq 1 ]]; then
warn "Non-interactive mode: replacing the non-compliant key with a generated TrustGraph API key."
generate_token
return
fi
if confirm "Generate a compliant TrustGraph API key now?" 1; then
generate_token
return
fi
die "TrustGraph API key must start with 'tg_'."
}
collect_answers() {
local generated_token
local token_default
local token_mask
generated_token="$(generate_token)"
if [[ -n "$AUTH_TOKEN" ]]; then
token_default="$AUTH_TOKEN"
token_mask="set in environment"
else
token_default="$generated_token"
token_mask="generated tg_ key"
fi
AUTH_TOKEN="$(prompt_secret \
"TrustGraph admin/bootstrap API key" \
"$token_default" \
"Recommendation: press Enter to use a generated TrustGraph API key beginning with tg_; it will be stored in the installer env file with restricted permissions." \
"$token_mask")"
AUTH_TOKEN="$(ensure_compliant_api_key "$AUTH_TOKEN")"
LLM_MODE="$(prompt_value \
"LLM provider: ollama, openai, or none" \
"${LLM_MODE:-$RECOMMENDED_LLM_MODE}" \
"Recommendation: $RECOMMENDED_LLM_MODE. $RECOMMENDATION_REASON")"
LLM_MODE="$(printf '%s' "$LLM_MODE" | tr '[:upper:]' '[:lower:]')"
case "$LLM_MODE" in
ollama)
OLLAMA_BASE_URL_VALUE="$(prompt_value \
"Ollama base URL" \
"$OLLAMA_BASE_URL_VALUE" \
"If Ollama runs on your laptop and TrustGraph runs in Docker, host.docker.internal is usually the right host on macOS/Windows.")"
local ollama_models=()
local ollama_model
if [[ "$NON_INTERACTIVE" -ne 1 ]]; then
while IFS= read -r ollama_model; do
[[ -n "$ollama_model" ]] && ollama_models+=("$ollama_model")
done < <(list_ollama_models)
fi
if [[ "${#ollama_models[@]}" -gt 0 ]]; then
OLLAMA_MODEL="$(prompt_ollama_model_choice \
"Ollama chat model" \
"$OLLAMA_MODEL" \
"chat" \
"Recommendation from the local Ollama processor defaults: $DEFAULT_OLLAMA_MODEL for a quick first run." \
"${ollama_models[@]}")"
OLLAMA_EMBEDDINGS_MODEL="$(prompt_ollama_model_choice \
"Ollama embeddings model" \
"$OLLAMA_EMBEDDINGS_MODEL" \
"embeddings" \
"Recommendation from the local Ollama embeddings defaults: $DEFAULT_OLLAMA_EMBEDDINGS_MODEL." \
"${ollama_models[@]}")"
else
OLLAMA_MODEL="$(prompt_ollama_model_choice \
"Ollama chat model" \
"$OLLAMA_MODEL" \
"chat" \
"Recommendation from the local Ollama processor defaults: $DEFAULT_OLLAMA_MODEL for a quick first run.")"
OLLAMA_EMBEDDINGS_MODEL="$(prompt_ollama_model_choice \
"Ollama embeddings model" \
"$OLLAMA_EMBEDDINGS_MODEL" \
"embeddings" \
"Recommendation from the local Ollama embeddings defaults: $DEFAULT_OLLAMA_EMBEDDINGS_MODEL.")"
fi
OPENAI_BASE_URL_VALUE="${OLLAMA_BASE_URL_VALUE%/}/v1"
OPENAI_TOKEN_VALUE="${OPENAI_TOKEN_VALUE:-ollama}"
;;
openai)
OPENAI_BASE_URL_VALUE="$(prompt_value \
"OpenAI-compatible base URL" \
"$OPENAI_BASE_URL_VALUE" \
"Use https://api.openai.com/v1 for OpenAI, or your provider's OpenAI-compatible /v1 endpoint.")"
OPENAI_TOKEN_VALUE="$(prompt_secret \
"OpenAI-compatible API key" \
"$OPENAI_TOKEN_VALUE" \
"Press Enter to reuse OPENAI_TOKEN if set; leave blank only if your endpoint does not require a key.")"
;;
none|skip)
LLM_MODE="none"
warn "Continuing without an LLM key. The platform can start, but agent/RAG calls will need an LLM configured later."
;;
*)
warn "Unknown LLM provider '$LLM_MODE'; using '$RECOMMENDED_LLM_MODE'."
LLM_MODE="$RECOMMENDED_LLM_MODE"
;;
esac
INSTALL_DIR="$(prompt_value \
"Installer output directory" \
"$INSTALL_DIR" \
"This keeps deploy.zip, compose files, logs, and saved answers together.")"
if [[ -z "${TG_VENV_DIR:-}" ]]; then
VENV_DIR="$INSTALL_DIR/.venv"
fi
if [[ -z "${TG_NLTK_DATA_DIR:-}" ]]; then
NLTK_DATA_DIR="$INSTALL_DIR/nltk_data"
fi
if [[ -z "${TIKTOKEN_CACHE_DIR:-}" ]]; then
TIKTOKEN_CACHE_DIR_VALUE="$INSTALL_DIR/tiktoken_cache"
fi
}
print_plan_summary() {
say "Install plan"
info "Install directory: $INSTALL_DIR"
info "Python venv: $VENV_DIR"
info "NLTK data: $NLTK_DATA_DIR"
info "Tokenizer cache: $TIKTOKEN_CACHE_DIR_VALUE"
info "Run all tests: $([[ "$RUN_TESTS" -eq 1 ]] && printf yes || printf no)"
if [[ -n "$USE_EXISTING_COMPOSE" ]]; then
info "Compose file: $USE_EXISTING_COMPOSE"
else
info "Config generator: npx @trustgraph/config"
fi
info "LLM provider: $LLM_MODE"
if [[ "$LLM_MODE" == "ollama" ]]; then
info "Ollama URL: $OLLAMA_BASE_URL_VALUE"
info "Ollama model: $OLLAMA_MODEL"
info "Ollama embeddings model: $OLLAMA_EMBEDDINGS_MODEL"
elif [[ "$LLM_MODE" == "openai" ]]; then
info "OpenAI-compatible URL: $OPENAI_BASE_URL_VALUE"
fi
info "Health check timeout: ${HEALTH_TIMEOUT}s"
info "Autolaunch UI: $([[ "$AUTO_LAUNCH" -eq 1 ]] && printf yes || printf no)"
}
detect_compose_command() {
if command_exists docker && docker compose version >/dev/null 2>&1; then
COMPOSE_CMD=(docker compose)
elif command_exists docker-compose; then
COMPOSE_CMD=(docker-compose)
elif command_exists podman-compose; then
COMPOSE_CMD=(podman-compose)
else
return 1
fi
}
wait_for_docker_ready() {
local timeout="${1:-60}"
local deadline=$((SECONDS + timeout))
while (( SECONDS < deadline )); do
if docker info >/dev/null 2>&1; then
return 0
fi
sleep 2
done
return 1
}
wait_for_podman_ready() {
local timeout="${1:-60}"
local deadline=$((SECONDS + timeout))
while (( SECONDS < deadline )); do
if podman info >/dev/null 2>&1; then
return 0
fi
sleep 2
done
return 1
}
start_docker_runtime_if_possible() {
local command_text
say "Docker is installed but not running"
if [[ "$HW_OS" == "Darwin" ]] && command_exists open && [[ -d /Applications/Docker.app ]]; then
command_text="$(command_to_text open -a Docker)"
if confirm_install_command "Start Docker Desktop now?" "$command_text"; then
open -a Docker
wait_for_docker_ready 90
return
fi
fi
if command_exists systemctl; then
command_text="$(root_command_to_text systemctl start docker)"
if confirm_install_command "Start the Docker service now?" "$command_text"; then
run_root_command systemctl start docker
wait_for_docker_ready 60
return
fi
fi
return 1
}
start_podman_runtime_if_possible() {
local command_text
say "Podman is installed but not running"
if [[ "$HW_OS" == "Darwin" ]] && command_exists podman; then
command_text="$(command_to_text podman machine init) && $(command_to_text podman machine start)"
if confirm_install_command "Start a local Podman machine now?" "$command_text"; then
podman machine init >/dev/null 2>&1 || true
podman machine start
wait_for_podman_ready 90
return
fi
fi
if command_exists systemctl; then
command_text="$(command_to_text systemctl --user start podman.socket)"
if confirm_install_command "Start the user Podman socket now?" "$command_text"; then
systemctl --user start podman.socket
wait_for_podman_ready 30
return
fi
fi
return 1
}
check_container_runtime_ready() {
case "${COMPOSE_CMD[0]}" in
docker|docker-compose)
if ! docker info >/dev/null 2>&1; then
start_docker_runtime_if_possible || true
docker info >/dev/null 2>&1 || die "Docker is installed, but the Docker daemon is not reachable. Start Docker Desktop or Docker Engine and run this installer again."
fi
;;
podman-compose)
if ! podman info >/dev/null 2>&1; then
start_podman_runtime_if_possible || true
podman info >/dev/null 2>&1 || die "Podman is installed, but the Podman service is not reachable. Start Podman Desktop or the Podman machine and run this installer again."
fi
;;
esac
}
install_with_brew() {
local label="$1"
shift
local command_text
local log_file
command_text="$(command_to_text brew install "$@")"
log_file="$(installer_log_file "brew-install-${label// /-}")"
if confirm_install_command "Install $label with Homebrew now?" "$command_text"; then
run_with_spinner_logged "Installing $label with Homebrew" "$log_file" brew install "$@"
else
return 1
fi
}
install_with_apt() {
local label="$1"
shift
local command_text
command_text="$(root_command_to_text apt-get update) && $(root_command_to_text apt-get install -y "$@")"
if confirm_install_command "Install $label with apt now?" "$command_text"; then
run_root_command apt-get update
run_root_command apt-get install -y "$@"
else
return 1
fi
}
install_with_dnf() {
local label="$1"
shift
local command_text
command_text="$(root_command_to_text dnf install -y "$@")"
if confirm_install_command "Install $label with dnf now?" "$command_text"; then
run_root_command dnf install -y "$@"
else
return 1
fi
}
install_with_yum() {
local label="$1"
shift
local command_text
command_text="$(root_command_to_text yum install -y "$@")"
if confirm_install_command "Install $label with yum now?" "$command_text"; then
run_root_command yum install -y "$@"
else
return 1
fi
}
install_with_pacman() {
local label="$1"
shift
local command_text
command_text="$(root_command_to_text pacman -Sy --noconfirm "$@")"
if confirm_install_command "Install $label with pacman now?" "$command_text"; then
run_root_command pacman -Sy --noconfirm "$@"
else
return 1
fi
}
install_with_zypper() {
local label="$1"
shift
local command_text
command_text="$(root_command_to_text zypper install -y "$@")"
if confirm_install_command "Install $label with zypper now?" "$command_text"; then
run_root_command zypper install -y "$@"
else
return 1
fi
}
install_python3_prerequisite() {
if command_exists brew; then
install_with_brew "Python 3" python
elif command_exists apt-get; then
install_with_apt "Python 3" python3 python3-venv python3-pip
elif command_exists dnf; then
install_with_dnf "Python 3" python3 python3-pip
elif command_exists yum; then
install_with_yum "Python 3" python3 python3-pip
elif command_exists pacman; then
install_with_pacman "Python 3" python
elif command_exists zypper; then
install_with_zypper "Python 3" python3 python3-pip python3-venv
else
warn "No supported package manager was found. Install Python 3 manually, then run this installer again."
return 1
fi
}
install_python_venv_prerequisite() {
if command_exists apt-get; then
install_with_apt "Python venv support" python3-venv
elif command_exists zypper; then
install_with_zypper "Python venv support" python3-venv
elif command_exists brew || command_exists dnf || command_exists yum || command_exists pacman; then
info "Python venv support is usually bundled with the Python package on this platform."
return 1
else
warn "Install Python's venv module manually, then run this installer again."
return 1
fi
}
install_basic_tool_prerequisite() {
local tool="$1"
if command_exists brew; then
install_with_brew "$tool" "$tool"
elif command_exists apt-get; then
install_with_apt "$tool" "$tool"
elif command_exists dnf; then
install_with_dnf "$tool" "$tool"
elif command_exists yum; then
install_with_yum "$tool" "$tool"
elif command_exists pacman; then
install_with_pacman "$tool" "$tool"
elif command_exists zypper; then
install_with_zypper "$tool" "$tool"
else
warn "No supported package manager was found. Install $tool manually, then run this installer again."
return 1
fi
}
install_node_prerequisite() {
if command_exists brew; then
install_with_brew "Node.js and npx" node
elif command_exists apt-get; then
install_with_apt "Node.js and npx" nodejs npm
elif command_exists dnf; then
install_with_dnf "Node.js and npx" nodejs npm
elif command_exists yum; then
install_with_yum "Node.js and npx" nodejs npm
elif command_exists pacman; then
install_with_pacman "Node.js and npx" nodejs npm
elif command_exists zypper; then
install_with_zypper "Node.js and npx" nodejs npm
else
warn "No supported package manager was found. Install Node.js/npm manually, then run this installer again."
return 1
fi
}
start_podman_machine_if_needed() {
[[ "$HW_OS" == "Darwin" ]] || return 0
command_exists podman || return 0
if podman info >/dev/null 2>&1; then
return 0
fi
if ! confirm_install_command \
"Start a local Podman machine now?" \
"$(command_to_text podman machine init) && $(command_to_text podman machine start)"; then
return 1
fi
podman machine init >/dev/null 2>&1 || true
podman machine start
}
install_compose_prerequisite() {
if command_exists docker && ! docker compose version >/dev/null 2>&1; then
if command_exists brew; then
install_with_brew "Docker Compose" docker-compose
elif command_exists apt-get; then
install_with_apt "Docker Compose plugin" docker-compose-plugin
elif command_exists dnf; then
install_with_dnf "Docker Compose plugin" docker-compose-plugin
elif command_exists yum; then
install_with_yum "Docker Compose plugin" docker-compose-plugin
elif command_exists pacman; then
install_with_pacman "Docker Compose" docker-compose
elif command_exists zypper; then
install_with_zypper "Docker Compose" docker-compose
else
warn "Install Docker Compose manually, then run this installer again."
return 1
fi
return
fi
if command_exists podman && ! command_exists podman-compose; then
if command_exists brew; then
install_with_brew "podman-compose" podman-compose
elif command_exists apt-get; then
install_with_apt "podman-compose" podman-compose
elif command_exists dnf; then
install_with_dnf "podman-compose" podman-compose
elif command_exists yum; then
install_with_yum "podman-compose" podman-compose
elif command_exists pacman; then
install_with_pacman "podman-compose" podman-compose
elif command_exists zypper; then
install_with_zypper "podman-compose" podman-compose
else
warn "Install podman-compose manually, then run this installer again."
return 1
fi
start_podman_machine_if_needed || true
return
fi
if command_exists brew; then
info "Docker Desktop also works well. The CLI-friendly fallback is Podman plus podman-compose."
install_with_brew "Podman and podman-compose" podman podman-compose
start_podman_machine_if_needed || true
elif command_exists apt-get; then
install_with_apt "Podman and podman-compose" podman podman-compose
elif command_exists dnf; then
install_with_dnf "Podman and podman-compose" podman podman-compose
elif command_exists yum; then
install_with_yum "Podman and podman-compose" podman podman-compose
elif command_exists pacman; then
install_with_pacman "Podman and podman-compose" podman podman-compose
elif command_exists zypper; then
install_with_zypper "Podman and podman-compose" podman podman-compose
else
warn "Install Docker Desktop, Docker Engine with Compose, or Podman with podman-compose, then run this installer again."
return 1
fi
}
install_ollama_prerequisite() {
local command_text
if command_exists brew; then
install_with_brew "Ollama" ollama
elif [[ "$HW_OS" == "Linux" ]] && command_exists curl; then
command_text="curl -fsSL https://ollama.com/install.sh | sh"
info "This uses Ollama's official Linux install script."
if confirm_install_command "Install Ollama now?" "$command_text"; then
sh -c "$command_text"
else
return 1
fi
else
warn "Install Ollama from https://ollama.com/download, then run this installer again."
return 1
fi
}
ensure_python3_available() {
command_exists python3 && return 0
say "Python 3 is missing"
install_python3_prerequisite || die "Python 3 is required to run tests and helper CLIs."
command_exists python3 || die "Python 3 was not found after installation. Open a new terminal or add it to PATH, then run this installer again."
}
ensure_python_venv_available() {
python3 -m venv --help >/dev/null 2>&1 && return 0
say "Python venv support is missing"
install_python_venv_prerequisite || die "Python venv support is required to create the installer environment."
python3 -m venv --help >/dev/null 2>&1 || die "Python venv support is still unavailable. Open a new terminal or install python3-venv manually."
}
ensure_basic_tool_available() {
local tool="$1"
local reason="$2"
command_exists "$tool" && return 0
say "$tool is missing"
info "$reason"
install_basic_tool_prerequisite "$tool" || die "$tool is required. Install it manually, then run this installer again."
command_exists "$tool" || die "$tool was not found after installation. Open a new terminal or add it to PATH, then run this installer again."
}
ensure_npx_available() {
[[ -n "$USE_EXISTING_COMPOSE" ]] && return 0
command_exists npx && return 0
say "npx is missing"
info "npx is required for the existing TrustGraph config generator: npx @trustgraph/config."
install_node_prerequisite || die "npx is required. Install Node.js/npm manually, then run this installer again."
command_exists npx || die "npx was not found after installation. Open a new terminal or add it to PATH, then run this installer again."
}
ensure_compose_available() {
detect_compose_command && return 0
say "Container compose support is missing"
info "TrustGraph runs as a compose stack. Docker Compose or podman-compose is required."
install_compose_prerequisite || die "Docker Compose or podman-compose is required to start TrustGraph."
detect_compose_command || die "Compose support was not found after installation. Open a new terminal or add it to PATH, then run this installer again."
}
ensure_ollama_available_if_needed() {
[[ "$LLM_MODE" == "ollama" ]] || return 0
command_exists ollama && return 0
say "Ollama is missing"
info "Ollama was selected for local LLMs, so the Ollama CLI and service are needed before model setup."
install_ollama_prerequisite || die "Ollama is required for the selected local LLM path. Install it manually, then run this installer again."
command_exists ollama || die "Ollama was not found after installation. Open a new terminal or add it to PATH, then run this installer again."
}
preflight() {
say "Checking prerequisites"
ensure_python3_available
ensure_python_venv_available
ensure_basic_tool_available unzip "unzip is required to unpack deploy.zip from the config generator."
ensure_basic_tool_available curl "curl is required for startup health checks and local service probes."
ensure_npx_available
ensure_compose_available
ensure_ollama_available_if_needed
check_container_runtime_ready
info "Compose command: ${COMPOSE_CMD[*]}"
info "Python: $(python3 --version 2>&1)"
if command_exists npx; then
info "npx: $(npx --version 2>/dev/null || printf unknown)"
fi
}
write_env_file() {
mkdir -p "$INSTALL_DIR"
local env_file="$INSTALL_DIR/trustgraph-installer.env"
local grafana_admin_password="${GF_SECURITY_ADMIN_PASSWORD:-${GRAFANA_ADMIN_PASSWORD:-$AUTH_TOKEN}}"
umask 077
{
printf 'export TRUSTGRAPH_URL=%q\n' "$API_URL"
printf 'export TRUSTGRAPH_UI_URL=%q\n' "$UI_URL"
printf 'export TRUSTGRAPH_TOKEN=%q\n' "$AUTH_TOKEN"
printf 'export TRUSTGRAPH_BOOTSTRAP_TOKEN=%q\n' "$AUTH_TOKEN"
printf 'export IAM_BOOTSTRAP_TOKEN=%q\n' "$AUTH_TOKEN"
printf 'export GF_SECURITY_ADMIN_PASSWORD=%q\n' "$grafana_admin_password"
printf 'export TG_VENV_DIR=%q\n' "$VENV_DIR"
printf 'export TG_NLTK_DATA_DIR=%q\n' "$NLTK_DATA_DIR"
printf 'export NLTK_DATA=%q\n' "$NLTK_DATA_DIR${NLTK_DATA:+:$NLTK_DATA}"
printf 'export TIKTOKEN_CACHE_DIR=%q\n' "$TIKTOKEN_CACHE_DIR_VALUE"
printf 'export TRUSTGRAPH_LLM_MODE=%q\n' "$LLM_MODE"
printf 'export OPENAI_BASE_URL=%q\n' "$OPENAI_BASE_URL_VALUE"
printf 'export OPENAI_TOKEN=%q\n' "$OPENAI_TOKEN_VALUE"
printf 'export OLLAMA_HOST=%q\n' "$OLLAMA_BASE_URL_VALUE"
printf 'export OLLAMA_BASE_URL=%q\n' "$OLLAMA_BASE_URL_VALUE"
printf 'export OLLAMA_MODEL=%q\n' "$OLLAMA_MODEL"
printf 'export OLLAMA_EMBEDDINGS_MODEL=%q\n' "$OLLAMA_EMBEDDINGS_MODEL"
} > "$env_file"
chmod 600 "$env_file"
info "Saved answers to $env_file"
}
prepare_python_env() {
say "Preparing Python environment"
mkdir -p "$INSTALL_DIR"
if [[ ! -x "$VENV_DIR/bin/python" ]]; then
info "Creating venv at $VENV_DIR"
run_with_spinner "Creating Python venv" python3 -m venv "$VENV_DIR"
else
info "Using existing venv at $VENV_DIR"
fi
PYTHON_BIN="$VENV_DIR/bin/python"
export PATH="$VENV_DIR/bin:$PATH"
info "Python venv: $($PYTHON_BIN --version 2>&1)"
}
ensure_version_files() {
local version="${TRUSTGRAPH_LOCAL_VERSION:-2.5.0}"
local specs=(
"trustgraph-base/trustgraph/base_version.py:trustgraph.base_version"
"trustgraph-flow/trustgraph/flow_version.py:trustgraph.flow_version"
"trustgraph-vertexai/trustgraph/vertexai_version.py:trustgraph.vertexai_version"
"trustgraph-bedrock/trustgraph/bedrock_version.py:trustgraph.bedrock_version"
"trustgraph-embeddings-hf/trustgraph/embeddings_hf_version.py:trustgraph.embeddings_hf_version"
"trustgraph-cli/trustgraph/cli_version.py:trustgraph.cli_version"
"trustgraph-ocr/trustgraph/ocr_version.py:trustgraph.ocr_version"
"trustgraph-unstructured/trustgraph/unstructured_version.py:trustgraph.unstructured_version"
"trustgraph-mcp/trustgraph/mcp_version.py:trustgraph.mcp_version"
"trustgraph/trustgraph/trustgraph_version.py:trustgraph.trustgraph_version"
)
say "Ensuring local package version files"
for spec in "${specs[@]}"; do
local file="${spec%%:*}"
mkdir -p "$(dirname "$SCRIPT_DIR/$file")"
printf '__version__ = "%s"\n' "$version" > "$SCRIPT_DIR/$file"
info "Set $file to $version"
done
}
local_package_pythonpath() {
local package_dirs=(
"$SCRIPT_DIR/trustgraph-flow"
"$SCRIPT_DIR/trustgraph-embeddings-hf"
"$SCRIPT_DIR/trustgraph-base"
"$SCRIPT_DIR/trustgraph-cli"
"$SCRIPT_DIR/trustgraph-bedrock"
"$SCRIPT_DIR/trustgraph-ocr"
"$SCRIPT_DIR/trustgraph-unstructured"
"$SCRIPT_DIR/trustgraph-mcp"
"$SCRIPT_DIR/trustgraph-vertexai"
"$SCRIPT_DIR/trustgraph"
)
local joined=""
local dir
for dir in "${package_dirs[@]}"; do
if [[ -d "$dir" ]]; then
if [[ -n "$joined" ]]; then
joined="$joined:$dir"
else
joined="$dir"
fi
fi
done
printf '%s\n' "$joined"
}
ensure_python_build_tools() {
say "Preparing Python build tools"
local pip_cache_dir="$INSTALL_DIR/pip_cache"
mkdir -p "$pip_cache_dir"
if ! "$PYTHON_BIN" -m pip --version >/dev/null 2>&1; then
local ensurepip_log
ensurepip_log="$(installer_log_file "python-ensurepip")"
info "Installing pip into the Python venv"
run_with_spinner_logged \
"Installing pip" \
"$ensurepip_log" \
"$PYTHON_BIN" -m ensurepip --upgrade \
|| die "Could not install pip into the Python venv."
fi
if "$PYTHON_BIN" - <<'PY' >/dev/null 2>&1
import setuptools.build_meta
PY
then
info "Python build backend available: setuptools.build_meta"
return
fi
local log_file
log_file="$(installer_log_file "pip-build-tools")"
info "Installing setuptools and wheel into the Python venv"
run_with_spinner_logged \
"Installing Python build tools" \
"$log_file" \
env \
PIP_CACHE_DIR="$pip_cache_dir" \
PIP_DISABLE_PIP_VERSION_CHECK=1 \
"$PYTHON_BIN" -m pip install "setuptools>=61" wheel \
|| die "Could not install setuptools/wheel. Check $log_file, then re-run the installer."
}
install_test_packages() {
say "Installing local Python packages for tests"
local pip_cache_dir="$INSTALL_DIR/pip_cache"
mkdir -p "$pip_cache_dir"
ensure_python_build_tools
local package_dirs=(
trustgraph-base
trustgraph-cli
trustgraph-flow
trustgraph-vertexai
trustgraph-bedrock
trustgraph-embeddings-hf
trustgraph-ocr
trustgraph-unstructured
trustgraph-mcp
)
for package_dir in "${package_dirs[@]}"; do
if [[ -d "$SCRIPT_DIR/$package_dir" ]]; then
local log_file
log_file="$(installer_log_file "pip-${package_dir}")"
info "Installing $package_dir"
run_with_spinner_logged \
"Installing $package_dir" \
"$log_file" \
env \
PIP_CACHE_DIR="$pip_cache_dir" \
PIP_DISABLE_PIP_VERSION_CHECK=1 \
"$PYTHON_BIN" -m pip install --no-build-isolation "$SCRIPT_DIR/$package_dir"
fi
done
if [[ -f "$SCRIPT_DIR/tests/requirements.txt" ]]; then
local log_file
log_file="$(installer_log_file "pip-test-requirements")"
info "Installing test requirements"
run_with_spinner_logged \
"Installing test requirements" \
"$log_file" \
env \
PIP_CACHE_DIR="$pip_cache_dir" \
PIP_DISABLE_PIP_VERSION_CHECK=1 \
"$PYTHON_BIN" -m pip install -r "$SCRIPT_DIR/tests/requirements.txt"
fi
}
ensure_tokenizer_cache() {
say "Preparing tokenizer cache"
mkdir -p "$TIKTOKEN_CACHE_DIR_VALUE"
info "tiktoken cache: $TIKTOKEN_CACHE_DIR_VALUE"
TIKTOKEN_CACHE_DIR="$TIKTOKEN_CACHE_DIR_VALUE" "$PYTHON_BIN" - <<'PY'
import tiktoken
tiktoken.get_encoding("cl100k_base")
print(" Cached tiktoken encoding: cl100k_base")
PY
}
ensure_nltk_data() {
say "Preparing NLTK tokenizer data"
mkdir -p "$NLTK_DATA_DIR"
info "NLTK data: $NLTK_DATA_DIR"
TG_NLTK_DATA_DIR="$NLTK_DATA_DIR" \
NLTK_DATA="$NLTK_DATA_DIR${NLTK_DATA:+:$NLTK_DATA}" \
"$PYTHON_BIN" - <<'PY'
import os
import nltk
target = os.environ["TG_NLTK_DATA_DIR"]
if target not in nltk.data.path:
nltk.data.path.insert(0, target)
resources = (
("punkt", "tokenizers/punkt"),
("punkt_tab", "tokenizers/punkt_tab"),
("averaged_perceptron_tagger_eng", "taggers/averaged_perceptron_tagger_eng"),
)
for package, resource in resources:
try:
nltk.data.find(resource)
except LookupError:
print(f" Downloading NLTK resource: {package}")
if not nltk.download(package, download_dir=target, quiet=True):
raise SystemExit(f"Could not download NLTK resource: {package}")
else:
print(f" NLTK resource already available: {package}")
PY
}
run_all_tests() {
if [[ "$RUN_TESTS" -ne 1 ]]; then
warn "Skipping tests because --skip-tests was supplied."
return
fi
prepare_python_env
ensure_version_files
install_test_packages
ensure_tokenizer_cache
ensure_nltk_data
say "Running all tests"
info "Command: $PYTHON_BIN -m pytest tests"
local test_log
test_log="$(installer_log_file "pytest")"
if spinner_enabled; then
info "Test output log: $test_log"
fi
(
cd "$SCRIPT_DIR"
run_with_spinner_logged \
"Running pytest tests" \
"$test_log" \
env \
INSTALL_TRUSTGRAPH_SOURCE_ONLY= \
TG_NO_SPINNER= \
TG_FORCE_SPINNER= \
NLTK_DATA="$NLTK_DATA_DIR${NLTK_DATA:+:$NLTK_DATA}" \
TIKTOKEN_CACHE_DIR="$TIKTOKEN_CACHE_DIR_VALUE" \
TRUSTGRAPH_CASSANDRA_SKIP_ON_UNREADY=1 \
"$PYTHON_BIN" -m pytest tests
)
}
show_config_guidance() {
say "Before the config wizard starts"
info "Choose a Docker/Podman compose deployment for local installation."
info "Keep the Workbench UI enabled; the existing UI default is port 8888."
info "Use the bundled infrastructure defaults: Cassandra, Qdrant, Garage, and RabbitMQ/Pulsar as offered."
if [[ -n "$AUTH_TOKEN" ]]; then
info "For IAM/auth, use token/bootstrap-token mode when offered."
info "Admin/bootstrap API key to enter if asked: $AUTH_TOKEN"
else
info "For IAM/auth, use token/bootstrap-token mode when offered and paste the API key saved by this installer."
fi
if [[ "$LLM_MODE" == "ollama" ]]; then
info "For LLMs, choose Ollama or an OpenAI-compatible endpoint and use $OLLAMA_BASE_URL_VALUE."
elif [[ "$LLM_MODE" == "openai" ]]; then
info "For LLMs, choose OpenAI/OpenAI-compatible and use $OPENAI_BASE_URL_VALUE."
else
info "You can skip LLM configuration now and add it later in the Workbench."
fi
}
run_config_generator() {
if [[ -n "$USE_EXISTING_COMPOSE" ]]; then
return
fi
mkdir -p "$INSTALL_DIR"
if [[ -f "$INSTALL_DIR/deploy.zip" ]]; then
if confirm "Existing deploy.zip found in $INSTALL_DIR. Reuse it and skip the config wizard?" 1; then
info "Using existing deployment archive: $INSTALL_DIR/deploy.zip"
return
fi
fi
show_config_guidance
if ! confirm "Start the TrustGraph config wizard now?" 1; then
die "Config generation cancelled."
fi
say "Running TrustGraph config generator"
(
cd "$INSTALL_DIR"
TRUSTGRAPH_TOKEN="$AUTH_TOKEN" \
TRUSTGRAPH_BOOTSTRAP_TOKEN="$AUTH_TOKEN" \
OPENAI_TOKEN="$OPENAI_TOKEN_VALUE" \
OPENAI_BASE_URL="$OPENAI_BASE_URL_VALUE" \
OLLAMA_HOST="$OLLAMA_BASE_URL_VALUE" \
OLLAMA_BASE_URL="$OLLAMA_BASE_URL_VALUE" \
OLLAMA_MODEL="$OLLAMA_MODEL" \
OLLAMA_EMBEDDINGS_MODEL="$OLLAMA_EMBEDDINGS_MODEL" \
NLTK_DATA="$NLTK_DATA_DIR${NLTK_DATA:+:$NLTK_DATA}" \
TIKTOKEN_CACHE_DIR="$TIKTOKEN_CACHE_DIR_VALUE" \
npx @trustgraph/config
)
}
find_compose_file() {
if [[ -n "$USE_EXISTING_COMPOSE" ]]; then
[[ -f "$USE_EXISTING_COMPOSE" ]] || die "Compose file does not exist: $USE_EXISTING_COMPOSE"
printf '%s\n' "$USE_EXISTING_COMPOSE"
return
fi
local deploy_zip="$INSTALL_DIR/deploy.zip"
local unpack_dir="$INSTALL_DIR/deploy"
[[ -f "$deploy_zip" ]] || die "The config generator did not create $deploy_zip"
rm -rf "$unpack_dir"
mkdir -p "$unpack_dir"
unzip -oq "$deploy_zip" -d "$unpack_dir"
local compose_file
compose_file="$(find "$unpack_dir" "$INSTALL_DIR" \
\( -name 'docker-compose.yaml' -o -name 'docker-compose.yml' -o -name 'compose.yaml' -o -name 'compose.yml' \) \
-type f | head -n 1)"
[[ -n "$compose_file" ]] || die "Could not find a compose file in $deploy_zip"
printf '%s\n' "$compose_file"
}
compose_dir_for() {
local compose_file="$1"
(cd "$(dirname "$compose_file")" && pwd -P)
}
compose_env_file_for() {
local compose_file="$1"
local compose_dir
compose_dir="$(compose_dir_for "$compose_file")"
printf '%s/.env\n' "$compose_dir"
}
write_compose_env_file() {
local compose_file="$1"
local compose_env_file
local grafana_admin_password="${GF_SECURITY_ADMIN_PASSWORD:-${GRAFANA_ADMIN_PASSWORD:-$AUTH_TOKEN}}"
[[ -n "$AUTH_TOKEN" ]] || die "TrustGraph API key is empty; cannot create compose environment."
compose_env_file="$(compose_env_file_for "$compose_file")"
umask 077
{
printf 'TRUSTGRAPH_TOKEN=%s\n' "$AUTH_TOKEN"
printf 'TRUSTGRAPH_BOOTSTRAP_TOKEN=%s\n' "$AUTH_TOKEN"
printf 'IAM_BOOTSTRAP_TOKEN=%s\n' "$AUTH_TOKEN"
printf 'GF_SECURITY_ADMIN_PASSWORD=%s\n' "$grafana_admin_password"
printf 'OLLAMA_HOST=%s\n' "$OLLAMA_BASE_URL_VALUE"
printf 'OLLAMA_BASE_URL=%s\n' "$OLLAMA_BASE_URL_VALUE"
printf 'OLLAMA_MODEL=%s\n' "$OLLAMA_MODEL"
printf 'OLLAMA_EMBEDDINGS_MODEL=%s\n' "$OLLAMA_EMBEDDINGS_MODEL"
} > "$compose_env_file"
chmod 600 "$compose_env_file"
info "Compose environment: $compose_env_file"
}
start_stack() {
local compose_file="$1"
local compose_dir
local compose_name
local log_file
say "Starting TrustGraph"
info "Compose file: $compose_file"
write_compose_env_file "$compose_file"
compose_dir="$(compose_dir_for "$compose_file")"
compose_name="$(basename "$compose_file")"
log_file="$(installer_log_file "compose-up")"
case "$log_file" in
/*) ;;
*) log_file="$SCRIPT_DIR/$log_file" ;;
esac
(
cd "$compose_dir"
run_with_spinner_logged \
"Starting TrustGraph containers" \
"$log_file" \
"${COMPOSE_CMD[@]}" -f "$compose_name" up -d
)
}
http_status() {
local url="$1"
curl -sS -o /dev/null -w '%{http_code}' --max-time 5 "$url" 2>/dev/null || true
}
http_status_with_bearer() {
local url="$1"
local token="$2"
curl -sS -o /dev/null -w '%{http_code}' --max-time 5 \
-H "Authorization: Bearer $token" \
"$url" 2>/dev/null || true
}
sha256_text() {
local value="$1"
printf '%s' "$value" | python3 -c 'import hashlib, sys; print(hashlib.sha256(sys.stdin.buffer.read()).hexdigest())'
}
repair_local_iam_api_key() {
local compose_file="$1"
local compose_dir
local compose_name
local key_hash
local key_suffix
local user_id
local username
local key_id
local prefix
local cql
local log_file
[[ -n "$compose_file" && -f "$compose_file" ]] || return 1
[[ -n "$AUTH_TOKEN" ]] || return 1
command_exists python3 || return 1
[[ "${#COMPOSE_CMD[@]}" -gt 0 ]] || return 1
key_hash="$(sha256_text "$AUTH_TOKEN")"
key_suffix="${key_hash:0:12}"
user_id="installer-admin-$key_suffix"
username="installer-admin-$key_suffix"
key_id="installer-key-$key_suffix"
prefix="$(printf '%s' "${AUTH_TOKEN:0:7}" | tr -cd 'a-zA-Z0-9_-')"
compose_dir="$(compose_dir_for "$compose_file")"
compose_name="$(basename "$compose_file")"
log_file="$(installer_log_file "iam-key-repair")"
cql="
USE iam;
INSERT INTO iam_users (id, workspace, username, name, email, password_hash, roles, enabled, must_change_password, created)
VALUES ('$user_id', 'default', '$username', 'Installer Admin', '', 'installer-repair', {'admin'}, true, false, toTimestamp(now()));
INSERT INTO iam_users_by_username (workspace, username, user_id)
VALUES ('default', '$username', '$user_id');
INSERT INTO iam_api_keys (key_hash, id, user_id, name, prefix, expires, created, last_used)
VALUES ('$key_hash', '$key_id', '$user_id', 'installer-repair', '$prefix', null, toTimestamp(now()), null);
"
say "Repairing local IAM API key"
info "Adding the saved installer key to the local installer-managed IAM database."
mkdir -p "$(dirname "$log_file")"
if (
cd "$compose_dir"
printf '%s\n' "$cql" | "${COMPOSE_CMD[@]}" -f "$compose_name" exec -T cassandra cqlsh
) >"$log_file" 2>&1; then
info "Local IAM API key repair completed."
return 0
fi
warn "Local IAM API key repair failed. Last log lines from $log_file:"
tail -n 40 "$log_file" >&2 || true
return 1
}
wait_for_gateway() {
local deadline=$((SECONDS + HEALTH_TIMEOUT))
local next_notice=$((SECONDS + 15))
local status=""
say "Waiting for API gateway"
info "Checking $API_URL for up to ${HEALTH_TIMEOUT}s."
while (( SECONDS < deadline )); do
status="$(http_status "$API_URL")"
if [[ "$status" == "200" || "$status" == "401" || "$status" == "404" ]]; then
info "API gateway is responding with HTTP $status"
return 0
fi
if (( SECONDS >= next_notice )); then
info "Still waiting; last HTTP status was ${status:-connection failed}."
next_notice=$((SECONDS + 15))
fi
sleep 3
done
die "API gateway did not respond at $API_URL within ${HEALTH_TIMEOUT}s"
}
verify_api_key_authentication() {
local compose_file="${1:-}"
local deadline=$((SECONDS + AUTH_CHECK_TIMEOUT))
local metrics_url="${API_URL%/}/api/metrics/query?query=processor_info"
local status=""
[[ -n "$AUTH_TOKEN" ]] || return 0
say "Checking API key authentication"
info "The API gateway root can return HTTP 404; that is normal. This checks an authenticated endpoint."
while :; do
status="$(http_status_with_bearer "$metrics_url" "$AUTH_TOKEN")"
case "$status" in
200)
info "Installer API key authenticated at the API gateway."
return 0
;;
401|403)
;;
"")
;;
*)
info "Authentication probe returned HTTP $status; continuing to the full health checks."
return 0
;;
esac
(( SECONDS >= deadline )) && break
sleep 3
done
if [[ "$status" == "401" || "$status" == "403" ]]; then
if [[ -n "$compose_file" ]] && repair_local_iam_api_key "$compose_file"; then
status="$(http_status_with_bearer "$metrics_url" "$AUTH_TOKEN")"
if [[ "$status" == "200" ]]; then
info "Installer API key authenticated after local IAM repair."
return 0
fi
fi
warn "The API gateway is running, but it rejected the installer API key."
info "Configured installer API key: $AUTH_TOKEN"
info "Saved environment: $INSTALL_DIR/trustgraph-installer.env"
warn "This usually means compose volumes contain IAM data from an earlier install. Run ./install_trustgraph.sh --remove-all to remove the installer-managed deployment and compose volumes, then reinstall; or rerun with the original TRUSTGRAPH_TOKEN if you know it."
return 1
fi
warn "Could not confirm API key authentication yet; continuing to the full health checks."
}
bootstrap_iam_if_available() {
local bootstrap_output=""
local log_file="$INSTALL_DIR/iam-bootstrap.log"
if ! command_exists tg-bootstrap-iam; then
warn "tg-bootstrap-iam is not on PATH; using the installer API key for health checks."
return
fi
say "Checking IAM bootstrap"
if bootstrap_output="$(tg-bootstrap-iam --api-url "$API_URL" 2>"$log_file")"; then
if [[ -n "$bootstrap_output" ]]; then
AUTH_TOKEN="$bootstrap_output"
info "Captured the first-run admin API key from IAM bootstrap."
write_env_file
fi
else
info "IAM bootstrap did not issue a new key; this is normal for token mode or an already-bootstrapped system."
info "Details: $log_file"
fi
}
verify_system() {
local verify_cmd=()
if command_exists tg-verify-system-status; then
verify_cmd=(tg-verify-system-status)
elif "$PYTHON_BIN" -c 'import trustgraph.cli.verify_system_status' >/dev/null 2>&1; then
verify_cmd=("$PYTHON_BIN" -m trustgraph.cli.verify_system_status)
else
say "Verifying TrustGraph health"
info "API gateway: $API_URL"
info "Workbench UI: $UI_URL"
[[ "$(http_status "$API_URL")" =~ ^(200|401|404)$ ]] || die "API gateway health check failed."
[[ "$(http_status "${UI_URL%/}/index.html")" == "200" ]] || warn "Workbench UI did not return HTTP 200 yet."
return
fi
say "Verifying TrustGraph health"
verify_cmd+=(
--api-url "$API_URL"
--ui-url "$UI_URL"
--global-timeout "$HEALTH_TIMEOUT"
)
if [[ -n "$AUTH_TOKEN" ]]; then
verify_cmd+=(--token "$AUTH_TOKEN")
fi
"${verify_cmd[@]}"
}
launch_ui() {
if [[ "$AUTO_LAUNCH" -ne 1 ]]; then
info "Workbench UI autolaunch disabled."
return
fi
say "Opening Workbench UI"
if command_exists open; then
open "$UI_URL"
elif command_exists xdg-open; then
xdg-open "$UI_URL"
elif command_exists wslview; then
wslview "$UI_URL"
else
warn "Could not find a browser launcher. Open this URL manually: $UI_URL"
return
fi
info "Workbench UI: $UI_URL"
}
print_ready_summary() {
local auth_status="${1:-0}"
if [[ "$auth_status" -eq 0 ]]; then
say "TrustGraph is ready"
else
say "TrustGraph started with an authentication warning"
fi
info "Workbench UI: $UI_URL"
info "API gateway: $API_URL"
if [[ -n "$AUTH_TOKEN" ]]; then
info "Admin/bootstrap API key: $AUTH_TOKEN"
fi
info "Saved environment: $INSTALL_DIR/trustgraph-installer.env"
}
main() {
parse_args "$@"
cd "$SCRIPT_DIR"
init_colors
print_banner
if [[ "$REMOVE_ALL" -eq 1 ]]; then
say "$APP_NAME guided uninstaller"
load_saved_answers
remove_all_installation
return 0
fi
say "$APP_NAME guided installer"
handle_existing_install
load_saved_answers
detect_hardware
choose_recommendations
print_hardware_summary
collect_answers
print_plan_summary
if [[ "$DRY_RUN" -eq 1 ]]; then
say "Dry run complete"
return 0
fi
if ! confirm "Proceed with this install plan?" 1; then
die "Install cancelled."
fi
preflight
offer_ollama_model_downloads
write_env_file
run_all_tests
run_config_generator
local compose_file
compose_file="$(find_compose_file)"
start_stack "$compose_file"
wait_for_gateway
bootstrap_iam_if_available
local auth_status=0
local verify_status=0
verify_api_key_authentication "$compose_file" || auth_status=$?
if [[ "$auth_status" -eq 0 ]]; then
verify_system || verify_status=$?
else
warn "Skipping authenticated health checks because the configured API key was rejected."
fi
launch_ui
print_ready_summary "$auth_status"
if [[ "$auth_status" -ne 0 ]]; then
return "$auth_status"
fi
if [[ "$verify_status" -ne 0 ]]; then
return "$verify_status"
fi
}
if [[ "${INSTALL_TRUSTGRAPH_SOURCE_ONLY:-0}" != "1" ]]; then
main "$@"
fi