diff --git a/README.dev-install.md b/README.dev-install.md new file mode 100644 index 00000000..d57cb1f3 --- /dev/null +++ b/README.dev-install.md @@ -0,0 +1,218 @@ +# TrustGraph Developer Install Guide + +A guided installer that gets TrustGraph running locally in a single +command. It detects your hardware, recommends an LLM backend, installs +missing prerequisites, runs the test suite, generates a compose deployment, +starts the stack, and opens the Workbench UI. + +> **macOS only.** This installer has only been tested on macOS. If you are +> on Linux or Windows, use the standard docker-compose / podman-compose +> installation instructions instead. + +## Quick start + +```bash +./install_trustgraph.sh +``` + +The installer walks you through each step interactively. When it finishes, +the Workbench UI opens at `http://localhost:8888` and the API gateway is +available at `http://localhost:8088/`. + +## Prerequisites + +The installer checks for these and offers to install any that are missing +(via Homebrew): + +- **Python 3** with venv support +- **Node.js / npx** (drives the `@trustgraph/config` deployment generator) +- **Docker** (with Compose) or **Podman** (with podman-compose) +- **curl** and **unzip** +- **Ollama** (only if you choose local LLMs) + +The installer can also launch Docker Desktop or the Ollama app for you if +they are installed but not running. + +## What the installer does + +1. **Detects hardware** -- OS, architecture, CPU cores, memory, and GPU. +2. **Recommends an LLM mode** -- `ollama` for machines with >= 16 GB RAM and + a GPU or >= 8 cores; `openai` otherwise. +3. **Collects configuration** -- API key, LLM provider, model choices, + install directory. Answers are saved to + `/trustgraph-installer.env` and reused on subsequent runs. +4. **Checks and installs prerequisites** -- Python, Node/npx, Docker or + Podman, Ollama (if selected). +5. **Downloads Ollama models** (if using Ollama) -- chat model + (`granite4:350m` by default) and embeddings model (`mxbai-embed-large`). +6. **Creates a Python venv** and installs the local TrustGraph packages into + it, along with NLTK data and tiktoken caches. +7. **Runs the full pytest suite** against the local source tree. +8. **Runs `npx @trustgraph/config`** -- the existing interactive config + wizard that produces a `deploy.zip` with a compose file. +9. **Starts the compose stack** and waits for the API gateway to respond. +10. **Bootstraps IAM** and verifies the API key authenticates. +11. **Opens the Workbench UI** in your default browser. + +## Command-line options + +| Option | Description | +|---|---| +| `--install-dir PATH` | Directory for deployment files (default: `./trustgraph-deploy`) | +| `--api-url URL` | API gateway URL for health checks (default: `http://localhost:8088/`) | +| `--ui-url URL` | Workbench UI URL to open (default: `http://localhost:8888`) | +| `--use-existing-compose FILE` | Skip config generation and start this compose file directly | +| `--skip-tests` | Do not run the pytest suite | +| `--no-launch` | Do not open the Workbench UI at the end | +| `--non-interactive` | Accept all defaults without prompting | +| `--yes` | Auto-accept confirmation prompts | +| `--fresh` | Remove installer-managed files before generating a new deployment | +| `--remove-all` | Uninstall: stop containers, remove compose volumes, delete installer files | +| `--dry-run` | Print detected hardware and planned defaults, then exit | +| `-h`, `--help` | Show the built-in help text | + +## Environment variables + +These override the interactive prompts when set: + +| Variable | Purpose | +|---|---| +| `TRUSTGRAPH_TOKEN` | Admin/bootstrap API key (must start with `tg_`) | +| `TRUSTGRAPH_URL` | API gateway URL | +| `TRUSTGRAPH_UI_URL` | Workbench UI URL | +| `OPENAI_TOKEN` | OpenAI-compatible API key | +| `OPENAI_BASE_URL` | OpenAI-compatible base URL | +| `OLLAMA_HOST` / `OLLAMA_BASE_URL` | Ollama service URL | +| `OLLAMA_MODEL` | Ollama chat model (default: `granite4:350m`) | +| `OLLAMA_EMBEDDINGS_MODEL` | Ollama embeddings model (default: `mxbai-embed-large`) | +| `TG_INSTALL_DIR` | Override the install directory | +| `TG_VENV_DIR` | Override the Python venv location | +| `TG_NLTK_DATA_DIR` | Override the NLTK data directory | +| `TIKTOKEN_CACHE_DIR` | Override the tiktoken cache directory | +| `TG_HEALTH_TIMEOUT` | Seconds to wait for the API gateway (default: 240) | + +## Choosing an LLM mode + +### OpenAI (or any OpenAI-compatible provider) + +Best when you already have an API key or are running against a remote +endpoint. The installer asks for a base URL and an API key. + +```bash +OPENAI_TOKEN=sk-... ./install_trustgraph.sh +``` + +### Ollama (local models) + +Best on machines with enough RAM to run a small model. The installer detects +locally installed Ollama models and offers to pull missing ones. It uses +`host.docker.internal` so the Docker containers can reach the host-side +Ollama service. + +```bash +./install_trustgraph.sh # choose "ollama" when prompted +``` + +### None + +Start the platform without an LLM. Agent and RAG features will not work +until you configure one later through the Workbench. + +## Saved answers and re-running + +The installer saves your answers to +`/trustgraph-installer.env`. On the next run it loads those +answers as defaults, so you can re-run with a single Enter through each +prompt. + +To start completely fresh: + +```bash +./install_trustgraph.sh --fresh +``` + +This stops any running containers (keeping Docker volumes), removes +installer-managed files, and re-runs the full flow. + +## Using an existing compose file + +If you already have a compose file from the config tool or another source: + +```bash +./install_trustgraph.sh --use-existing-compose path/to/docker-compose.yaml +``` + +This skips the config wizard and `npx` prerequisite check, and goes straight +to starting the stack. + +## Non-interactive / CI usage + +```bash +TRUSTGRAPH_TOKEN=tg_my-token \ +OPENAI_TOKEN=sk-... \ +./install_trustgraph.sh --non-interactive --yes --skip-tests +``` + +In non-interactive mode the installer uses defaults for every prompt. Pair +with `--yes` to auto-accept confirmation prompts and `--skip-tests` if you +want a faster run. + +## Dry run + +Preview what the installer would do without making any changes: + +```bash +./install_trustgraph.sh --dry-run +``` + +This prints the detected hardware, recommended LLM mode, and planned +install paths, then exits. + +## Uninstalling + +```bash +./install_trustgraph.sh --remove-all +``` + +This stops containers, removes compose-managed volumes, and deletes +installer-managed files (venv, deploy output, logs, saved answers). It does +**not** remove Docker/Podman itself, container images, Ollama, or Ollama +models. + +## Troubleshooting + +### Logs + +All long-running operations write logs to `/logs/`. Key files: + +- `pytest.log` -- test suite output +- `compose-up.log` -- docker compose output +- `iam-bootstrap.log` -- IAM bootstrap output +- `ollama-pull-*.log` -- Ollama model downloads +- `pip-*.log` -- Python package installs +- `brew-install-*.log` -- Homebrew installs + +### API key rejected after reinstall + +If the API gateway returns 401/403 with your saved key, the compose volumes +likely contain IAM data from a previous install with a different key. Run: + +```bash +./install_trustgraph.sh --remove-all +./install_trustgraph.sh +``` + +This clears the old volumes and starts fresh. + +### Ollama not reachable from containers + +The Ollama base URL should use `host.docker.internal` instead of +`localhost` so that containers running in Docker Desktop can reach the +host-side Ollama service. The installer sets this automatically; if you +override `OLLAMA_HOST`, make sure the URL is reachable from inside the +container network. + +### Docker daemon not running + +The installer detects Docker Desktop and offers to start it. If that +doesn't work, start Docker Desktop manually and re-run the installer. diff --git a/install_trustgraph.sh b/install_trustgraph.sh new file mode 100644 index 00000000..b3919791 --- /dev/null +++ b/install_trustgraph.sh @@ -0,0 +1,2603 @@ +#!/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