From ee6e5bead78e8968fb8c3af542681d197860d97f Mon Sep 17 00:00:00 2001 From: jon Date: Wed, 22 Jul 2026 08:46:46 -0500 Subject: [PATCH] Replace stale bin/install.sh with a wrapper over sync.py restore bin/install.sh still rsynced --delete from a backup path retired in July, so the documented Linux install both failed and could erase a working tree. Every step it claimed to do already lives in bin/sync.py, shared with Windows. Root ./install.sh is now a thin wrapper over `sync.py restore`, so deployment stays one bash command. It also registers ncp/promethean in ~/.bashrc, which was the only thing the old script uniquely did. Split restore-linux.sh into runtime (Ollama binary, shell aliases) and desktop (XFCE panel, theme, os-release). Only desktop writes outside the repo, it now auto-skips off XFCE, and `--no-desktop` skips it explicitly. Two tests keep the stage names in sync and the $HOME writes confined to the desktop stage. Co-Authored-By: Claude Opus 4.8 --- README.md | 12 +++- bin/check.sh | 2 +- bin/install.sh | 168 ------------------------------------------- bin/restore-linux.sh | 85 ++++++++++++++++------ bin/sync.py | 7 +- install.sh | 26 +++++++ tests/test_smoke.py | 26 +++++++ 7 files changed, 133 insertions(+), 193 deletions(-) delete mode 100644 bin/install.sh create mode 100755 install.sh diff --git a/README.md b/README.md index 6bfc4f9..4598ea6 100644 --- a/README.md +++ b/README.md @@ -72,8 +72,9 @@ does all inference over HTTP). ### Linux ```bash -# 1. Install deps into the Promethean venv (auto-selects AMD/NVIDIA/CPU) -./bin/install.sh +# 1. Build everything: venv (auto-selects AMD/NVIDIA/CPU), web UI, memory DB, +# system packages and the Ollama binary. +./install.sh # 2. Launch (memory :8001, backend :8000 — backend also serves the built UI) ./launch_nexus.sh @@ -82,7 +83,12 @@ does all inference over HTTP). Python deps are layered: `requirements-base.txt` (GPU-agnostic core) plus one GPU overlay — `requirements-amd.txt` (ROCm) or `requirements-nvidia.txt` (CUDA). `requirements-wsl.txt` is the standalone CPU-only runtime (no base overlay). -`bin/install.sh` picks the right one for the host. +`bin/sync.py` picks the right one for the host. + +`./install.sh` is also the update path — re-run it any time to pull the latest +and rebuild. `--check` dry-runs it; `--no-desktop` skips the XFCE panel/theme +wiring (that stage is auto-skipped off XFCE anyway). It's a thin wrapper over +`bin/sync.py restore`, the same code the Windows box runs. ### Individual services diff --git a/bin/check.sh b/bin/check.sh index dc4ea91..177d8ab 100644 --- a/bin/check.sh +++ b/bin/check.sh @@ -10,7 +10,7 @@ cd "$(dirname "$0")/.." fail=0 if [ ! -x Promethean/bin/python ]; then - echo "!! no Promethean venv - run bin/install.sh first" >&2 + echo "!! no Promethean venv - run ./install.sh first" >&2 exit 1 fi diff --git a/bin/install.sh b/bin/install.sh deleted file mode 100644 index 4473e8b..0000000 --- a/bin/install.sh +++ /dev/null @@ -1,168 +0,0 @@ -#!/bin/bash -# NexusOS installer — Linux side. -# Syncs the repo, builds the Python venv, installs frontend deps, registers ncp, -# and installs the Promethean Terminal + panel. -# -# ./install.sh Linux install (default) -# ./install.sh -w | --windows Hand off to the Windows installer (install-windows.ps1) - -NEXUS_ROOT="$HOME/nexus-core" -ROUTER_BACKUP="router:/tmp/mnt/Wingdrive2/nexus-core/" - -# ─── Helpers ────────────────────────────────────────────────────────────────── - -usage() { - cat </dev/null && nvidia-smi &>/dev/null 2>&1; then - echo "requirements-nvidia.txt" - elif lspci 2>/dev/null | grep -qi nvidia; then - echo "requirements-nvidia.txt" - elif grep -qi microsoft /proc/version 2>/dev/null; then - echo "requirements-wsl.txt" - elif lspci 2>/dev/null | grep -qi amd; then - echo "requirements-amd.txt" - else - echo "requirements-wsl.txt" - fi -} - -run_windows() { - local ps1="$NEXUS_ROOT/install-windows.ps1" - if [ ! -f "$ps1" ]; then - echo "Error: $ps1 not found." >&2 - exit 1 - fi - - # install-windows.ps1 is a native-Windows installer: it self-elevates to - # Administrator and uses winget (Python/Node/Ollama) directly. No WSL. - # It must run from Windows PowerShell, so just point the way. - cat <&2 - usage - exit 1 - ;; -esac - -# ─── Step 1: Sync from router ───────────────────────────────────────────────── - -echo "Pulling Nexus from router..." -mkdir -p "$NEXUS_ROOT" -rsync -avz --delete \ - --exclude='.git/' \ - --exclude='Promethean/' \ - --exclude='models/blobs/' \ - --exclude='ollama/' \ - --exclude='interface/web/node_modules/' \ - --exclude='interface/web/dist/' \ - --exclude='runtime/' \ - --exclude='__pycache__/' \ - --exclude='*.pyc' \ - -e ssh \ - "$ROUTER_BACKUP" "$NEXUS_ROOT/" - -# ─── Step 2: Python venv ────────────────────────────────────────────────────── - -echo "" -echo "Creating Python environment..." -python3 -m venv "$NEXUS_ROOT/Promethean" - -# ─── Step 3: pip install ────────────────────────────────────────────────────── - -echo "" -echo "Installing Python dependencies..." -req=$(detect_requirements) -echo "Detected: $req" -if [ -f "$NEXUS_ROOT/$req" ]; then - "$NEXUS_ROOT/Promethean/bin/pip" install --upgrade pip -q - "$NEXUS_ROOT/Promethean/bin/pip" install -r "$NEXUS_ROOT/$req" -else - echo "Warning: $req not found — skipping pip install." -fi - -# ─── Step 4: npm install ────────────────────────────────────────────────────── - -echo "" -echo "Installing frontend dependencies..." -export NVM_DIR="$HOME/.nvm" -[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh" -if command -v npm &>/dev/null; then - cd "$NEXUS_ROOT/interface/web" && npm install -else - echo "npm not found — install nvm/node then run 'cd $NEXUS_ROOT/interface/web && npm install'." -fi - -# ─── Step 5: Register ncp in ~/.bashrc ─────────────────────────────────────── - -echo "" -echo "Registering ncp..." -if ! grep -q "nexus-core/management/nexus-cli.sh" "$HOME/.bashrc"; then - cat >> "$HOME/.bashrc" << 'EOF' - -# Nexus -ncp() { - ~/nexus-core/management/nexus-cli.sh "$@" -} -EOF - echo "ncp registered in ~/.bashrc." -else - echo "ncp already in ~/.bashrc — skipping." -fi - -if ! grep -qF "alias promethean='source ~/nexus-core/.promethean_bashrc'" "$HOME/.bashrc"; then - printf '\n# Promethean\nalias promethean='"'"'source ~/nexus-core/.promethean_bashrc'"'"'\n' >> "$HOME/.bashrc" - echo "promethean registered in ~/.bashrc." -else - echo "promethean already in ~/.bashrc — skipping." -fi - -# ─── Step 6: Promethean Terminal + panel ───────────────────────────────────── - -echo "" -echo "Installing Promethean Terminal..." -bash "$NEXUS_ROOT/bin/promethean/install.sh" || \ - echo "Warning: Promethean Terminal install failed — run bin/promethean/install.sh manually." - -echo "" -echo "Installing NexusOS panel applet..." -bash "$NEXUS_ROOT/bin/panel/install.sh" || \ - echo "Warning: panel install failed — run bin/panel/install.sh manually." - -# ─── Done ───────────────────────────────────────────────────────────────────── - -echo "" -echo "Installation complete. Nexus is ready." -echo "Run 'ncp start' to launch Nexus." diff --git a/bin/restore-linux.sh b/bin/restore-linux.sh index 0a71899..d56fec7 100644 --- a/bin/restore-linux.sh +++ b/bin/restore-linux.sh @@ -3,17 +3,29 @@ # The Linux-only half of a restore: system packages, the bundled Ollama binary, # and the XFCE desktop wiring (panel, wallpaper, theme, terminal, branding). # None of it means anything on Windows, which is why it lives here instead of in -# bin/sync.py — sync.py owns the portable half and calls this in two stages: +# bin/sync.py — sync.py owns the portable half and calls this in three stages: # # restore-linux.sh prep Before the rebuild: system packages the build needs. -# restore-linux.sh desktop After the rebuild: Ollama binary + desktop wiring. +# restore-linux.sh runtime After: Ollama binary + shell wiring. Any Linux box +# needs these to actually run NexusOS. +# restore-linux.sh desktop After: the XFCE look (panel, wallpaper, theme, +# branding). Cosmetic, this box's setup, and the only +# stage that rewrites files outside the repo and $HOME. +# Skippable with `--no-desktop`; auto-skipped off XFCE. # # Not meant to be run by hand — use `ncp restore` (python bin/sync.py restore). # Set by bin/sync.py to the repo it was invoked from, so a clone in a scratch dir # operates on itself instead of reaching into the real ~/nexus-core. NEXUS_ROOT="${NEXUS_ROOT:-$HOME/nexus-core}" -stage="${1:?usage: restore-linux.sh prep|desktop}" +stage="${1:?usage: restore-linux.sh prep|runtime|desktop}" +# sync.py calls these by name with check=False, so a stage renamed on one side +# and not the other would silently do nothing. Fail loudly instead; a test keeps +# this list and sync.py's call sites in agreement. +case "$stage" in + prep|runtime|desktop) ;; + *) echo "unknown stage: $stage (expected prep|runtime|desktop)" >&2; exit 2 ;; +esac cd "$NEXUS_ROOT" || { echo "No $NEXUS_ROOT"; exit 1; } if [ "$stage" = "prep" ]; then @@ -29,14 +41,55 @@ if [ "$stage" = "prep" ]; then exit 0 fi -echo "" -echo "Ensuring Ollama binary..." -# ollama/ is gitignored, so a fresh clone has no binary — fetch it. No-op if the -# machine already has one (e.g. an in-place restore). -if [ -x "$NEXUS_ROOT/bin/fetch-ollama.sh" ]; then - "$NEXUS_ROOT/bin/fetch-ollama.sh" "$NEXUS_ROOT" || echo "Warning: Ollama fetch failed — install it manually." -else - echo "Warning: bin/fetch-ollama.sh not found — skipping Ollama fetch." +if [ "$stage" = "runtime" ]; then + echo "" + echo "Ensuring Ollama binary..." + # ollama/ is gitignored, so a fresh clone has no binary — fetch it. No-op if the + # machine already has one (e.g. an in-place restore). + if [ -x "$NEXUS_ROOT/bin/fetch-ollama.sh" ]; then + "$NEXUS_ROOT/bin/fetch-ollama.sh" "$NEXUS_ROOT" || echo "Warning: Ollama fetch failed — install it manually." + else + echo "Warning: bin/fetch-ollama.sh not found — skipping Ollama fetch." + fi + + # Shell wiring: the `ncp` function and the Promethean venv alias. This is the + # only place they get registered now that bin/install.sh is gone. Guarded by a + # grep so an in-place restore is a no-op and a scratch clone can't re-point an + # already-wired ~/.bashrc at itself. + if ! grep -q "management/nexus-cli.sh" "$HOME/.bashrc" 2>/dev/null; then + cat >> "$HOME/.bashrc" </dev/null; then + printf "\n# Promethean\nalias promethean='source %s/.promethean_bashrc'\n" \ + "$NEXUS_ROOT" >> "$HOME/.bashrc" + echo "Registered promethean in ~/.bashrc." + fi + + # The AMD box runs CPU-only (Vega 20, 4GB VRAM thrashes). An NVIDIA box should not. + if command -v nvidia-smi >/dev/null && command -v sqlite3 >/dev/null && \ + [ "$(sqlite3 "$NEXUS_ROOT/synapse/memory/memory.db" \ + "select value from settings where key='memory_gpu_offload'" 2>/dev/null)" = "0" ]; then + echo "Note: memory_gpu_offload=0 came from the AMD box (4GB VRAM). This machine has an" + echo " NVIDIA GPU — raise it in Settings to actually use the card." + fi + exit 0 +fi + +# --- desktop stage: XFCE only, and it writes outside the repo ----------------- +# Everything below reconfigures the logged-in user's desktop. On any other DE it +# would be actively wrong (copying XFCE channel XMLs onto a GNOME box does +# nothing good), so bail rather than make a mess of someone else's machine. +if ! command -v xfconf-query >/dev/null; then + echo "Not an XFCE session (no xfconf-query) — skipping desktop wiring." + exit 0 fi # Panel layout, wallpaper, compositing, keybindings and terminal profile all live @@ -45,7 +98,7 @@ fi # it clobbers what we just copied; the next xfconf-query respawns it. xml_snap="$NEXUS_ROOT/assets/themes/restore-snapshot/xfconf-xml" xml_dst="$HOME/.config/xfce4/xfconf/xfce-perchannel-xml" -if [ -d "$xml_snap" ] && command -v xfconf-query >/dev/null; then +if [ -d "$xml_snap" ]; then echo "" echo "Restoring XFCE desktop settings (panel, wallpaper, effects)..." mkdir -p "$xml_dst" @@ -110,11 +163,3 @@ if grep -q '^ID=linuxmint' /etc/os-release 2>/dev/null && ! grep -q 'NexusOS' /e sudo sed -i -e 's/^NAME=.*/NAME="NexusOS"/' -e 's/^PRETTY_NAME=.*/PRETTY_NAME="NexusOS 1.0"/' \ /etc/os-release || echo "Warning: os-release branding skipped." fi - -# The AMD box runs CPU-only (Vega 20, 4GB VRAM thrashes). An NVIDIA box should not. -if command -v nvidia-smi >/dev/null && \ - [ "$(sqlite3 "$NEXUS_ROOT/synapse/memory/memory.db" \ - "select value from settings where key='memory_gpu_offload'" 2>/dev/null)" = "0" ]; then - echo "Note: memory_gpu_offload=0 came from the AMD box (4GB VRAM). This machine has an" - echo " NVIDIA GPU — raise it in Settings to actually use the card." -fi diff --git a/bin/sync.py b/bin/sync.py index d458bc9..3e8260e 100644 --- a/bin/sync.py +++ b/bin/sync.py @@ -237,7 +237,9 @@ def cmd_restore(args) -> int: return 1 restore_db() rebuild_env() - linux_stage("restore-linux.sh", "desktop") + linux_stage("restore-linux.sh", "runtime") + if not args.no_desktop: + linux_stage("restore-linux.sh", "desktop") print("\nRestore complete. Nexus is ready to start.") print("Note: Ollama models are not in the backup - pull them with `ollama pull `.") return 0 @@ -323,6 +325,9 @@ def main() -> int: restore = sub.add_parser("restore", help="pull from Gitea, rebuild DB + venv + web UI") restore.add_argument("-c", "--check", action="store_true", help="dry run, change nothing") + restore.add_argument("--no-desktop", action="store_true", + help="skip the XFCE desktop wiring (panel, theme, os-release). " + "Use for a test clone - that stage writes to $HOME, not the repo.") restore.set_defaults(func=cmd_restore) backup = sub.add_parser("backup", help="dump DB, commit and push to Gitea") diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..2b66550 --- /dev/null +++ b/install.sh @@ -0,0 +1,26 @@ +#!/bin/bash +# NexusOS installer, Linux. One painless command: +# +# git clone nexus-core && cd nexus-core && ./install.sh +# +# ponytail: a wrapper, not an installer. Every step - git pull, venv, pip with +# the right GPU overlay, npm build, apt packages, Ollama binary, desktop wiring +# - lives in bin/sync.py, shared with native Windows. Duplicating any of it here +# means two installers drifting apart, which is exactly how the old +# bin/install.sh ended up rsyncing from a backup path retired months earlier. +# Re-run it any time to update; --check dry-runs it. Windows: install-windows.ps1. +set -euo pipefail +cd "$(dirname "$0")" + +if ! command -v python3 >/dev/null; then + echo "python3 is required (it runs the installer). Install it, then re-run:" >&2 + echo " sudo apt-get install -y python3 python3-venv" >&2 + exit 1 +fi + +# Prefer the venv interpreter once it exists, same as ncp does; python3 is the +# bootstrap case on a fresh clone. sync.py is stdlib-only either way. +py=Promethean/bin/python +[ -x "$py" ] || py=python3 + +exec "$py" bin/sync.py restore "$@" diff --git a/tests/test_smoke.py b/tests/test_smoke.py index 6462467..78e7551 100644 --- a/tests/test_smoke.py +++ b/tests/test_smoke.py @@ -209,3 +209,29 @@ def test_sync_compare_detects_direction(tmp_path): db.unlink() assert sync.compare(db, dump) == "no-live" + + +def test_restore_stages_match_the_script(): + """sync.py invokes restore-linux.sh stages by name with check=False, so a + rename on one side alone fails silently - and the runtime stage is what + installs Ollama and the ncp alias. Keep the two in agreement.""" + import re + called = set(re.findall( + r'linux_stage\(\s*"restore-linux\.sh"\s*,\s*"(\w+)"', + (REPO_ROOT / "bin" / "sync.py").read_text())) + script = (REPO_ROOT / "bin" / "restore-linux.sh").read_text() + handled = set(re.search(r"case \"\$stage\" in\s*\n\s*([\w|]+)\)", script).group(1).split("|")) + assert called, "no restore-linux.sh stages found in sync.py" + assert called <= handled, f"sync.py calls unhandled stage(s): {called - handled}" + + +def test_desktop_stage_is_the_only_one_touching_home(): + """--no-desktop is only a real safety valve if the $HOME writes all live in + the desktop stage. A test clone runs prep + runtime unconditionally.""" + script = (REPO_ROOT / "bin" / "restore-linux.sh").read_text() + runtime = script.split('if [ "$stage" = "runtime" ]; then')[1].split("\n exit 0\nfi")[0] + # The ncp/promethean shell aliases are the one deliberate exception - they're + # how you launch NexusOS at all, and both are grep-guarded no-ops on re-run. + home_writes = [ln for ln in runtime.splitlines() + if "$HOME" in ln and ".bashrc" not in ln] + assert not home_writes, f"runtime stage writes to $HOME: {home_writes}"