From 5d30ab4a8ae1ff822eede2b25728393eeba03823 Mon Sep 17 00:00:00 2001 From: Michael Heller <21163552+mdheller@users.noreply.github.com> Date: Mon, 3 Aug 2026 22:01:58 -0400 Subject: [PATCH] feat(search): sovereign SearXNG stack + mesh dashboard Search badge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit sourceos-searxng-setup.sh (new): - Docker path: pulls searxng/searxng:latest → localhost:8888, skips if exists - Homebrew fallback: brew install searxng → localhost:8080 - Probes instance after setup; writes URL to ~/.local/state/sourceos/searxng-url com.sourceos.searxng.plist (new): - launchd agent: docker start sourceos-searxng at login - Activated via turtle-install-launchd --with-searxng turtle-web-search: - _searxng_url(): env var → state file → localhost probe (8888→8080) → searx.be - No public instance required when local SearXNG is running turtle-install-launchd: - --with-searxng flag appends com.sourceos.searxng to install list (off by default) - Updated usage string turtle-mesh-serve: - _searxng_alive(): same 4-level probe, 1s timeout - gather_state() includes searxng_ok bool - Dashboard status bar: Search badge (green=up / red=down) next to Noetica --- assets/sourceos/bin/turtle-install-launchd | 25 +++- assets/sourceos/bin/turtle-mesh-serve | 43 +++++-- assets/sourceos/bin/turtle-web-search | 36 +++++- .../launchd/com.sourceos.searxng.plist | 30 +++++ scripts/sourceos-searxng-setup.sh | 120 ++++++++++++++++++ 5 files changed, 237 insertions(+), 17 deletions(-) create mode 100644 assets/sourceos/launchd/com.sourceos.searxng.plist create mode 100755 scripts/sourceos-searxng-setup.sh diff --git a/assets/sourceos/bin/turtle-install-launchd b/assets/sourceos/bin/turtle-install-launchd index 2bbee4ae043..4242e2a4018 100755 --- a/assets/sourceos/bin/turtle-install-launchd +++ b/assets/sourceos/bin/turtle-install-launchd @@ -5,10 +5,14 @@ # com.sourceos.turtle-mesh-push — GCS mesh sync every 5 minutes # com.sourceos.turtle-mesh-serve — local mesh dashboard on :7788 # +# Optionally installs (requires --with-searxng): +# com.sourceos.searxng — start sovereign SearXNG container at login +# # Usage: -# turtle-install-launchd # install both -# turtle-install-launchd --unload # stop + unload both -# turtle-install-launchd --status # show launchctl status +# turtle-install-launchd # install mesh agents +# turtle-install-launchd --with-searxng # also install SearXNG agent +# turtle-install-launchd --unload # stop + unload all loaded agents +# turtle-install-launchd --status # show launchctl status set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" @@ -18,20 +22,26 @@ LAUNCH_AGENTS="$HOME/Library/LaunchAgents" LABELS=(com.sourceos.turtle-mesh-push com.sourceos.turtle-mesh-serve) usage() { - echo "Usage: turtle-install-launchd [--unload|--status]" + echo "Usage: turtle-install-launchd [--with-searxng] [--unload|--status]" } status_mode=false unload_mode=false +with_searxng=false for arg in "$@"; do case "$arg" in - --status) status_mode=true ;; - --unload) unload_mode=true ;; + --status) status_mode=true ;; + --unload) unload_mode=true ;; + --with-searxng) with_searxng=true ;; -h|--help) usage; exit 0 ;; *) echo "Unknown arg: $arg" >&2; usage >&2; exit 1 ;; esac done +if $with_searxng; then + LABELS+=(com.sourceos.searxng) +fi + if $status_mode; then for label in "${LABELS[@]}"; do echo -n " $label: " @@ -72,3 +82,6 @@ done echo "" echo " Mesh dashboard → http://localhost:7788" echo " GCS sync → every 5 minutes (requires SOURCEOS_GCS_BUCKET env in ~/.zshrc)" +if $with_searxng; then + echo " SearXNG → http://localhost:8888 (container must exist; run sourceos-searxng-setup.sh first)" +fi diff --git a/assets/sourceos/bin/turtle-mesh-serve b/assets/sourceos/bin/turtle-mesh-serve index d2c29d4b01b..10015716d4d 100755 --- a/assets/sourceos/bin/turtle-mesh-serve +++ b/assets/sourceos/bin/turtle-mesh-serve @@ -26,6 +26,7 @@ import queue import sys import threading import time +import urllib.request from http.server import BaseHTTPRequestHandler, HTTPServer from pathlib import Path @@ -60,6 +61,23 @@ def load_jsonl_tail(path: Path, n: int = 50) -> list[dict]: return items[-n:] +def _searxng_alive() -> bool: + state_file = Path(os.getenv("XDG_STATE_HOME", str(Path.home() / ".local/state"))) / "sourceos" / "searxng-url" + candidates = [] + if state_file.exists(): + url = state_file.read_text().strip().rstrip("/") + if url: + candidates.append(url) + candidates += ["http://localhost:8888", "http://localhost:8080"] + for url in candidates: + try: + urllib.request.urlopen(f"{url}/healthz", timeout=1) + return True + except Exception: + pass + return False + + def gather_state() -> dict: mesh = load_jsonl_tail(MESH_DIR / "context.jsonl", 60) active = load_json(MESH_DIR / "active.json") @@ -67,6 +85,7 @@ def gather_state() -> dict: pr = load_json(STATUS_DIR / "pr.json") noetica= load_json(STATUS_DIR / "noetica.json") board = load_json(STATUS_DIR / "board.json") + searxng_ok = _searxng_alive() bb_cands: list[dict] = [] cand_path = BB_SUPPORT / "memory" / "candidates.jsonl" @@ -84,15 +103,16 @@ def gather_state() -> dict: notes.append({"name": p.name, "mtime": p.stat().st_mtime}) return { - "ts": datetime.datetime.now().isoformat(), - "active": active, - "mesh": mesh, - "ci": ci, - "pr": pr, - "noetica": noetica, - "board": board, - "bb_cands": bb_cands, - "notes": notes, + "ts": datetime.datetime.now().isoformat(), + "active": active, + "mesh": mesh, + "ci": ci, + "pr": pr, + "noetica": noetica, + "board": board, + "bb_cands": bb_cands, + "notes": notes, + "searxng_ok": searxng_ok, } @@ -227,6 +247,7 @@ h2{font-size:12px;color:var(--dim);text-transform:uppercase;letter-spacing:.08em
Noetica … + Search … CI — PRs — Board — @@ -273,6 +294,10 @@ function render(state) { ? `Noetica up` : `Noetica down` + document.getElementById('searxng-badge').innerHTML = state.searxng_ok + ? `Search up` + : `Search down` + const ci = state.ci || {} const ciClass = ci.conclusion === 'success' ? 'badge-green' : ci.conclusion === 'failure' ? 'badge-red' : 'badge-yellow' document.getElementById('ci-badge').innerHTML = ci.status diff --git a/assets/sourceos/bin/turtle-web-search b/assets/sourceos/bin/turtle-web-search index 2175def5edd..6a80a708aef 100755 --- a/assets/sourceos/bin/turtle-web-search +++ b/assets/sourceos/bin/turtle-web-search @@ -15,14 +15,46 @@ import sys import urllib.error import urllib.parse import urllib.request +from pathlib import Path -_DEFAULT_SEARXNG = "https://searx.be" _USER_AGENT = "TurtleTerm/1.0 (sovereign; +https://sourceos.io)" +def _searxng_url() -> str: + """Resolve the SearXNG base URL with a four-level priority chain: + + 1. SEARXNG_URL environment variable (explicit override) + 2. Persisted URL written by sourceos-searxng-setup.sh + 3. Auto-probe localhost:8888 (Docker) then localhost:8080 (Homebrew) + 4. Public fallback (https://searx.be) + """ + # 1. Explicit env var wins + if url := os.environ.get("SEARXNG_URL", ""): + return url.rstrip("/") + # 2. Persisted local instance URL + state_file = ( + Path(os.getenv("XDG_STATE_HOME", str(Path.home() / ".local/state"))) + / "sourceos" + / "searxng-url" + ) + if state_file.exists(): + url = state_file.read_text().strip() + if url: + return url.rstrip("/") + # 3. Try localhost:8888 (Docker), then 8080 (Homebrew) + for local_url in ("http://localhost:8888", "http://localhost:8080"): + try: + urllib.request.urlopen(f"{local_url}/healthz", timeout=1) + return local_url + except Exception: # noqa: BLE001 + pass + # 4. Public fallback + return "https://searx.be" + + def search(query: str, count: int = 5) -> list[dict]: """Query SearXNG and return a ranked list of results.""" - base = os.environ.get("SEARXNG_URL", _DEFAULT_SEARXNG).rstrip("/") + base = _searxng_url() params = urllib.parse.urlencode({ "q": query, "format": "json", diff --git a/assets/sourceos/launchd/com.sourceos.searxng.plist b/assets/sourceos/launchd/com.sourceos.searxng.plist new file mode 100644 index 00000000000..a39f55315e5 --- /dev/null +++ b/assets/sourceos/launchd/com.sourceos.searxng.plist @@ -0,0 +1,30 @@ + + + + + + Label + com.sourceos.searxng + + ProgramArguments + + /usr/local/bin/docker + start + sourceos-searxng + + + RunAtLoad + + + StandardOutPath + /tmp/sourceos-searxng.log + + StandardErrorPath + /tmp/sourceos-searxng.err + + diff --git a/scripts/sourceos-searxng-setup.sh b/scripts/sourceos-searxng-setup.sh new file mode 100755 index 00000000000..cc7f448598d --- /dev/null +++ b/scripts/sourceos-searxng-setup.sh @@ -0,0 +1,120 @@ +#!/usr/bin/env bash +# MIT License — https://sourceos.io +# sourceos-searxng-setup.sh — provision a sovereign SearXNG instance for TurtleTerm. +# +# Option A (preferred): Docker on 127.0.0.1:8888 +# Option B (fallback): Homebrew searxng on 127.0.0.1:8080 +# +# Usage: +# sourceos-searxng-setup.sh +set -euo pipefail + +STATE_DIR="${XDG_STATE_HOME:-$HOME/.local/state}/sourceos" +STATE_FILE="$STATE_DIR/searxng-url" +CONTAINER_NAME="sourceos-searxng" +DOCKER_PORT="8888" +BREW_PORT="8080" + +# ── helpers ──────────────────────────────────────────────────────────────────── + +log() { echo " [searxng] $*"; } +warn() { echo " [searxng] WARN: $*" >&2; } + +persist_url() { + mkdir -p "$STATE_DIR" + printf '%s' "$1" > "$STATE_FILE" + log "URL persisted → $STATE_FILE" +} + +test_instance() { + local url="$1" + local test_url="${url}/search?q=test&format=json" + log "Testing $url ..." + result=$(curl -sf --max-time 10 "$test_url" 2>/dev/null || true) + if [[ -z "$result" ]]; then + warn "No response from $url — instance may need a moment to start." + return 1 + fi + count=$(python3 -c "import json,sys; d=json.loads(sys.stdin.read()); print(len(d.get('results',[])))" <<< "$result" 2>/dev/null || echo "?") + log "SearXNG OK: ${count} results" +} + +# ── Option A — Docker ────────────────────────────────────────────────────────── + +setup_docker() { + log "Docker available — using container path (port $DOCKER_PORT)." + + if docker inspect "$CONTAINER_NAME" &>/dev/null; then + log "Container '$CONTAINER_NAME' already exists — starting if not running." + docker start "$CONTAINER_NAME" &>/dev/null || true + else + log "Creating container '$CONTAINER_NAME' ..." + docker run -d \ + --name "$CONTAINER_NAME" \ + --restart unless-stopped \ + -p "127.0.0.1:${DOCKER_PORT}:8080" \ + -e SEARXNG_BASE_URL="http://localhost:${DOCKER_PORT}" \ + -e SEARXNG_LIMITER=false \ + -e SEARXNG_SECRET_KEY="$(python3 -c 'import secrets; print(secrets.token_hex(32))')" \ + searxng/searxng:latest + log "Container started." + fi + + # Brief wait for the HTTP stack to bind + sleep 3 + + local url="http://localhost:${DOCKER_PORT}" + test_instance "$url" || warn "Run 'docker logs $CONTAINER_NAME' if the instance stays unreachable." + persist_url "$url" + + echo "" + echo " export SEARXNG_URL=http://localhost:${DOCKER_PORT}" + echo "" + log "Done (Docker). Add the export above to ~/.zshrc if desired." +} + +# ── Option B — Homebrew ──────────────────────────────────────────────────────── + +setup_brew() { + log "Docker not available — falling back to Homebrew." + + brew install searxng 2>/dev/null || true + + SEARXNG_CFG="${XDG_CONFIG_HOME:-$HOME/.config}/searxng/settings.yml" + if [[ ! -f "$SEARXNG_CFG" ]]; then + mkdir -p "$(dirname "$SEARXNG_CFG")" + cat > "$SEARXNG_CFG" </dev/null 2>&1; then + setup_docker +else + setup_brew +fi