diff --git a/pyproject.toml b/pyproject.toml index 9747817..35830e1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,10 +15,14 @@ dependencies = [ ] [project.optional-dependencies] +watch = [ + "watchdog>=3.0", +] dev = [ "pytest>=7.0.0", "pytest-asyncio>=0.21.0", "mypy>=1.0.0", + "watchdog>=3.0", ] [project.scripts] diff --git a/src/radicle_reticulum/cli.py b/src/radicle_reticulum/cli.py index 3652ac1..9e819c4 100644 --- a/src/radicle_reticulum/cli.py +++ b/src/radicle_reticulum/cli.py @@ -568,6 +568,111 @@ def cmd_bridge(args): print("Bridge stopped.") +def cmd_setup(args): + """Check prerequisites and print setup instructions.""" + seed_home = Path(args.seed_home) + seed_port = args.seed_port + + ok = True + + def check(label: str, passed: bool, fix: Optional[str] = None) -> bool: + icon = "✓" if passed else "✗" + print(f" {icon} {label}") + if not passed and fix: + print(f" → {fix}") + return passed + + print("Checking prerequisites...") + + # rad CLI + try: + r = subprocess.run(["rad", "--version"], capture_output=True, text=True, timeout=5) + ver = r.stdout.strip().split("\n")[0] if r.returncode == 0 else None + ok &= check(f"rad CLI{f' ({ver})' if ver else ''}", r.returncode == 0, "Install Radicle: https://radicle.xyz") + except FileNotFoundError: + ok &= check("rad CLI", False, "Install Radicle: https://radicle.xyz") + + # radicle-node + try: + r = subprocess.run(["radicle-node", "--version"], capture_output=True, text=True, timeout=5) + ok &= check("radicle-node", r.returncode == 0, "Install Radicle: https://radicle.xyz") + except FileNotFoundError: + ok &= check("radicle-node", False, "Install Radicle: https://radicle.xyz") + + # RNS Python library + try: + import RNS as _rns # noqa: F401 + ok &= check("Reticulum (RNS)", True) + except ImportError: + ok &= check("Reticulum (RNS)", False, "pip install rns") + + # watchdog (optional) + try: + import watchdog # noqa: F401 + check("watchdog (instant push detection)", True) + except ImportError: + check( + "watchdog (optional — enables instant push detection)", + False, + "pip install watchdog # or: uv add watchdog", + ) + + print() + print("Seed identity...") + + seed_node = SeedNode(seed_home=seed_home, port=seed_port) + seed_initialized = seed_node.is_initialized() + ok &= check( + f"Seed identity at {seed_home}", + seed_initialized, + f"RAD_HOME={seed_home} rad auth", + ) + + seed_nid: Optional[str] = None + if seed_initialized: + seed_nid = seed_node.get_nid() + check( + f"Seed NID: {seed_nid[:48] if seed_nid else '(could not read)'}", + bool(seed_nid), + ) + + print() + print("User radicle-node configuration...") + + user_nid = detect_radicle_nid() + ok &= check( + f"Your radicle identity{f' ({user_nid[:32]}...)' if user_nid else ''}", + bool(user_nid), + "rad auth # initialise your radicle identity first", + ) + + # Check whether the seed is registered in the user's radicle-node. + # We do this by calling 'rad node' and looking for the seed NID. + seed_registered = False + if seed_nid: + try: + r = subprocess.run( + ["rad", "node"], capture_output=True, text=True, timeout=5 + ) + seed_registered = seed_nid in r.stdout + except Exception: + pass + ok &= check( + "Seed registered in your radicle node", + seed_registered, + f"rad node connect {seed_nid}@127.0.0.1:{seed_port}", + ) + + print() + if ok: + print("All checks passed. Start the bridge with:") + print(f" radicle-rns seed") + else: + print("Setup incomplete. Follow the instructions above, then run:") + print(f" radicle-rns setup # re-check") + print(f" radicle-rns seed # once all checks pass") + + def main(): """Main entry point.""" parser = argparse.ArgumentParser( @@ -709,6 +814,24 @@ def main(): ) add_identity_arg(seed_parser) + setup_parser = subparsers.add_parser( + "setup", + help="Check prerequisites and print setup instructions", + ) + setup_parser.add_argument( + "--seed-home", + default=str(DEFAULT_SEED_HOME), + metavar="PATH", + help=f"RAD_HOME for the seed node (default: {DEFAULT_SEED_HOME})", + ) + setup_parser.add_argument( + "--seed-port", + type=int, + default=DEFAULT_SEED_PORT, + metavar="PORT", + help=f"Seed TCP port (default: {DEFAULT_SEED_PORT})", + ) + bridge_parser = subparsers.add_parser("bridge", help="Run Radicle-Reticulum bridge") bridge_parser.add_argument( "-l", "--listen-port", @@ -777,6 +900,8 @@ def main(): cmd_gossip(args) elif args.command == "seed": cmd_seed(args) + elif args.command == "setup": + cmd_setup(args) elif args.command == "bridge": cmd_bridge(args) else: diff --git a/src/radicle_reticulum/gossip.py b/src/radicle_reticulum/gossip.py index 1e60364..b028f38 100644 --- a/src/radicle_reticulum/gossip.py +++ b/src/radicle_reticulum/gossip.py @@ -137,6 +137,8 @@ class GossipRelay: self._known_refs: Dict[str, Dict[str, str]] = {} # rid -> refs self._refs_lock = threading.Lock() self._running = False + self._poll_event = threading.Event() # set by watchdog or stop() + self._observer = None # watchdog Observer, if available self._on_sync_triggered: Optional[Callable[[str, str], None]] = None @@ -148,18 +150,52 @@ class GossipRelay: RNS.Transport.register_announce_handler(self._on_announce) self.announce() threading.Thread(target=self._startup_announce_loop, daemon=True).start() + self._start_watcher() threading.Thread(target=self._poll_loop, daemon=True).start() RNS.log(f"Gossip relay started: {self.destination.hexhash}", RNS.LOG_INFO) RNS.log( - f" Watching {len(self.rids)} repo(s), polling every {self.poll_interval}s", + f" Watching {len(self.rids)} repo(s), " + f"{'inotify+' if self._observer else ''}poll every {self.poll_interval}s", RNS.LOG_INFO, ) def stop(self): """Stop the relay.""" self._running = False + self._poll_event.set() # wake poll loop so it exits promptly + if self._observer: + try: + self._observer.stop() + self._observer.join(timeout=3) + except Exception: + pass RNS.log("Gossip relay stopped", RNS.LOG_INFO) + def _start_watcher(self): + """Set up a watchdog filesystem observer if the library is available.""" + try: + from watchdog.observers import Observer + from watchdog.events import FileSystemEventHandler + + relay = self + + class _Handler(FileSystemEventHandler): + def on_any_event(self, event): + if not event.is_directory: + relay._poll_event.set() + + self.storage.mkdir(parents=True, exist_ok=True) + self._observer = Observer() + self._observer.schedule(_Handler(), str(self.storage), recursive=True) + self._observer.start() + RNS.log(f"Watchdog active on {self.storage}", RNS.LOG_INFO) + except ImportError: + RNS.log( + "watchdog not installed — install it for instant push detection " + "(pip install watchdog). Falling back to polling.", + RNS.LOG_INFO, + ) + # ── Public API ─────────────────────────────────────────────────────────── def announce(self): @@ -220,30 +256,31 @@ class GossipRelay: except Exception as e: RNS.log(f"Error scanning storage: {e}", RNS.LOG_DEBUG) + def _poll_loop_once(self): + """Check all watched repos for ref changes and broadcast any diffs.""" + if self.auto_discover: + self._discover_rids() + + for rid in self.rids: + try: + refs = _read_refs(self.storage, rid) + with self._refs_lock: + old = self._known_refs.get(rid) + changed = bool(refs and refs != old) + first_poll = old is None + if changed: + self._known_refs[rid] = refs + if changed and not first_poll: + self._broadcast(rid, refs) + except Exception as e: + RNS.log(f"Gossip poll error ({rid[:20]}): {e}", RNS.LOG_WARNING) + def _poll_loop(self): while self._running: - if self.auto_discover: - self._discover_rids() - - for rid in self.rids: - try: - refs = _read_refs(self.storage, rid) - with self._refs_lock: - old = self._known_refs.get(rid) - changed = bool(refs and refs != old) - first_poll = old is None - if changed: - self._known_refs[rid] = refs - if changed and not first_poll: - self._broadcast(rid, refs) - except Exception as e: - RNS.log(f"Gossip poll error ({rid[:20]}): {e}", RNS.LOG_WARNING) - - # Interruptible sleep - for _ in range(self.poll_interval): - if not self._running: - return - time.sleep(1) + self._poll_loop_once() + # Wait for next poll: woken early by watchdog event or stop() + self._poll_event.wait(timeout=self.poll_interval) + self._poll_event.clear() # ── Internal: sending ──────────────────────────────────────────────────── diff --git a/tests/test_gossip.py b/tests/test_gossip.py index fd67933..2f84830 100644 --- a/tests/test_gossip.py +++ b/tests/test_gossip.py @@ -376,3 +376,59 @@ class TestPushRefsNow: relay.push_refs_now("rad:z3abc123") assert relay._known_refs["rad:z3abc123"] == SAMPLE_REFS + + +# ── Watchdog / poll event ────────────────────────────────────────────────────── + +class TestWatchdog: + def test_poll_event_wakes_poll_loop_early(self, tmp_path): + relay = _make_relay(tmp_path) + relay._known_refs["rad:z3abc123"] = {"refs/heads/main": "old"} + new_refs = {"refs/heads/main": "new"} + broadcasts = [] + + with patch("radicle_reticulum.gossip._read_refs", return_value=new_refs), \ + patch.object(relay, "_broadcast", side_effect=lambda r, refs: broadcasts.append(r)): + # Signal the event (simulates watchdog firing) + relay._poll_event.set() + # Run one poll iteration + relay._poll_loop_once() + + assert broadcasts == ["rad:z3abc123"] + + def test_stop_sets_poll_event(self, tmp_path): + relay = _make_relay(tmp_path) + relay._running = True + with patch("radicle_reticulum.gossip.RNS.log"): + relay.stop() + assert relay._poll_event.is_set() + + def test_start_watcher_graceful_without_watchdog(self, tmp_path): + relay = _make_relay(tmp_path) + with patch.dict("sys.modules", {"watchdog": None, "watchdog.observers": None, + "watchdog.events": None}), \ + patch("radicle_reticulum.gossip.RNS.log"): + relay._start_watcher() + assert relay._observer is None + + def test_auto_discover_adds_new_repos(self, tmp_path): + relay = _make_relay(tmp_path, rids=[]) + storage = tmp_path / "storage" + storage.mkdir() + (storage / "z3newrepo").mkdir() + + relay.storage = storage + relay._discover_rids() + + assert "rad:z3newrepo" in relay.rids + + def test_auto_discover_does_not_add_duplicates(self, tmp_path): + relay = _make_relay(tmp_path, rids=["rad:z3existing"]) + storage = tmp_path / "storage" + storage.mkdir() + (storage / "z3existing").mkdir() + + relay.storage = storage + relay._discover_rids() + + assert relay.rids.count("rad:z3existing") == 1