From d670b096f28608f08290c50a2eeb745602d5f707 Mon Sep 17 00:00:00 2001 From: "Maciek \"mab122\" Bator" Date: Fri, 24 Apr 2026 11:06:42 +0200 Subject: [PATCH] refactor: remove seed mode, simplify setup and README MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Delete seed.py and cmd_seed — managing a separate radicle-node process is radicle configuration, not this project's job. Simplify cmd_setup to check only bridge prerequisites (rad, radicle-node, RNS, identity, localhost listen). Update README prerequisites to cover all four user types (from scratch, Radicle only, Reticulum only, both) with inline install hints for each missing piece. Co-Authored-By: Claude Sonnet 4.6 --- README.md | 17 +- src/radicle_reticulum/__init__.py | 2 - src/radicle_reticulum/cli.py | 266 +++--------------------------- src/radicle_reticulum/seed.py | 174 ------------------- 4 files changed, 36 insertions(+), 423 deletions(-) delete mode 100644 src/radicle_reticulum/seed.py diff --git a/README.md b/README.md index 9f6e7f9..949bb2f 100644 --- a/README.md +++ b/README.md @@ -8,8 +8,11 @@ Bridges [Radicle](https://radicle.xyz) (decentralized Git) over [Reticulum](http ## Prerequisites -- [Radicle](https://radicle.xyz/install) — `rad` CLI + `radicle-node` -- [uv](https://docs.astral.sh/uv/getting-started/installation/) +Install anything you don't already have: + +- **Radicle** — `rad` CLI + `radicle-node`: [radicle.xyz/install](https://radicle.xyz/install) +- **Reticulum** — `rns` Python package: `pip install rns` or [reticulum.network](https://reticulum.network/manual/gettingstartedfast.html) +- **Python 3.10+** --- @@ -18,13 +21,19 @@ Bridges [Radicle](https://radicle.xyz) (decentralized Git) over [Reticulum](http ```sh git clone rad:z4NMdcKbw2TETQ56fbQfbibFHtZqZ cd radicle-reticulum +pip install . +``` + +With [uv](https://docs.astral.sh/uv/getting-started/installation/) instead of pip: + +```sh uv sync ``` -Optional — faster push detection (inotify, Linux/macOS): +Optional — faster push detection on Linux/macOS (inotify): ```sh -uv sync --extra watch +pip install ".[watch]" # or: uv sync --extra watch ``` --- diff --git a/src/radicle_reticulum/__init__.py b/src/radicle_reticulum/__init__.py index 3b33c1c..2d7d493 100644 --- a/src/radicle_reticulum/__init__.py +++ b/src/radicle_reticulum/__init__.py @@ -3,12 +3,10 @@ from radicle_reticulum.identity import RadicleIdentity from radicle_reticulum.bridge import RadicleBridge from radicle_reticulum.gossip import GossipRelay -from radicle_reticulum.seed import SeedNode __version__ = "0.1.0" __all__ = [ "RadicleIdentity", "RadicleBridge", "GossipRelay", - "SeedNode", ] diff --git a/src/radicle_reticulum/cli.py b/src/radicle_reticulum/cli.py index d544a10..e40edda 100644 --- a/src/radicle_reticulum/cli.py +++ b/src/radicle_reticulum/cli.py @@ -16,7 +16,6 @@ import RNS from radicle_reticulum.identity import RadicleIdentity from radicle_reticulum.bridge import RadicleBridge from radicle_reticulum.gossip import GossipRelay -from radicle_reticulum.seed import SeedNode, DEFAULT_SEED_HOME, DEFAULT_SEED_PORT def detect_radicle_nid() -> Optional[str]: @@ -190,128 +189,6 @@ def cmd_gossip(args): print("Gossip relay stopped.") -def cmd_seed(args): - """Start a dedicated seed radicle-node, bridge, and gossip relay.""" - seed_home = Path(args.seed_home) - - # Validate args before starting any processes - announce_retry_delays = _parse_delays(args.announce_retry_delays) - - seed = SeedNode(seed_home=seed_home, port=args.seed_port) - - # First-time setup: guide the user - if not seed.is_initialized(): - seed.write_config() - print(f"Seed home: {seed_home}") - print() - print("Seed identity not found. Initialize it with:") - print(f" RAD_HOME={seed_home} rad auth") - print() - print("Then run 'radicle-rns seed' again.") - sys.exit(1) - - # Start seed radicle-node - print(f"Starting seed radicle-node (port {args.seed_port})...") - try: - seed.start() - except RuntimeError as e: - print(f"Error: {e}", file=sys.stderr) - sys.exit(1) - - nid = seed.get_nid() - if not nid: - print("Error: could not get seed NID.", file=sys.stderr) - seed.stop() - sys.exit(1) - - identity = RadicleIdentity.load_or_generate(args.identity) - _print_identity_info(args.identity) - - bridge = RadicleBridge( - identity=identity, - listen_port=args.bridge_port, - radicle_host="127.0.0.1", - radicle_port=args.seed_port, - auto_connect=True, - auto_seed=True, - announce_retry_delays=announce_retry_delays, - rad_home=str(seed_home), - state_path=seed_home / "bridge_state.json", - ) - bridge.set_local_radicle_nid(nid) - - def on_peer_seed_discovered(dest_hash, remote_nid=None): - nid_info = f" (NID: {remote_nid[:32]})" if remote_nid else "" - print(f"[+] Discovered remote seed: {dest_hash.hex()[:16]}{nid_info}") - - bridge.set_on_bridge_discovered(on_peer_seed_discovered) - - # Gossip relay: watches seed's storage, notifies remote seeds of ref changes. - # bridge_port=None: bridge's auto_seed already registered NIDs on correct ports. - gossip = GossipRelay( - identity=identity, - rids=[], - storage=seed_home / "storage", - radicle_nid=nid, - bridge_port=None, - poll_interval=args.poll_interval, - announce_retry_delays=announce_retry_delays, - auto_discover=True, - auto_seed=True, - rad_home=str(seed_home), - ) - - running = True - - def signal_handler(sig, frame): - nonlocal running - print("\nShutting down...") - running = False - - signal.signal(signal.SIGINT, signal_handler) - signal.signal(signal.SIGTERM, signal_handler) - - try: - bridge.start() - gossip.start() - - print() - print("Seed node running.") - print(f" Seed NID: {nid}") - print(f" Seed port: {args.seed_port}") - print(f" Bridge hash: {bridge.destination.hexhash}") - print(f" Gossip hash: {gossip.destination.hexhash}") - print() - print("Add this seed to your radicle node (one-time setup):") - print(f" rad node connect {nid}@127.0.0.1:{args.seed_port}") - print() - print("Other machines running 'radicle-rns seed' will discover this") - print("seed automatically and sync over the mesh.") - print() - print("Press Ctrl+C to stop.") - print() - - last_known_bridges = -1 - while running: - if not seed.is_running(): - print("Seed radicle-node exited unexpectedly.", file=sys.stderr) - break - - stats = bridge.get_stats() - if stats["known_bridges"] != last_known_bridges: - last_known_bridges = stats["known_bridges"] - print(f"[Status] Remote seeds: {stats['known_bridges']}, " - f"Tunnels: {stats['active_tunnels']}, " - f"Gossip peers: {gossip.get_stats()['known_peers']}") - - time.sleep(5) - finally: - gossip.stop() - bridge.stop() - seed.stop() - print("Seed stopped.") - - def cmd_bridge(args): """Run Radicle-Reticulum bridge.""" print("Starting Radicle-Reticulum bridge...") @@ -436,9 +313,6 @@ def cmd_bridge(args): 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: @@ -450,7 +324,6 @@ def cmd_setup(args): 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 @@ -458,21 +331,18 @@ def cmd_setup(args): 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") + ok &= check("Reticulum (RNS)", False, "pip install rns (or install Reticulum: https://reticulum.network)") - # watchdog (optional) try: import watchdog # noqa: F401 check("watchdog (instant push detection)", True) @@ -480,75 +350,40 @@ def cmd_setup(args): check( "watchdog (optional — enables instant push detection)", False, - "pip install watchdog # or: uv add watchdog", + "uv sync --extra watch", ) 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...") + print("Radicle identity...") user_nid = detect_radicle_nid() ok &= check( - f"Your radicle identity{f' ({user_nid[:32]}...)' if user_nid else ''}", + f"radicle identity{f' ({user_nid[:32]}...)' if user_nid else ''}", bool(user_nid), - "rad auth # initialise your radicle identity first", + "rad auth", ) - # 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() - print("Seed process...") - - seed_listening = seed_node._port_open() - check( - f"Seed radicle-node listening on port {seed_port}", - seed_listening, - f"radicle-rns seed --seed-home {seed_home} --seed-port {seed_port}", + nid_in_config = False + try: + import json as _json + cfg = Path.home() / ".radicle" / "config.json" + if cfg.exists(): + data = _json.loads(cfg.read_text()) + listen = data.get("node", {}).get("listen", []) + nid_in_config = any("127.0.0.1" in addr for addr in listen) + except Exception: + pass + ok &= check( + "radicle-node listens on 127.0.0.1", + nid_in_config, + 'set "node": {"listen": ["127.0.0.1:8776"]} in ~/.radicle/config.json', ) print() - if ok and seed_listening: - print("All checks passed. Seed is running.") - elif ok: - print("All checks passed. Start the seed with:") - print(f" radicle-rns seed") + if ok: + print("All checks passed. Run: radicle-rns bridge") 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") + print("Setup incomplete. Follow the instructions above, then re-run: radicle-rns setup") def main(): @@ -625,63 +460,10 @@ def main(): ) add_identity_arg(gossip_parser) - # seed command - seed_parser = subparsers.add_parser( - "seed", - help="Start a Radicle seed node and bridge it to the mesh over Reticulum", - ) - seed_parser.add_argument( - "--seed-home", - default=str(DEFAULT_SEED_HOME), - metavar="PATH", - help=f"RAD_HOME for the seed node (default: {DEFAULT_SEED_HOME})", - ) - seed_parser.add_argument( - "--seed-port", - type=int, - default=DEFAULT_SEED_PORT, - metavar="PORT", - help=f"TCP port for the seed radicle-node (default: {DEFAULT_SEED_PORT})", - ) - seed_parser.add_argument( - "--bridge-port", - type=int, - default=8778, - metavar="PORT", - help="TCP listen port for the seed bridge (default: 8778)", - ) - seed_parser.add_argument( - "--poll-interval", - type=int, - default=30, - metavar="SECONDS", - help="Seconds between gossip ref polls (default: 30)", - ) - seed_parser.add_argument( - "--announce-retry-delays", - default="5,15,30", - metavar="SECONDS", - help="Startup re-announce delays in seconds, comma-separated (default: 5,15,30).", - ) - add_identity_arg(seed_parser) - - setup_parser = subparsers.add_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( @@ -740,8 +522,6 @@ def main(): cmd_identity(args) elif args.command == "gossip": cmd_gossip(args) - elif args.command == "seed": - cmd_seed(args) elif args.command == "setup": cmd_setup(args) elif args.command == "bridge": diff --git a/src/radicle_reticulum/seed.py b/src/radicle_reticulum/seed.py deleted file mode 100644 index bb53c58..0000000 --- a/src/radicle_reticulum/seed.py +++ /dev/null @@ -1,174 +0,0 @@ -"""Radicle seed node manager. - -Starts and manages a dedicated radicle-node configured as a seed server -(storage.policy = allow). The seed runs under its own RAD_HOME so it -doesn't interfere with the user's own radicle identity. - -Users add the seed as a peer once: - rad node connect @127.0.0.1: - -The bridge then connects this seed to remote seeds over Reticulum. -""" - -import json -import os -import socket -import subprocess -import time -from pathlib import Path -from typing import Optional - - -DEFAULT_SEED_HOME = Path.home() / ".radicle-rns" / "seed" -DEFAULT_SEED_PORT = 8779 - - -SEED_CONFIG = { - "node": { - "alias": "radicle-rns-seed", - "listen": [], # filled in by write_config() - "connect": [], - "externalAddresses": [], - "seedingPolicy": { - "default": "allow", - "repos": {} - }, - "db": {"journalMode": "wal"}, - } -} - - -class SeedNode: - """Manages a dedicated radicle-node process configured as a seed.""" - - def __init__( - self, - seed_home: Path = DEFAULT_SEED_HOME, - port: int = DEFAULT_SEED_PORT, - ): - self.seed_home = Path(seed_home) - self.port = port - self._process: Optional[subprocess.Popen] = None - - # ── Setup ──────────────────────────────────────────────────────────────── - - def is_initialized(self) -> bool: - """Return True if rad auth has been run for this seed home.""" - try: - result = subprocess.run( - ["rad", "self"], - env=self._env(), - capture_output=True, - text=True, - timeout=5, - ) - return result.returncode == 0 and "NID" in result.stdout - except Exception: - return False - - def write_config(self): - """Write config.json only if it does not already exist.""" - self.seed_home.mkdir(parents=True, exist_ok=True) - config_path = self.seed_home / "config.json" - if config_path.exists(): - return - config = json.loads(json.dumps(SEED_CONFIG)) # deep copy - config["node"]["listen"] = [f"127.0.0.1:{self.port}"] - config_path.write_text(json.dumps(config, indent=2)) - - # ── Lifecycle ───────────────────────────────────────────────────────────── - - def start(self): - """Start radicle-node for the seed. Raises if not initialized.""" - if not self.is_initialized(): - raise RuntimeError( - f"Seed identity not found. Initialize it with:\n" - f" RAD_HOME={self.seed_home} rad auth\n" - f"Then run 'radicle-rns seed' again." - ) - - self.write_config() - - # Discard stdout/stderr — if kept as PIPE the buffer fills and the - # process deadlocks once radicle-node produces more than ~64 KB of logs. - self._process = subprocess.Popen( - ["radicle-node"], - env=self._env(), - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - ) - - # Wait up to 10s for the port to open. - deadline = time.time() + 10 - while time.time() < deadline: - if self._process.poll() is not None: - raise RuntimeError( - f"Seed radicle-node exited immediately after launch. " - f"Check: RAD_HOME={self.seed_home} radicle-node" - ) - if self._port_open(): - return - time.sleep(0.3) - - if self._process.poll() is not None: - raise RuntimeError("Seed radicle-node exited before port opened.") - - # Still running but port not open — may be slow on first run; continue. - print(f"Warning: seed port {self.port} not yet open after 10s, continuing anyway.") - - def stop(self): - """Stop the seed radicle-node.""" - if self._process and self._process.poll() is None: - self._process.terminate() - try: - self._process.wait(timeout=5) - except subprocess.TimeoutExpired: - self._process.kill() - self._process.wait() # reap zombie - - def is_running(self) -> bool: - return self._process is not None and self._process.poll() is None - - # ── Queries ─────────────────────────────────────────────────────────────── - - def get_nid(self) -> Optional[str]: - """Return the seed's radicle NID (z6Mk...).""" - result = subprocess.run( - ["rad", "self"], - env=self._env(), - capture_output=True, - text=True, - timeout=5, - ) - if result.returncode != 0: - return None - for line in result.stdout.splitlines(): - parts = line.split() - if len(parts) >= 2 and parts[0] == "NID": - return parts[1] - return None - - def connect_peer(self, nid: str, addr: str) -> bool: - """Tell the seed to connect to a remote peer.""" - result = subprocess.run( - ["rad", "node", "connect", f"{nid}@{addr}"], - env=self._env(), - capture_output=True, - text=True, - timeout=30, - ) - return result.returncode == 0 - - # ── Internal ────────────────────────────────────────────────────────────── - - def _env(self) -> dict: - env = os.environ.copy() - env["RAD_HOME"] = str(self.seed_home) - return env - - def _port_open(self) -> bool: - try: - with socket.create_connection(("127.0.0.1", self.port), timeout=0.5): - return True - except OSError: - return False