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};
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| {
if let Ok(ticket) = s.parse::<EndpointTicket>() {
return Some(ticket.endpoint_addr().clone());
}
s.parse::<iroh_base::PublicKey>()
.ok()
.map(|pk| EndpointAddr::from(EndpointId::from(pk)))
})
.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>,
},
Pull {
pile: PathBuf,
remote: String,
#[arg(long)]
branch: String,
#[arg(long)]
key: Option<PathBuf>,
},
}
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 } => {
run_sync(pile, peers, key)
}
Command::Pull { pile, remote, branch, key } => {
run_pull(pile, remote, branch, key)
}
}
}
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>) -> Result<()> {
use triblespace_core::repo::Repository;
let key = load_or_create_key(&key_path, key_dir(&pile_path))?;
let parsed_peers = parse_peers(&peer_strs);
let peers: Vec<EndpointId> = parsed_peers.iter().map(|a| a.id).collect();
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,
});
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()));
eprintln!("live sync active. (Ctrl-C to stop)\n");
repo.storage_mut().republish_branches();
let mut last_announce = std::time::Instant::now();
loop {
if last_announce.elapsed() > std::time::Duration::from_secs(10) {
repo.storage_mut().republish_branches();
last_announce = std::time::Instant::now();
}
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}"),
}
}
std::thread::sleep(std::time::Duration::from_millis(100));
}
}
fn run_pull(pile_path: PathBuf, remote: String, branch: String, key_path: Option<PathBuf>) -> Result<()> {
let key = load_or_create_key(&key_path, key_dir(&pile_path))?;
let remote_addr: EndpointAddr = if let Ok(ticket) = remote.parse::<EndpointTicket>() {
ticket.endpoint_addr().clone()
} else {
let pk: iroh_base::PublicKey = remote.parse()
.map_err(|e| anyhow!("bad remote: not an EndpointTicket and not a hex pubkey ({e})"))?;
EndpointAddr::from(EndpointId::from(pk))
};
let remote_short = remote_addr.id.fmt_short();
use triblespace_core::repo::Repository;
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: Vec::new(),
gossip: false,
team_root,
revoked: std::collections::HashSet::new(),
self_cap,
});
let mut repo = Repository::new(peer, key.clone(), triblespace_core::trible::TribleSet::new())
.map_err(|e| anyhow!("repo: {e:?}"))?;
eprintln!("connecting to {remote_short}...");
eprintln!("syncing...");
let tracking_id = repo.storage_mut().pull_branch(remote_addr, &branch)?;
use triblespace_net::tracking::MergeOutcome;
let outcome = triblespace_net::tracking::merge_tracking_into_local(
&mut repo, tracking_id, &branch,
)?;
let _ = repo.into_storage().into_store().close();
match outcome {
MergeOutcome::Empty => return Err(anyhow!("remote has no commit")),
MergeOutcome::UpToDate => eprintln!("up to date '{branch}'"),
MergeOutcome::Merged { .. } => eprintln!("merged '{branch}'"),
}
Ok(())
}