refactor: remove seed mode, simplify setup and README

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 <noreply@anthropic.com>
This commit is contained in:
Maciek "mab122" Bator 2026-04-24 11:06:42 +02:00
parent 6a11905500
commit d670b096f2
4 changed files with 36 additions and 423 deletions

View File

@ -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
```
---

View File

@ -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",
]

View File

@ -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":

View File

@ -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 <seed-NID>@127.0.0.1:<seed-port>
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