#!/usr/bin/env bash # bootstrap_sources — source-anchored dependency build into /mnt/AI. # # Every Thicket dependency is mirrored as a git checkout under # /distfiles/git/ and built into THE venv at # /runtime/thicket-venv — one environment, always, outside # the project tree so the checkout stays clean for git and archives. # Launchers land in /tools/bin and ~/.local/bin. # Idempotent: re-run to update. # # Methods (per package, table below): # source — pip install from the local git checkout (pure Python) # wheel — pip install the release wheel; the git checkout is still # mirrored for audit. Native-heavy builds (faiss, lancedb, # duckdb, fastembed/onnxruntime, PySide6/Qt) produce the # same code as the wheel at a fraction of the build cost. # --force-source builds everything from checkouts. # # Repository sync is state-aware: # absent -> atomic clone (staged in a temp dir, moved into place) # corrupt -> dir without .git is moved aside, then re-cloned # dirty -> local modifications are never destroyed; the package # is skipped with the offending files listed # moved -> fast-forward to FETCH_HEAD only when HEAD differs; # unchanged checkouts are NOT reinstalled (the venv is # probed for the importable module first) # Step-down: a failed source build falls back to the wheel (logged), # so one broken upstream never wedges the whole bootstrap. # # Usage: # scripts/bootstrap_sources.sh # asks which projects # (Enter = all) in a # terminal; installs all # when non-interactive # scripts/bootstrap_sources.sh --only a,b # subset, no prompt # scripts/bootstrap_sources.sh --force-source # build native too # scripts/bootstrap_sources.sh --update # git pull + reinstall # # Native build deps (auto-installed via pacman when missing, sudo): # git gcc make cmake rustup postgresql-libs set -euo pipefail AI_ROOT="${THICKET_AI_ROOT:-/mnt/AI}" PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" GIT_MIRROR="$AI_ROOT/distfiles/git" VENV_DIR="$AI_ROOT/runtime/thicket-venv" BIN_DIR="$AI_ROOT/tools/bin" LOG_DIR="$AI_ROOT/logs" LOG="$LOG_DIR/thicket-bootstrap.log" FORCE_SOURCE=0 UPDATE_ONLY=0 ONLY="" mode="${1:-}" case "$mode" in --force-source) FORCE_SOURCE=1 ;; --update) UPDATE_ONLY=1 ;; --only) ONLY="${2:-}"; [ -n "$ONLY" ] || { echo "--only needs a list"; exit 2; } ;; "") ;; *) echo "unknown option: $mode"; exit 2 ;; esac mkdir -p "$GIT_MIRROR" "$VENV_DIR" "$BIN_DIR" "$LOG_DIR" exec > >(tee -a "$LOG") 2>&1 log() { printf '\033[1;32m>>>\033[0m %s\n' "$*"; } warn() { printf '\033[1;33m>>> WARN\033[0m %s\n' "$*"; } # ── name | git url | pip name | extras | method ───────────────────────── # Pure-python projects install from their checkout; the native-heavy # set keeps a mirrored checkout but installs the release wheel. PACKAGES=( "qdrant-client|https://github.com/qdrant/qdrant-client.git|qdrant-client||source|qdrant_client|vector target: qdrant (service)" "chromadb|https://github.com/chroma-core/chroma.git|chromadb||source|chromadb|vector target: chroma (embedded)" "lancedb|https://github.com/lancedb/lancedb.git|lancedb||wheel|lancedb|vector target: lancedb (embedded)" "faiss|https://github.com/facebookresearch/faiss.git|faiss-cpu||wheel|faiss|vector target: faiss (file index)" "pymilvus|https://github.com/milvus-io/pymilvus.git|pymilvus|[milvus_lite]|source|pymilvus|vector target: milvus lite" "weaviate-client|https://github.com/weaviate/weaviate-python-client.git|weaviate-client||source|weaviate|vector target: weaviate (service)" "psycopg|https://github.com/psycopg/psycopg.git|psycopg|[binary]|wheel|psycopg|vector target: pgvector - postgres driver" "pgvector|https://github.com/pgvector/pgvector-python.git|pgvector||source|pgvector|vector target: pgvector - python bindings" "duckdb|https://github.com/duckdb/duckdb.git|duckdb||wheel|duckdb|vector target: duckdb (embedded)" "sqlite-vec|https://github.com/asg017/sqlite-vec.git|sqlite-vec||source|sqlite_vec|vector target: sqlite-vec (embedded)" "PyMySQL|https://github.com/PyMySQL/PyMySQL.git|PyMySQL||source|pymysql|vector target: mariadb driver" "graphify|https://github.com/Graphify-Labs/graphify.git|graphifyy|[ollama]|source|graphify|graph engine: graph.json + HTML report" "vanna|https://github.com/vanna-ai/vanna.git|vanna|[ollama,postgres,mysql]|source|vanna|ask: natural-language SQL over corpora" "minio-py|https://github.com/minio/minio-py.git|minio||source|minio|stage: MinIO object archive" "LightRAG|https://github.com/HKUDS/LightRAG.git|lightrag-hku||source|lightrag|graph engine: merged entity graph" "ebooklib|https://github.com/aerkalov/ebooklib.git|EbookLib||source|ebooklib|parser: EPUB" "BeautifulSoup4|https://github.com/wention/BeautifulSoup4.git|beautifulsoup4||source|bs4|parser: HTML inside EPUB" "pypdf|https://github.com/py-pdf/pypdf.git|pypdf||source|pypdf|parser: PDF" "python-slugify|https://github.com/un33k/python-slugify.git|python-slugify||source|slugify|vault note filenames" "fastembed|https://github.com/qdrant/fastembed.git|fastembed||wheel|fastembed|local ONNX embeddings" "PySide6|https://code.qt.io/pyside/pyside-setup.git|PySide6||wheel|PySide6|the console GUI toolkit" ) # ── native build tools via pacman (skipped when present) ───────────── required_tools=(git gcc make) missing=() for tool in "${required_tools[@]}"; do command -v "$tool" >/dev/null 2>&1 || missing+=("$tool") done if [ "${#missing[@]}" -gt 0 ]; then log "installing build tools via pacman: ${missing[*]}" sudo pacman -S --needed "${missing[@]}" fi # ── project selection ───────────────────────────────────────────────── # Interactive in a terminal; --only or non-TTY stdin bypasses the menu. select_packages() { if [ -n "$ONLY" ]; then return fi if [ ! -t 0 ] && [ "${THICKET_BOOTSTRAP_INTERACTIVE:-0}" != "1" ]; then return # everything, no prompt fi local -a names=() roles=() local row for row in "${PACKAGES[@]}"; do names+=("$(cut -d'|' -f1 <<<"$row")") roles+=("$(cut -d'|' -f7 <<<"$row")") done printf 'Thicket projects:\n' local i for i in "${!names[@]}"; do printf ' %2d) %-18s %-34s %s\n' \ "$((i + 1))" "${names[$i]}" \ "$(cut -d'|' -f5 <<<"${PACKAGES[$i]}")" "${roles[$i]}" done local answer token from to picked valid while :; do read -r -p $'Install which? (numbers/names/ranges, "all", Enter = all): ' answer || return [ -z "$answer" ] && return [ "$answer" = "all" ] && return picked="" for token in ${answer//,/ }; do valid="" if [[ "$token" =~ ^[0-9]+$ ]] && [ "$token" -ge 1 ] \ && [ "$token" -le "${#names[@]}" ]; then picked+=",${names[$((token - 1))]}"; valid=1 elif [[ "$token" =~ ^([0-9]+)-([0-9]+)$ ]]; then from="${BASH_REMATCH[1]}"; to="${BASH_REMATCH[2]}" if [ "$from" -ge 1 ] && [ "$to" -le "${#names[@]}" ] \ && [ "$from" -le "$to" ]; then for ((i = from; i <= to; i++)); do picked+=",${names[$((i - 1))]}" done valid=1 fi else for i in "${!names[@]}"; do if [ "${names[$i],,}" = "${token,,}" ]; then picked+=",${names[$i]}"; valid=1; break fi done fi [ -n "$valid" ] || warn "ignored unknown selection: $token" done if [ -n "$picked" ]; then ONLY="${picked#,}" log "selected: ${ONLY//,/ }" return fi done } select_packages # ── the venv ────────────────────────────────────────────────────────── if [ ! -x "$VENV_DIR/bin/python" ]; then log "creating venv at $VENV_DIR" python3 -m venv "$VENV_DIR" fi PIP=("$VENV_DIR/bin/pip") "${PIP[@]}" install -q --upgrade pip setuptools wheel # ── repository sync: absent | corrupt | dirty | unchanged | moved ──── # Prints the resulting state; never destroys uncommitted work. sync_repo() { local name="$1" url="$2" local dir="$GIT_MIRROR/$name" if [ ! -e "$dir" ]; then local stage="$dir.clone.$$" if git clone --quiet --depth 1 "$url" "$stage"; then mv "$stage" "$dir" echo "cloned" else rm -rf "$stage" echo "clone-failed" fi return fi if [ ! -d "$dir/.git" ]; then local aside="$dir.corrupt-$(date +%s)" warn "[$name] not a git checkout — moved aside to $(basename "$aside")" mv "$dir" "$aside" local stage="$dir.clone.$$" if git clone --quiet --depth 1 "$url" "$stage"; then mv "$stage" "$dir" echo "recloned" else rm -rf "$stage" echo "clone-failed" fi return fi local origin_url origin_url="$(git -C "$dir" remote get-url origin 2>/dev/null || true)" if [ "$origin_url" != "$url" ]; then warn "[$name] remote is '$origin_url', table says '$url' — skipping" echo "url-mismatch" return fi if [ -n "$(git -C "$dir" status --porcelain 2>/dev/null)" ]; then warn "[$name] local modifications — not touching them:" git -C "$dir" status --porcelain | sed 's/^/ /' echo "dirty" return fi local branch refspec branch="$(git -C "$dir" rev-parse --abbrev-ref HEAD)" if [ "$branch" = "HEAD" ]; then refspec="HEAD" # detached shallow clone else refspec="$branch" fi if ! git -C "$dir" fetch --quiet --depth 1 origin "$refspec"; then warn "[$name] fetch failed (offline?)" echo "unchanged" return fi local head fetched head="$(git -C "$dir" rev-parse HEAD)" fetched="$(git -C "$dir" rev-parse FETCH_HEAD)" if [ "$head" = "$fetched" ]; then echo "unchanged" return fi git -C "$dir" reset --hard --quiet "$fetched" echo "moved" } module_importable() { "$VENV_DIR/bin/python" -c "import $1" >/dev/null 2>&1 } # ── per-package install with step-down ──────────────────────────────── install_one() { local name="$1" url="$2" pipname="$3" extras="$4" method="$5" module="$6" local dir="$GIT_MIRROR/$name" if [ -n "$ONLY" ]; then case ",$ONLY," in *",$name,"*) ;; *) return 0 ;; esac fi local state state="$(sync_repo "$name" "$url")" case "$state" in clone-failed) warn "[$name] clone failed — skipping" return 0 ;; url-mismatch | dirty) return 0 ;; # never clobber; report above esac if [ "$UPDATE_ONLY" -eq 1 ]; then log "[$name] $state" return 0 fi # Unchanged checkout + already importable: nothing to do. if [ "$state" = "unchanged" ] && module_importable "$module"; then log "[$name] up to date ($(git -C "$dir" rev-parse --short HEAD))" return 0 fi if [ "$method" = "source" ] || [ "$FORCE_SOURCE" -eq 1 ]; then log "[$name] $state — installing from source checkout" # Local-path extras: pip accepts "[extra1,extra2]". if "${PIP[@]}" install -q "${dir}${extras}"; then return 0 fi warn "[$name] source build failed — stepping down to wheel" fi log "[$name] $state — installing ${pipname}${extras} (wheel)" "${PIP[@]}" install -q "${pipname}${extras}" } for row in "${PACKAGES[@]}"; do IFS='|' read -r name url pipname extras method module _ <<<"$row" install_one "$name" "$url" "$pipname" "$extras" "$method" "$module" done # ── thicket itself, editable from this checkout ─────────────────────── log "[thicket] installing project (editable)" "${PIP[@]}" install -q -e "$PROJECT_ROOT" # ── launcher in the AI tree ─────────────────────────────────────────── cat > "$BIN_DIR/thicket" < "$HOME/.local/bin/thicket" <