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:
parent
6a11905500
commit
d670b096f2
17
README.md
17
README.md
|
|
@ -8,8 +8,11 @@ Bridges [Radicle](https://radicle.xyz) (decentralized Git) over [Reticulum](http
|
||||||
|
|
||||||
## Prerequisites
|
## Prerequisites
|
||||||
|
|
||||||
- [Radicle](https://radicle.xyz/install) — `rad` CLI + `radicle-node`
|
Install anything you don't already have:
|
||||||
- [uv](https://docs.astral.sh/uv/getting-started/installation/)
|
|
||||||
|
- **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
|
```sh
|
||||||
git clone rad:z4NMdcKbw2TETQ56fbQfbibFHtZqZ
|
git clone rad:z4NMdcKbw2TETQ56fbQfbibFHtZqZ
|
||||||
cd radicle-reticulum
|
cd radicle-reticulum
|
||||||
|
pip install .
|
||||||
|
```
|
||||||
|
|
||||||
|
With [uv](https://docs.astral.sh/uv/getting-started/installation/) instead of pip:
|
||||||
|
|
||||||
|
```sh
|
||||||
uv sync
|
uv sync
|
||||||
```
|
```
|
||||||
|
|
||||||
Optional — faster push detection (inotify, Linux/macOS):
|
Optional — faster push detection on Linux/macOS (inotify):
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
uv sync --extra watch
|
pip install ".[watch]" # or: uv sync --extra watch
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
|
||||||
|
|
@ -3,12 +3,10 @@
|
||||||
from radicle_reticulum.identity import RadicleIdentity
|
from radicle_reticulum.identity import RadicleIdentity
|
||||||
from radicle_reticulum.bridge import RadicleBridge
|
from radicle_reticulum.bridge import RadicleBridge
|
||||||
from radicle_reticulum.gossip import GossipRelay
|
from radicle_reticulum.gossip import GossipRelay
|
||||||
from radicle_reticulum.seed import SeedNode
|
|
||||||
|
|
||||||
__version__ = "0.1.0"
|
__version__ = "0.1.0"
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"RadicleIdentity",
|
"RadicleIdentity",
|
||||||
"RadicleBridge",
|
"RadicleBridge",
|
||||||
"GossipRelay",
|
"GossipRelay",
|
||||||
"SeedNode",
|
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,6 @@ import RNS
|
||||||
from radicle_reticulum.identity import RadicleIdentity
|
from radicle_reticulum.identity import RadicleIdentity
|
||||||
from radicle_reticulum.bridge import RadicleBridge
|
from radicle_reticulum.bridge import RadicleBridge
|
||||||
from radicle_reticulum.gossip import GossipRelay
|
from radicle_reticulum.gossip import GossipRelay
|
||||||
from radicle_reticulum.seed import SeedNode, DEFAULT_SEED_HOME, DEFAULT_SEED_PORT
|
|
||||||
|
|
||||||
|
|
||||||
def detect_radicle_nid() -> Optional[str]:
|
def detect_radicle_nid() -> Optional[str]:
|
||||||
|
|
@ -190,128 +189,6 @@ def cmd_gossip(args):
|
||||||
print("Gossip relay stopped.")
|
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):
|
def cmd_bridge(args):
|
||||||
"""Run Radicle-Reticulum bridge."""
|
"""Run Radicle-Reticulum bridge."""
|
||||||
print("Starting Radicle-Reticulum bridge...")
|
print("Starting Radicle-Reticulum bridge...")
|
||||||
|
|
@ -436,9 +313,6 @@ def cmd_bridge(args):
|
||||||
|
|
||||||
def cmd_setup(args):
|
def cmd_setup(args):
|
||||||
"""Check prerequisites and print setup instructions."""
|
"""Check prerequisites and print setup instructions."""
|
||||||
seed_home = Path(args.seed_home)
|
|
||||||
seed_port = args.seed_port
|
|
||||||
|
|
||||||
ok = True
|
ok = True
|
||||||
|
|
||||||
def check(label: str, passed: bool, fix: Optional[str] = None) -> bool:
|
def check(label: str, passed: bool, fix: Optional[str] = None) -> bool:
|
||||||
|
|
@ -450,7 +324,6 @@ def cmd_setup(args):
|
||||||
|
|
||||||
print("Checking prerequisites...")
|
print("Checking prerequisites...")
|
||||||
|
|
||||||
# rad CLI
|
|
||||||
try:
|
try:
|
||||||
r = subprocess.run(["rad", "--version"], capture_output=True, text=True, timeout=5)
|
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
|
ver = r.stdout.strip().split("\n")[0] if r.returncode == 0 else None
|
||||||
|
|
@ -458,21 +331,18 @@ def cmd_setup(args):
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
ok &= check("rad CLI", False, "Install Radicle: https://radicle.xyz")
|
ok &= check("rad CLI", False, "Install Radicle: https://radicle.xyz")
|
||||||
|
|
||||||
# radicle-node
|
|
||||||
try:
|
try:
|
||||||
r = subprocess.run(["radicle-node", "--version"], capture_output=True, text=True, timeout=5)
|
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")
|
ok &= check("radicle-node", r.returncode == 0, "Install Radicle: https://radicle.xyz")
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
ok &= check("radicle-node", False, "Install Radicle: https://radicle.xyz")
|
ok &= check("radicle-node", False, "Install Radicle: https://radicle.xyz")
|
||||||
|
|
||||||
# RNS Python library
|
|
||||||
try:
|
try:
|
||||||
import RNS as _rns # noqa: F401
|
import RNS as _rns # noqa: F401
|
||||||
ok &= check("Reticulum (RNS)", True)
|
ok &= check("Reticulum (RNS)", True)
|
||||||
except ImportError:
|
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:
|
try:
|
||||||
import watchdog # noqa: F401
|
import watchdog # noqa: F401
|
||||||
check("watchdog (instant push detection)", True)
|
check("watchdog (instant push detection)", True)
|
||||||
|
|
@ -480,75 +350,40 @@ def cmd_setup(args):
|
||||||
check(
|
check(
|
||||||
"watchdog (optional — enables instant push detection)",
|
"watchdog (optional — enables instant push detection)",
|
||||||
False,
|
False,
|
||||||
"pip install watchdog # or: uv add watchdog",
|
"uv sync --extra watch",
|
||||||
)
|
)
|
||||||
|
|
||||||
print()
|
print()
|
||||||
print("Seed identity...")
|
print("Radicle 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()
|
user_nid = detect_radicle_nid()
|
||||||
ok &= check(
|
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),
|
bool(user_nid),
|
||||||
"rad auth # initialise your radicle identity first",
|
"rad auth",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Check whether the seed is registered in the user's radicle-node.
|
nid_in_config = False
|
||||||
# We do this by calling 'rad node' and looking for the seed NID.
|
|
||||||
seed_registered = False
|
|
||||||
if seed_nid:
|
|
||||||
try:
|
try:
|
||||||
r = subprocess.run(
|
import json as _json
|
||||||
["rad", "node"], capture_output=True, text=True, timeout=5
|
cfg = Path.home() / ".radicle" / "config.json"
|
||||||
)
|
if cfg.exists():
|
||||||
seed_registered = seed_nid in r.stdout
|
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:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
ok &= check(
|
ok &= check(
|
||||||
"Seed registered in your radicle node",
|
"radicle-node listens on 127.0.0.1",
|
||||||
seed_registered,
|
nid_in_config,
|
||||||
f"rad node connect {seed_nid}@127.0.0.1:{seed_port}",
|
'set "node": {"listen": ["127.0.0.1:8776"]} in ~/.radicle/config.json',
|
||||||
)
|
)
|
||||||
|
|
||||||
print()
|
print()
|
||||||
print("Seed process...")
|
if ok:
|
||||||
|
print("All checks passed. Run: radicle-rns bridge")
|
||||||
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}",
|
|
||||||
)
|
|
||||||
|
|
||||||
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")
|
|
||||||
else:
|
else:
|
||||||
print("Setup incomplete. Follow the instructions above, then run:")
|
print("Setup incomplete. Follow the instructions above, then re-run: radicle-rns setup")
|
||||||
print(f" radicle-rns setup # re-check")
|
|
||||||
print(f" radicle-rns seed # once all checks pass")
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
|
|
@ -625,63 +460,10 @@ def main():
|
||||||
)
|
)
|
||||||
add_identity_arg(gossip_parser)
|
add_identity_arg(gossip_parser)
|
||||||
|
|
||||||
# seed command
|
subparsers.add_parser(
|
||||||
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(
|
|
||||||
"setup",
|
"setup",
|
||||||
help="Check prerequisites and print setup instructions",
|
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 = subparsers.add_parser("bridge", help="Run Radicle-Reticulum bridge")
|
||||||
bridge_parser.add_argument(
|
bridge_parser.add_argument(
|
||||||
|
|
@ -740,8 +522,6 @@ def main():
|
||||||
cmd_identity(args)
|
cmd_identity(args)
|
||||||
elif args.command == "gossip":
|
elif args.command == "gossip":
|
||||||
cmd_gossip(args)
|
cmd_gossip(args)
|
||||||
elif args.command == "seed":
|
|
||||||
cmd_seed(args)
|
|
||||||
elif args.command == "setup":
|
elif args.command == "setup":
|
||||||
cmd_setup(args)
|
cmd_setup(args)
|
||||||
elif args.command == "bridge":
|
elif args.command == "bridge":
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
Loading…
Reference in New Issue