use std::path::PathBuf;
use anyhow::{Result, anyhow};
use clap::Parser;
use ed25519_dalek::{SigningKey, VerifyingKey};
use iroh_base::{EndpointAddr, EndpointId};
use iroh_tickets::endpoint::EndpointTicket;
use triblespace_net::peer::{Peer, PeerConfig, SyncDirection};
use triblespace_net::identity::load_or_create_key;
use triblespace_core::repo::pile::Pile;
fn open_pile(path: &PathBuf) -> Result<Pile> {
Pile::open(path).map_err(|e| anyhow!("open pile: {e:?}"))
}
fn parse_peers(strs: &[String]) -> Vec<EndpointAddr> {
strs.iter()
.filter_map(|s| {
let addr = if let Ok(ticket) = s.parse::<EndpointTicket>() {
ticket.endpoint_addr().clone()
} else {
let pk = s.parse::<iroh_base::PublicKey>().ok()?;
EndpointAddr::from(EndpointId::from(pk))
};
Some(triblespace_net::dot_stripped_endpoint_addr(addr))
})
.collect()
}
fn key_dir(pile_path: &PathBuf) -> &std::path::Path {
pile_path.parent().unwrap_or(pile_path.as_ref())
}
fn team_root_from_env(key: &SigningKey) -> Result<VerifyingKey> {
match std::env::var("TRIBLE_TEAM_ROOT") {
Ok(hex_str) => {
let bytes = hex::decode(hex_str.trim())
.map_err(|e| anyhow!("TRIBLE_TEAM_ROOT decode: {e}"))?;
let raw: [u8; 32] = bytes
.as_slice()
.try_into()
.map_err(|_| anyhow!("TRIBLE_TEAM_ROOT must be 32 bytes"))?;
VerifyingKey::from_bytes(&raw)
.map_err(|e| anyhow!("TRIBLE_TEAM_ROOT bad pubkey: {e}"))
}
Err(_) => Ok(key.verifying_key()),
}
}
fn self_cap_from_env() -> Result<[u8; 32]> {
match std::env::var("TRIBLE_TEAM_CAP") {
Ok(hex_str) => {
let bytes = hex::decode(hex_str.trim())
.map_err(|e| anyhow!("TRIBLE_TEAM_CAP decode: {e}"))?;
let raw: [u8; 32] = bytes
.as_slice()
.try_into()
.map_err(|_| anyhow!("TRIBLE_TEAM_CAP must be 32 bytes"))?;
Ok(raw)
}
Err(_) => Ok([0u8; 32]),
}
}
#[derive(Parser)]
pub enum Command {
Identity {
#[arg(long)]
key: Option<PathBuf>,
},
Status {
#[arg(long)]
key: Option<PathBuf>,
},
Sync {
pile: PathBuf,
#[arg(long, value_delimiter = ',')]
peers: Vec<String>,
#[arg(long)]
key: Option<PathBuf>,
#[arg(long, conflicts_with = "write_only")]
read_only: bool,
#[arg(long, conflicts_with = "read_only")]
write_only: bool,
#[arg(long, value_name = "SECS")]
duration: Option<u64>,
#[arg(long, value_name = "SECS")]
quiescent_for: Option<u64>,
},
}
pub fn run(cmd: Command) -> Result<()> {
match cmd {
Command::Identity { key } => run_identity(key),
Command::Status { key } => run_status(key),
Command::Sync { pile, peers, key, read_only, write_only, duration, quiescent_for } => {
let direction = if read_only {
SyncDirection::ReadOnly
} else if write_only {
SyncDirection::WriteOnly
} else {
SyncDirection::Bidirectional
};
run_sync(pile, peers, key, direction, duration, quiescent_for)
}
}
}
fn run_identity(sk: Option<PathBuf>) -> Result<()> {
let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
let key = load_or_create_key(&sk, &cwd)?;
let public = triblespace_net::identity::iroh_secret(&key).public();
println!("node: {public}");
let ticket = EndpointTicket::from(EndpointAddr::from(EndpointId::from(public)));
println!("ticket: {ticket} (id only — no relay/direct addrs)");
Ok(())
}
fn run_status(sk: Option<PathBuf>) -> Result<()> {
let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
let key = load_or_create_key(&sk, &cwd)?;
let public = triblespace_net::identity::iroh_secret(&key).public();
println!("node: {public}");
match std::env::var("TRIBLE_TEAM_ROOT") {
Ok(s) => {
let trimmed = s.trim();
println!("team_root: {trimmed} (from TRIBLE_TEAM_ROOT)");
}
Err(_) => {
println!(
"team_root: {} (single-user fallback — own pubkey)",
hex::encode(key.verifying_key().to_bytes()),
);
}
}
match std::env::var("TRIBLE_TEAM_CAP") {
Ok(s) => {
let trimmed = s.trim();
println!("self_cap: {trimmed} (from TRIBLE_TEAM_CAP)");
}
Err(_) => {
println!(
"self_cap: {} (NOT SET — remote will reject OP_AUTH)",
"0".repeat(64),
);
}
}
Ok(())
}
fn run_sync(
pile_path: PathBuf,
peer_strs: Vec<String>,
key_path: Option<PathBuf>,
direction: SyncDirection,
duration: Option<u64>,
quiescent_for: Option<u64>,
) -> Result<()> {
use triblespace_core::repo::Repository;
let key = load_or_create_key(&key_path, key_dir(&pile_path))?;
let peers = parse_peers(&peer_strs);
let pile = open_pile(&pile_path)?;
let team_root = team_root_from_env(&key)?;
let self_cap = self_cap_from_env()?;
let peer = Peer::new(pile, key.clone(), PeerConfig {
peers,
gossip: true,
team_root,
revoked: std::collections::HashSet::new(),
self_cap,
direction,
});
let mut repo = Repository::new(peer, key.clone(), triblespace_core::trible::TribleSet::new())
.map_err(|e| anyhow!("repo: {e:?}"))?;
eprintln!("node: {}", repo.storage().id());
eprintln!("team_root: {} (gossip topic)", hex::encode(team_root.to_bytes()));
let dir_label = match direction {
SyncDirection::Bidirectional => "bidirectional",
SyncDirection::ReadOnly => "read-only (no publish)",
SyncDirection::WriteOnly => "write-only (no fetch)",
};
eprintln!("direction: {dir_label}");
if let Some(d) = duration {
eprintln!("stop after: {d}s");
}
if let Some(q) = quiescent_for {
eprintln!("quiescent stop: {q}s without events");
}
eprintln!("live sync active. (Ctrl-C to stop)\n");
repo.storage_mut().republish_branches();
let started = std::time::Instant::now();
let duration_limit = duration.map(std::time::Duration::from_secs);
let quiescent_limit = quiescent_for.map(std::time::Duration::from_secs);
loop {
if let Some(limit) = duration_limit {
if started.elapsed() >= limit {
eprintln!("\nreached --duration limit ({}s); stopping", limit.as_secs());
break;
}
}
if let Some(limit) = quiescent_limit {
if repo.storage().last_event_at().elapsed() >= limit {
eprintln!("\nquiescent for {}s; stopping", limit.as_secs());
break;
}
}
if direction != SyncDirection::WriteOnly {
let tracks = triblespace_net::tracking::list_tracking_branches(repo.storage_mut());
for info in tracks {
let triblespace_net::tracking::TrackingBranchInfo {
local_id: tracking_id,
remote_name: name,
..
} = info;
match triblespace_net::tracking::merge_tracking_into_local(
&mut repo, tracking_id, &name,
) {
Ok(triblespace_net::tracking::MergeOutcome::Merged { .. }) => {
eprintln!(" merged '{name}'");
}
Ok(_) => { }
Err(e) => eprintln!(" merge error '{name}': {e}"),
}
}
} else {
repo.storage_mut().refresh();
}
std::thread::sleep(std::time::Duration::from_millis(100));
}
Ok(())
}