Ports the ncp PATH work from upstream. Registering ncp as a shell-profile function failed three ways on Windows: the default Restricted execution policy blocks the profile itself, profiles don't exist outside PowerShell (cmd, Win+R, Task Scheduler), and the self-elevating installer writes the admin's profile. Windows now ships management/ncp.cmd and the installer appends management\ to the Machine PATH via [Environment]::SetEnvironmentVariable -- never setx, which truncates PATH at 1024 chars. A .cmd is exempt from the execution policy. Linux symlinks /usr/local/bin/ncp -> management/nexus-cli.sh, falling back to the old .bashrc function when sudo is unavailable. Also renames requirements-wsl.txt to requirements-windows.txt and purges stale WSL references, including vite.config.js's dev-server comment and controlpanel.py's "Check WSLg." error string. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
349 lines
14 KiB
Python
349 lines
14 KiB
Python
#!/usr/bin/env python3
|
|
"""NexusOS backup and restore - one entry point for Linux and native Windows.
|
|
|
|
Same command on both boxes. Everything portable lives here: the git sync with
|
|
Gitea, the memory-DB dump / compare / rebuild, and the venv + web-UI rebuild.
|
|
The parts that only mean something on Linux - apt packages, the bundled Ollama
|
|
binary, the XFCE desktop wiring, the desktop snapshot - stay in bash and get
|
|
called from here, skipped outright on Windows.
|
|
|
|
python bin/sync.py restore [--check]
|
|
python bin/sync.py backup [--check] [--full] [--force-db]
|
|
python bin/sync.py compare # print the direction verdict only
|
|
|
|
Standard library only, so it runs before the Promethean venv exists and needs no
|
|
sqlite3 binary on PATH - Windows has none, which is why the old
|
|
bin/db-compare.sh could never work there.
|
|
|
|
A fresh machine clones first (git clone <repo> nexus-core), then runs this.
|
|
"""
|
|
import argparse
|
|
import os
|
|
import shutil
|
|
import sqlite3
|
|
import subprocess
|
|
import sys
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
|
|
ROOT = Path(__file__).resolve().parent.parent
|
|
DB = ROOT / "synapse" / "memory" / "memory.db"
|
|
DB_SQL = ROOT / "synapse" / "memory" / "memory.db.sql"
|
|
DB_SQL_REL = "synapse/memory/memory.db.sql" # git paths are always posix-style
|
|
|
|
|
|
def run(*args, capture=False, check=False):
|
|
"""Run a command from the repo root. Returns CompletedProcess."""
|
|
exe = shutil.which(args[0]) # resolves npm -> npm.cmd on Windows
|
|
if exe is None:
|
|
raise SystemExit(f"'{args[0]}' not found on PATH")
|
|
return subprocess.run(
|
|
[exe, *args[1:]], cwd=ROOT, check=check,
|
|
capture_output=capture, text=True, encoding="utf-8",
|
|
)
|
|
|
|
|
|
def linux_stage(script: str, *args) -> None:
|
|
"""Run one of the Linux-only bash stages. A no-op on Windows, where apt,
|
|
xfconf, plank and the rest have nothing to act on."""
|
|
if os.name == "nt":
|
|
return
|
|
path = ROOT / "bin" / script
|
|
bash = shutil.which("bash")
|
|
if path.exists() and bash:
|
|
# Pin the stage to THIS repo. It defaults to ~/nexus-core otherwise, so a
|
|
# clone in a scratch dir would restore over the real machine instead.
|
|
subprocess.run([bash, str(path), *args], cwd=ROOT, check=False,
|
|
env={**os.environ, "NEXUS_ROOT": str(ROOT)})
|
|
|
|
|
|
def venv_python() -> Path:
|
|
"""Path to the Promethean interpreter, creating the venv if it's missing."""
|
|
venv = ROOT / "Promethean"
|
|
py = venv / ("Scripts/python.exe" if os.name == "nt" else "bin/python")
|
|
if not py.exists():
|
|
subprocess.run([sys.executable, "-m", "venv", str(venv)], check=True)
|
|
return py
|
|
|
|
|
|
def requirements() -> str:
|
|
"""Pick the PyTorch overlay for this host."""
|
|
if os.name == "nt":
|
|
return "requirements-windows.txt" # CPU / pure-Python, right for native Windows
|
|
if shutil.which("nvidia-smi"):
|
|
return "requirements-nvidia.txt"
|
|
lspci = shutil.which("lspci")
|
|
if lspci and "nvidia" in subprocess.run(
|
|
[lspci], capture_output=True, text=True).stdout.lower():
|
|
return "requirements-nvidia.txt"
|
|
return "requirements-amd.txt"
|
|
|
|
|
|
# -- memory DB -----------------------------------------------------------------
|
|
|
|
def dump_db() -> bool:
|
|
"""Dump the (gitignored, binary, WAL) memory DB to a diff-friendly SQL file
|
|
so git backs up the assistant's memory + conversation history. sqlite3 reads
|
|
through the WAL, so the dump has the latest committed data uncheckpointed."""
|
|
if not DB.exists():
|
|
return False
|
|
try:
|
|
with sqlite3.connect(f"file:{DB}?mode=ro", uri=True) as conn:
|
|
DB_SQL.write_text("\n".join(conn.iterdump()) + "\n", encoding="utf-8")
|
|
except sqlite3.Error as exc:
|
|
print(f"Warning: could not dump {DB} ({exc}) - DB not captured")
|
|
return False
|
|
print(f"Dumped memory DB -> {DB_SQL}")
|
|
return True
|
|
|
|
|
|
def _state(conn):
|
|
"""What a machine holds: conversations as {id: updated_at} and memory facts
|
|
as a set of ids. NOT messages.id - it's INTEGER AUTOINCREMENT, so both
|
|
machines hand out the same ids independently and comparing them is
|
|
meaningless. A new message bumps its conversation's updated_at, which is
|
|
what actually gets caught here."""
|
|
def query(sql):
|
|
try:
|
|
return conn.execute(sql).fetchall()
|
|
except sqlite3.Error:
|
|
return [] # table absent in an old dump - treat as empty
|
|
return (
|
|
dict(query("select id, updated_at from conversations")),
|
|
{row[0] for row in query("select id from memory")},
|
|
)
|
|
|
|
|
|
def _extra(a, b) -> int:
|
|
"""How much `a` holds that `b` lacks: conversations `b` is missing or has an
|
|
older copy of, plus memory facts `b` doesn't have at all. An out-of-date
|
|
copy is NOT extra content on b's side - that asymmetry is what separates
|
|
'one box is simply ahead' from a real divergence."""
|
|
a_conv, a_mem = a
|
|
b_conv, b_mem = b
|
|
newer = sum(1 for cid, ts in a_conv.items() if b_conv.get(cid, "") < ts)
|
|
return newer + len(a_mem - b_mem)
|
|
|
|
|
|
def compare(db: Path = DB, dump: Path = DB_SQL) -> str:
|
|
"""Which way the sync should go. One of: same, local-ahead, local-behind,
|
|
diverged, no-live, no-dump.
|
|
ponytail: detects direction, does not merge. Diverged is reported, not resolved."""
|
|
if not dump.exists() or not dump.stat().st_size:
|
|
return "no-dump"
|
|
if not db.exists():
|
|
return "no-live"
|
|
try:
|
|
with sqlite3.connect(f"file:{db}?mode=ro", uri=True) as live_conn:
|
|
live = _state(live_conn)
|
|
with sqlite3.connect(":memory:") as dump_conn:
|
|
dump_conn.executescript(dump.read_text(encoding="utf-8"))
|
|
backup = _state(dump_conn)
|
|
except (sqlite3.Error, OSError):
|
|
return "diverged" # can't tell: fail safe, refuse both directions
|
|
ahead, behind = _extra(live, backup), _extra(backup, live)
|
|
if ahead and behind:
|
|
return "diverged"
|
|
if ahead:
|
|
return "local-ahead"
|
|
if behind:
|
|
return "local-behind"
|
|
return "same"
|
|
|
|
|
|
def restore_db() -> None:
|
|
"""Rebuild the memory DB from the pulled dump, keeping a rollback copy."""
|
|
print()
|
|
state = compare()
|
|
if state == "same":
|
|
print("Memory DB already matches the backup.")
|
|
return
|
|
if state == "local-ahead":
|
|
print("WARNING: this machine has conversations the backup doesn't.")
|
|
print(" Not applying the dump - run `backup` HERE first.")
|
|
print(f" To discard local history instead: delete {DB} and restore")
|
|
return
|
|
if state == "diverged":
|
|
print("WARNING: this machine and the backup each have conversations the other lacks.")
|
|
print(" Not applying the dump - nothing is merging these automatically.")
|
|
print(f" Keep local: run `backup` here. Keep remote: delete {DB} and restore")
|
|
return
|
|
if state == "no-dump":
|
|
print("No memory dump in the backup - skipping DB restore.")
|
|
return
|
|
|
|
print("Restoring memory DB from backup...")
|
|
rollback = DB.with_suffix(".db.pre-restore")
|
|
if DB.exists():
|
|
shutil.copyfile(DB, rollback)
|
|
for suffix in ("", "-wal", "-shm"):
|
|
Path(str(DB) + suffix).unlink(missing_ok=True)
|
|
try:
|
|
with sqlite3.connect(DB) as conn:
|
|
conn.executescript(DB_SQL.read_text(encoding="utf-8"))
|
|
print("Memory DB restored (conversations + history + facts).")
|
|
except sqlite3.Error as exc:
|
|
print(f"Warning: memory DB rebuild failed ({exc}).")
|
|
if rollback.exists():
|
|
shutil.move(rollback, DB)
|
|
print("Rolled back to previous DB.")
|
|
|
|
|
|
# -- rebuild -------------------------------------------------------------------
|
|
|
|
def rebuild_env() -> None:
|
|
print("\nRebuilding Python environment...")
|
|
py, req = venv_python(), requirements()
|
|
if (ROOT / req).exists():
|
|
subprocess.run([str(py), "-m", "pip", "install", "--upgrade", "pip", "-q"], check=False)
|
|
subprocess.run([str(py), "-m", "pip", "install", "-r", req], cwd=ROOT, check=False)
|
|
else:
|
|
print(f"Warning: {req} not found - skipping pip install.")
|
|
|
|
print("\nRebuilding frontend dependencies...")
|
|
web = ROOT / "interface" / "web"
|
|
npm = shutil.which("npm")
|
|
if npm is None:
|
|
print("Warning: npm not found - the web UI will not be built.")
|
|
return
|
|
subprocess.run([npm, "install"], cwd=web, check=False)
|
|
# Build the UI so the backend can serve it single-process (it mounts
|
|
# interface/web/dist at :8000). Without this the app has no UI to show.
|
|
print("Building frontend...")
|
|
subprocess.run([npm, "run", "build"], cwd=web, check=False)
|
|
if not (web / "dist" / "index.html").exists():
|
|
print("Warning: interface/web/dist/index.html missing - the app will serve no UI.")
|
|
|
|
|
|
# -- commands ------------------------------------------------------------------
|
|
|
|
def cmd_restore(args) -> int:
|
|
if args.check:
|
|
print("Fetching to preview restore (no changes)...")
|
|
run("git", "fetch", "origin")
|
|
print("\nCommits a restore would apply:")
|
|
print(run("git", "log", "--oneline", "..origin/main", capture=True).stdout or "(up to date)")
|
|
print(run("git", "diff", "--stat", "..origin/main", capture=True).stdout)
|
|
print(f"Memory DB vs backup: {compare()}")
|
|
print("\n(dry-run only - nothing changed. Apply with: restore)")
|
|
return 0
|
|
|
|
# System packages first - the venv and npm build below need them present.
|
|
linux_stage("restore-linux.sh", "prep")
|
|
|
|
print("\nPulling latest from Gitea...")
|
|
if run("git", "pull", "--ff-only", "origin", "main").returncode:
|
|
print("Pull failed (diverged? stash/commit local changes).")
|
|
return 1
|
|
restore_db()
|
|
rebuild_env()
|
|
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 <model>`.")
|
|
return 0
|
|
|
|
|
|
def _remote_dump(tmp: Path) -> bool:
|
|
"""Write origin/main's dump to tmp. False if it can't be fetched."""
|
|
if run("git", "fetch", "-q", "origin").returncode:
|
|
print("Note: could not reach Gitea - skipping backup safety check.")
|
|
return False
|
|
shown = run("git", "show", f"origin/main:{DB_SQL_REL}", capture=True)
|
|
if shown.returncode:
|
|
return False
|
|
tmp.write_text(shown.stdout, encoding="utf-8")
|
|
return True
|
|
|
|
|
|
def check_db_direction(tmp_dir: Path) -> bool:
|
|
"""Mirror of restore's guard: refuse to dump a stale live DB over a backup
|
|
that already holds newer conversations from the other machine. Compares
|
|
against the REMOTE dump, not the working-tree copy - that copy is from this
|
|
box's last backup and is exactly what goes stale when the other box pushes."""
|
|
if not DB.exists():
|
|
return True
|
|
tmp = tmp_dir / "remote.sql"
|
|
if not _remote_dump(tmp):
|
|
return True
|
|
state = compare(DB, tmp)
|
|
if state == "local-behind":
|
|
print("REFUSING TO BACK UP: the backup has conversations this machine doesn't.")
|
|
print(" Backing up now would overwrite them with this box's older history.")
|
|
print(" Run `restore` here first, then back up.")
|
|
return False
|
|
if state == "diverged":
|
|
print("REFUSING TO BACK UP: this machine and the backup each have conversations")
|
|
print(" the other lacks. Nothing merges these automatically.")
|
|
print(" Force this box's history to win: python bin/sync.py backup --force-db")
|
|
return False
|
|
return True
|
|
|
|
|
|
def cmd_backup(args) -> int:
|
|
import tempfile
|
|
if args.full:
|
|
linux_stage("backup-linux.sh")
|
|
dump_db()
|
|
with tempfile.TemporaryDirectory() as tmp_dir:
|
|
safe = check_db_direction(Path(tmp_dir))
|
|
|
|
if args.check:
|
|
print(f"\nMemory DB vs backup: {'safe to back up' if safe else 'STALE - restore first'}")
|
|
print("\nFiles a backup would commit:")
|
|
print(run("git", "status", "--short", capture=True).stdout)
|
|
print("Local commits not yet pushed:")
|
|
print(run("git", "log", "--oneline", "@{u}..", capture=True).stdout or "(none / no upstream)")
|
|
print("(dry-run only - nothing committed or pushed. Apply with: backup)")
|
|
return 0
|
|
if not safe and not args.force_db:
|
|
return 1
|
|
|
|
run("git", "add", "-A")
|
|
if run("git", "diff", "--cached", "--quiet").returncode:
|
|
stamp = datetime.now(timezone.utc).astimezone().isoformat(timespec="seconds")
|
|
if run("git", "commit", "-q", "-m", f"backup: {stamp}").returncode:
|
|
print("Commit failed - see error above (e.g. git identity not set).")
|
|
return 1
|
|
print("Committed backup snapshot.")
|
|
else:
|
|
print("No changes to commit.")
|
|
|
|
print("Pushing to Gitea...")
|
|
if run("git", "push", "origin", "main").returncode:
|
|
print("Push failed. Set up credentials once with:")
|
|
print(" git config credential.helper store # then push once and enter your Gitea token")
|
|
return 1
|
|
print("Backup complete.")
|
|
return 0
|
|
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
sub = parser.add_subparsers(dest="cmd", required=True)
|
|
|
|
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")
|
|
backup.add_argument("-c", "--check", action="store_true", help="dry run, change nothing")
|
|
backup.add_argument("-f", "--full", action="store_true",
|
|
help="also snapshot the live desktop wiring + Claude notes (Linux)")
|
|
backup.add_argument("--force-db", action="store_true", help="skip the staleness guard")
|
|
backup.set_defaults(func=cmd_backup)
|
|
|
|
compare_cmd = sub.add_parser("compare", help="print the sync direction verdict")
|
|
compare_cmd.set_defaults(func=lambda a: (print(compare()), 0)[1])
|
|
|
|
args = parser.parse_args()
|
|
return args.func(args)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|