feat: Refactor CLI commands into dedicated modules, update roadmap, and simplify sync function signature.

This commit is contained in:
2025-12-22 23:06:36 +01:00
parent 57ecbffaed
commit 76810f8d8e
9 changed files with 798 additions and 782 deletions
+41 -745
View File
@@ -1,24 +1,21 @@
//! CLI command handlers
use lattice_core::{Node, StoreHandle};
use lattice_core::{Uuid, PeerStatus};
use lattice_net::LatticeEndpoint;
use chrono::DateTime;
use std::time::Instant;
/// Result of a command that may switch stores
/// Result of a command that may switch stores or exit
pub enum CommandResult {
/// No store change
Ok,
/// Switch to this store
SwitchTo(StoreHandle),
/// Exit the CLI
Quit,
}
/// Helper to call async code from sync command handlers
fn block_async<F: std::future::Future>(f: F) -> F::Output {
tokio::task::block_in_place(|| {
tokio::runtime::Handle::current().block_on(f)
})
pub fn block_async<F: std::future::Future>(f: F) -> F::Output {
tokio::task::block_in_place(|| tokio::runtime::Handle::current().block_on(f))
}
pub type Handler = fn(&Node, Option<&StoreHandle>, Option<&LatticeEndpoint>, &[String]) -> CommandResult;
@@ -26,758 +23,57 @@ pub type Handler = fn(&Node, Option<&StoreHandle>, Option<&LatticeEndpoint>, &[S
pub struct Command {
pub name: &'static str,
pub args: &'static str,
pub description: &'static str,
pub desc: &'static str,
pub group: &'static str,
pub min_args: usize,
pub max_args: usize,
pub handler: Handler,
}
/// Get all available commands
pub fn commands() -> Vec<Command> {
vec![
Command {
name: "init",
args: "",
description: "Initialize node with root store",
min_args: 0,
max_args: 0,
handler: cmd_init,
},
Command {
name: "create-store",
args: "",
description: "Create a new store",
min_args: 0,
max_args: 0,
handler: cmd_create_store,
},
Command {
name: "use",
args: "<uuid>",
description: "Switch to a store",
min_args: 1,
max_args: 1,
handler: cmd_use_store,
},
Command {
name: "list-stores",
args: "",
description: "List all stores",
min_args: 0,
max_args: 0,
handler: cmd_list_stores,
},
Command {
name: "put",
args: "<key> <value>",
description: "Store a key-value pair",
min_args: 2,
max_args: 2,
handler: cmd_put,
},
Command {
name: "get",
args: "<key> [-v]",
description: "Retrieve a value by key",
min_args: 1,
max_args: 2,
handler: cmd_get,
},
Command {
name: "delete",
args: "<key>",
description: "Delete a key",
min_args: 1,
max_args: 1,
handler: cmd_delete,
},
Command {
name: "list",
args: "[-v]",
description: "List all key-value pairs (-v for verbose)",
min_args: 0,
max_args: 1,
handler: cmd_list,
},
Command {
name: "status",
args: "",
description: "Show node/store info",
min_args: 0,
max_args: 0,
handler: cmd_status,
},
Command {
name: "author-state",
args: "[author-hex]",
description: "Show author state (default: self)",
min_args: 0,
max_args: 1,
handler: cmd_author_state,
},
Command {
name: "invite",
args: "<pubkey-hex>",
description: "Invite a peer node (writes to root store)",
min_args: 1,
max_args: 1,
handler: cmd_invite,
},
Command {
name: "peers",
args: "",
description: "List known peers from root store",
min_args: 0,
max_args: 0,
handler: cmd_peers,
},
Command {
name: "remove",
args: "<pubkey-hex>",
description: "Remove a peer (set status to removed)",
min_args: 1,
max_args: 1,
handler: cmd_remove,
},
Command {
name: "join",
args: "<nodeid-hex>",
description: "Join a mesh by connecting to a peer (requires no local store)",
min_args: 1,
max_args: 1,
handler: cmd_join,
},
Command {
name: "sync",
args: "[nodeid]",
description: "Sync entries with a peer (or all peers if none specified)",
min_args: 0,
max_args: 1,
handler: cmd_sync,
},
Command {
name: "help",
args: "",
description: "Show this help message",
min_args: 0,
max_args: 0,
handler: cmd_help,
},
]
}
// --- Store management ---
fn cmd_init(node: &Node, _store: Option<&StoreHandle>, _endpoint: Option<&LatticeEndpoint>, _args: &[String]) -> CommandResult {
match block_async(node.init()) {
Ok((store_id, handle)) => {
println!("Initialized with root store: {}", store_id);
println!("Node info stored in /nodes/{}/*", hex::encode(node.node_id()));
CommandResult::SwitchTo(handle)
}
Err(e) => {
eprintln!("Error: {}", e);
CommandResult::Ok
}
}
}
fn cmd_create_store(node: &Node, _store: Option<&StoreHandle>, _endpoint: Option<&LatticeEndpoint>, _args: &[String]) -> CommandResult {
match node.create_store() {
Ok(store_id) => {
println!("Created store: {}", store_id);
match node.open_store(store_id) {
Ok((handle, _)) => {
println!("Switched to new store");
CommandResult::SwitchTo(handle)
}
Err(e) => {
eprintln!("Warning: {}", e);
CommandResult::Ok
}
}
}
Err(e) => {
eprintln!("Error: {}", e);
CommandResult::Ok
}
}
}
fn cmd_use_store(node: &Node, _store: Option<&StoreHandle>, _endpoint: Option<&LatticeEndpoint>, args: &[String]) -> CommandResult {
let store_id = match Uuid::parse_str(&args[0]) {
Ok(id) => id,
Err(_) => {
eprintln!("Error: invalid UUID '{}'", args[0]);
return CommandResult::Ok;
}
};
let mut cmds = Vec::new();
let start = Instant::now();
match node.open_store(store_id) {
Ok((handle, info)) => {
if info.entries_replayed > 0 {
println!("Replayed {} entries ({:.2?})", info.entries_replayed, start.elapsed());
} else {
println!("Switched to store {}", store_id);
}
CommandResult::SwitchTo(handle)
}
Err(e) => {
eprintln!("Error: {}", e);
CommandResult::Ok
}
}
}
fn cmd_list_stores(node: &Node, store: Option<&StoreHandle>, _endpoint: Option<&LatticeEndpoint>, _args: &[String]) -> CommandResult {
let stores = match node.list_stores() {
Ok(s) => s,
Err(e) => {
eprintln!("Error: {}", e);
return CommandResult::Ok;
}
};
let current_id = store.map(|s| s.id());
// General CLI commands
cmds.push(Command {
name: "help", args: "", desc: "Show this help",
group: "general", min_args: 0, max_args: 0, handler: cmd_help as Handler
});
cmds.push(Command {
name: "quit", args: "", desc: "Exit",
group: "general", min_args: 0, max_args: 0, handler: cmd_quit as Handler
});
if stores.is_empty() {
println!("No stores. Use 'init' or 'create-store'.");
} else {
for store_id in stores {
let marker = if Some(store_id) == current_id { " *" } else { "" };
println!("{}{}", store_id, marker);
}
}
CommandResult::Ok
// Node commands (operations on the node)
cmds.extend(crate::node_commands::node_commands());
// Store commands (raw KV operations)
cmds.extend(crate::store_commands::store_commands());
cmds
}
// --- Info ---
fn cmd_help(_node: &Node, _store: Option<&StoreHandle>, _endpoint: Option<&LatticeEndpoint>, _args: &[String]) -> CommandResult {
println!("\nCommands:");
for cmd in commands() {
if cmd.args.is_empty() {
println!(" {:<16} {}", cmd.name, cmd.description);
} else {
println!(" {} {:<8} {}", cmd.name, cmd.args, cmd.description);
let cmds = commands();
let mut last_group = "";
for cmd in &cmds {
if cmd.group != last_group {
println!();
println!("[{}]", cmd.group);
last_group = cmd.group;
}
let usage = if cmd.args.is_empty() {
cmd.name.to_string()
} else {
format!("{} {}", cmd.name, cmd.args)
};
println!(" {:18} {}", usage, cmd.desc);
}
println!(" quit Exit");
println!();
CommandResult::Ok
}
fn cmd_status(node: &Node, store: Option<&StoreHandle>, _endpoint: Option<&LatticeEndpoint>, _args: &[String]) -> CommandResult {
println!("Node ID: {}", hex::encode(node.node_id()));
println!("Data: {}", node.data_path().display());
match node.root_store() {
Ok(Some(id)) => println!("Root: {}", id),
Ok(None) => println!("Root: (not set)"),
Err(_) => println!("Root: (error)"),
}
if let Some(h) = store {
println!("Store: {}", h.id());
println!("Log Seq: {}", block_async(h.log_seq()));
println!("Applied: {}", block_async(h.applied_seq()).unwrap_or(0));
// Show sync state summary
if let Ok(sync_state) = block_async(h.sync_state()) {
let authors = sync_state.authors();
let total_entries: u64 = authors.values().map(|a| a.seq).sum();
let num_authors = authors.len();
println!("Authors: {} ({} total entries)", num_authors, total_entries);
// Show per-author details
for (author, info) in authors {
println!(" {}...: seq={}, heads={}",
hex::encode(&author[..6]),
info.seq,
info.heads.len());
}
}
// Show log directory size
let logs_dir = node.data_path().join("stores").join(h.id().to_string()).join("logs");
if logs_dir.exists() {
let mut total_size = 0u64;
let mut file_count = 0;
if let Ok(entries) = std::fs::read_dir(&logs_dir) {
for entry in entries.flatten() {
if let Ok(meta) = entry.metadata() {
if meta.is_file() {
total_size += meta.len();
file_count += 1;
}
}
}
}
println!("Logs: {} files, {} bytes", file_count, total_size);
}
} else {
println!("Store: (none)");
}
CommandResult::Ok
}
// --- KV ---
fn cmd_put(_node: &Node, store: Option<&StoreHandle>, _endpoint: Option<&LatticeEndpoint>, args: &[String]) -> CommandResult {
let Some(h) = store else {
println!("No store selected. Use 'init' or 'use <uuid>'");
return CommandResult::Ok;
};
let start = Instant::now();
match block_async(h.put(args[0].as_bytes(), args[1].as_bytes())) {
Ok(seq) => println!("OK (seq: {}, {:.2?})", seq, start.elapsed()),
Err(e) => eprintln!("Error: {}", e),
}
CommandResult::Ok
}
fn cmd_get(_node: &Node, store: Option<&StoreHandle>, _endpoint: Option<&LatticeEndpoint>, args: &[String]) -> CommandResult {
let Some(h) = store else {
println!("No store selected. Use 'init' or 'use <uuid>'");
return CommandResult::Ok;
};
let verbose = args.get(1).map(|a| a == "-v").unwrap_or(false);
let start = Instant::now();
let key = args[0].as_bytes();
if verbose {
// Show all heads
match block_async(h.get_heads(key)) {
Ok(heads) if heads.is_empty() => println!("(nil)"),
Ok(heads) => {
for (i, head) in heads.iter().enumerate() {
let winner = if i == 0 { "" } else { " " };
let tombstone = if head.tombstone { "" } else { "" };
let author_short = hex::encode(&head.author).chars().take(8).collect::<String>();
if head.tombstone {
println!("{} {} (deleted) (hlc:{}, author:{})",
winner, tombstone, head.hlc, author_short);
} else {
println!("{} {} (hlc:{}, author:{})",
winner, format_value(&head.value), head.hlc, author_short);
}
}
if heads.len() > 1 {
println!("{} heads (conflict)", heads.len());
}
println!("({:.2?})", start.elapsed());
}
Err(e) => eprintln!("Error: {}", e),
}
} else {
match block_async(h.get(key)) {
Ok(Some(v)) => {
let heads = block_async(h.get_heads(key)).unwrap_or_default();
if heads.len() > 1 {
println!("{} (⚠ {} heads)", format_value(&v), heads.len());
} else {
println!("{}", format_value(&v));
}
println!("({:.2?})", start.elapsed());
}
Ok(None) => println!("(nil)"),
Err(e) => eprintln!("Error: {}", e),
}
}
CommandResult::Ok
}
fn cmd_delete(_node: &Node, store: Option<&StoreHandle>, _endpoint: Option<&LatticeEndpoint>, args: &[String]) -> CommandResult {
let Some(h) = store else {
println!("No store selected. Use 'init' or 'use <uuid>'");
return CommandResult::Ok;
};
let start = Instant::now();
match block_async(h.delete(args[0].as_bytes())) {
Ok(seq) => println!("OK (seq: {}, {:.2?})", seq, start.elapsed()),
Err(e) => eprintln!("Error: {}", e),
}
CommandResult::Ok
}
fn cmd_list(_node: &Node, store: Option<&StoreHandle>, _endpoint: Option<&LatticeEndpoint>, args: &[String]) -> CommandResult {
let Some(h) = store else {
println!("No store selected. Use 'init' or 'use <uuid>'");
return CommandResult::Ok;
};
let verbose = args.first().map(|a| a == "-v").unwrap_or(false);
let start = Instant::now();
match block_async(h.list()) {
Ok(entries) => {
if entries.is_empty() {
println!("(empty)");
} else {
for (k, v) in &entries {
let key_str = format_value(k);
if verbose {
// Show all heads for this key
let heads = block_async(h.get_heads(k)).unwrap_or_default();
println!("{}:", key_str);
for (i, head) in heads.iter().enumerate() {
let winner = if i == 0 { "" } else { " " };
let author_short = hex::encode(&head.author).chars().take(8).collect::<String>();
if head.tombstone {
println!(" {} ⊗ (deleted) (hlc:{}, author:{})",
winner, head.hlc, author_short);
} else {
println!(" {} {} (hlc:{}, author:{})",
winner, format_value(&head.value), head.hlc, author_short);
}
}
} else {
// Check for multiple heads
let heads = block_async(h.get_heads(k)).unwrap_or_default();
if heads.len() > 1 {
println!("{} = {} (⚠ {} heads)", key_str, format_value(v), heads.len());
} else {
println!("{} = {}", key_str, format_value(v));
}
}
}
println!("({} keys, {:.2?})", entries.len(), start.elapsed());
}
}
Err(e) => eprintln!("Error: {}", e),
}
CommandResult::Ok
}
fn format_value(v: &[u8]) -> String {
std::str::from_utf8(v).map(String::from).unwrap_or_else(|_| format!("0x{}", hex::encode(v)))
}
fn cmd_author_state(node: &Node, store: Option<&StoreHandle>, _endpoint: Option<&LatticeEndpoint>, args: &[String]) -> CommandResult {
let store = match store {
Some(s) => s,
None => {
eprintln!("Error: no store selected");
return CommandResult::Ok;
}
};
// Get author: from arg or default to self
let author_bytes: [u8; 32] = if args.is_empty() {
node.node_id()
} else {
let hex_str = args[0].trim_start_matches("0x");
match hex::decode(hex_str) {
Ok(bytes) if bytes.len() == 32 => bytes.try_into().unwrap(),
Ok(bytes) => {
eprintln!("Error: author must be 32 bytes, got {}", bytes.len());
return CommandResult::Ok;
}
Err(e) => {
eprintln!("Error: invalid hex: {}", e);
return CommandResult::Ok;
}
}
};
match block_async(store.author_state(&author_bytes)) {
Ok(Some(state)) => {
println!("Author: {}", hex::encode(&author_bytes));
println!(" seq: {}", state.seq);
println!(" hash: {}", hex::encode(&state.hash));
println!(" log_offset: {}", state.log_offset);
}
Ok(None) => {
println!("No state for author: {}", hex::encode(&author_bytes));
}
Err(e) => eprintln!("Error: {}", e),
}
CommandResult::Ok
}
// --- Peer management ---
fn cmd_invite(node: &Node, store: Option<&StoreHandle>, _endpoint: Option<&LatticeEndpoint>, args: &[String]) -> CommandResult {
let store = match store {
Some(s) => s,
None => {
eprintln!("Not in a store. Run 'use' or 'init' first.");
return CommandResult::Ok;
}
};
let pubkey_hex = &args[0];
let _peer_pubkey = match hex::decode(pubkey_hex) {
Ok(bytes) if bytes.len() == 32 => bytes,
_ => {
eprintln!("Invalid pubkey: expected 64 hex chars (32 bytes)");
return CommandResult::Ok;
}
};
// Write peer info as separate keys
let inviter_hex = hex::encode(node.node_id());
let added_at = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let added_by_key = format!("/nodes/{}/added_by", pubkey_hex);
match block_async(store.put(added_by_key.as_bytes(), inviter_hex.as_bytes())) {
Ok(_) => {}
Err(e) => {
eprintln!("Error writing added_by: {}", e);
return CommandResult::Ok;
}
}
let added_at_key = format!("/nodes/{}/added_at", pubkey_hex);
match block_async(store.put(added_at_key.as_bytes(), added_at.to_string().as_bytes())) {
Ok(_) => {}
Err(e) => {
eprintln!("Error writing added_at: {}", e);
return CommandResult::Ok;
}
}
// Write /nodes/{pubkey}/status = invited (becomes active after sync)
let status_key = format!("/nodes/{}/status", pubkey_hex);
match block_async(store.put(status_key.as_bytes(), PeerStatus::Invited.as_str().as_bytes())) {
Ok(_) => {}
Err(e) => {
eprintln!("Error writing status: {}", e);
return CommandResult::Ok;
}
}
println!("Invited peer: {}", pubkey_hex);
println!(" /nodes/{}/added_by", pubkey_hex);
println!(" /nodes/{}/added_at", pubkey_hex);
println!(" /nodes/{}/status = {} (will become active after sync)", pubkey_hex, PeerStatus::Invited.as_str());
CommandResult::Ok
}
fn cmd_peers(_node: &Node, store: Option<&StoreHandle>, _endpoint: Option<&LatticeEndpoint>, _args: &[String]) -> CommandResult {
let store = match store {
Some(s) => s,
None => {
eprintln!("Not in a store. Run 'use' or 'init' first.");
return CommandResult::Ok;
}
};
// List all keys under /nodes/
let all = match block_async(store.list()) {
Ok(v) => v,
Err(e) => {
eprintln!("Error listing: {}", e);
return CommandResult::Ok;
}
};
// Collect unique pubkeys with status
let mut peers: std::collections::HashMap<String, PeerStatus> = std::collections::HashMap::new();
for (key, value) in &all {
let key_str = String::from_utf8_lossy(key);
if key_str.ends_with("/status") {
if let Some(pubkey) = key_str.strip_prefix("/nodes/").and_then(|s| s.strip_suffix("/status")) {
let status_str = String::from_utf8_lossy(value);
if let Some(status) = PeerStatus::from_str(&status_str) {
peers.insert(pubkey.to_string(), status);
}
}
}
}
if peers.is_empty() {
println!("No peers found.");
} else {
// Group peers by status
let mut by_status: std::collections::HashMap<PeerStatus, Vec<(String, String, String)>> =
std::collections::HashMap::new();
for (pubkey, status) in &peers {
// Try to get name and added_at from separate keys
let name_key = format!("/nodes/{}/name", pubkey);
let added_at_key = format!("/nodes/{}/added_at", pubkey);
let name = match block_async(store.get(name_key.as_bytes())) {
Ok(Some(bytes)) => String::from_utf8_lossy(&bytes).to_string(),
_ => String::new(),
};
let added = match block_async(store.get(added_at_key.as_bytes())) {
Ok(Some(bytes)) => {
if let Ok(ts) = String::from_utf8_lossy(&bytes).parse::<i64>() {
DateTime::from_timestamp(ts, 0)
.map(|dt| dt.format("%Y-%m-%d").to_string())
.unwrap_or_default()
} else {
String::new()
}
}
_ => String::new(),
};
by_status.entry(*status)
.or_default()
.push((pubkey.clone(), name, added));
}
// Print grouped by status in order: active, invited, removed
let status_order = [PeerStatus::Active, PeerStatus::Invited, PeerStatus::Removed];
for status in &status_order {
if let Some(peer_list) = by_status.get(status) {
println!("\n[{}] ({}):", status.as_str(), peer_list.len());
let mut sorted = peer_list.clone();
sorted.sort_by(|a, b| a.0.cmp(&b.0));
for (pubkey, name, added) in &sorted {
let info_str = match (name.is_empty(), added.is_empty()) {
(false, false) => format!(" {} ({})", name, added),
(false, true) => format!(" {}", name),
(true, false) => format!(" ({})", added),
_ => String::new(),
};
println!(" {}{}", pubkey, info_str);
}
}
}
}
CommandResult::Ok
}
fn cmd_remove(node: &Node, store: Option<&StoreHandle>, _endpoint: Option<&LatticeEndpoint>, args: &[String]) -> CommandResult {
let store = match store {
Some(s) => s,
None => {
eprintln!("Not in a store. Run 'use' or 'init' first.");
return CommandResult::Ok;
}
};
let pubkey_hex = &args[0];
// Validate pubkey format (should be 64 hex chars)
if pubkey_hex.len() != 64 || !pubkey_hex.chars().all(|c| c.is_ascii_hexdigit()) {
eprintln!("Invalid pubkey: expected 64 hex characters");
return CommandResult::Ok;
}
// Prevent self-removal
let my_pubkey = hex::encode(node.node_id());
if pubkey_hex == &my_pubkey {
eprintln!("Cannot remove yourself.");
return CommandResult::Ok;
}
// Check if peer exists
let status_key = format!("/nodes/{}/status", pubkey_hex);
match block_async(store.get(status_key.as_bytes())) {
Ok(Some(status)) => {
if status == PeerStatus::Removed.as_str().as_bytes() {
println!("Peer {} is already removed.", &pubkey_hex[..10]);
return CommandResult::Ok;
}
}
Ok(None) => {
eprintln!("Peer {} not found.", &pubkey_hex[..10]);
return CommandResult::Ok;
}
Err(e) => {
eprintln!("Error checking peer: {}", e);
return CommandResult::Ok;
}
}
// Set status to removed
match block_async(store.put(status_key.as_bytes(), PeerStatus::Removed.as_str().as_bytes())) {
Ok(_) => println!("Removed peer: {}...", &pubkey_hex[..10]),
Err(e) => eprintln!("Error removing peer: {}", e),
}
CommandResult::Ok
}
fn cmd_join(node: &Node, store: Option<&StoreHandle>, endpoint: Option<&LatticeEndpoint>, args: &[String]) -> CommandResult {
let endpoint = match endpoint {
Some(ep) => ep,
None => {
eprintln!("Iroh endpoint not started.");
return CommandResult::Ok;
}
};
if store.is_some() {
eprintln!("Already initialized. Use 'sync' to sync with peers.");
return CommandResult::Ok;
}
let peer_id = match lattice_net::parse_node_id(&args[0]) {
Ok(id) => id,
Err(e) => {
eprintln!("Invalid node ID: {}", e);
return CommandResult::Ok;
}
};
println!("Joining mesh via {}...", peer_id.fmt_short());
match block_async(lattice_net::join_mesh(node, endpoint, peer_id)) {
Ok(handle) => {
println!("Joined mesh! Use 'sync' command to sync entries.");
CommandResult::SwitchTo(handle)
}
Err(e) => {
eprintln!("Join failed: {}", e);
CommandResult::Ok
}
}
}
fn cmd_sync(node: &Node, store: Option<&StoreHandle>, endpoint: Option<&LatticeEndpoint>, args: &[String]) -> CommandResult {
let endpoint = match endpoint {
Some(ep) => ep,
None => {
eprintln!("Iroh endpoint not started.");
return CommandResult::Ok;
}
};
let store = match store {
Some(s) => s,
None => {
eprintln!("No store open. Use 'init' or 'join' first.");
return CommandResult::Ok;
}
};
if args.is_empty() {
// Sync with all active peers
match block_async(lattice_net::sync_all(node, endpoint, store)) {
Ok(results) => {
if results.is_empty() {
println!("No peers to sync with.");
} else {
let total: u64 = results.iter().map(|r| r.entries_applied).sum();
println!("\nSync complete! Applied {} entries from {} peer(s).", total, results.len());
}
}
Err(e) => eprintln!("Sync failed: {}", e),
}
} else {
// Sync with specific peer
let peer_id = match lattice_net::parse_node_id(&args[0]) {
Ok(id) => id,
Err(e) => {
eprintln!("Invalid node ID: {}", e);
return CommandResult::Ok;
}
};
println!("Syncing with {}...", peer_id.fmt_short());
match block_async(lattice_net::sync_with_peer(node, endpoint, store, peer_id)) {
Ok(result) => {
println!("Sync complete! Applied {} entries (peer sent {})",
result.entries_applied, result.entries_sent_by_peer);
}
Err(e) => eprintln!("Sync failed: {}", e),
}
}
CommandResult::Ok
fn cmd_quit(_node: &Node, _store: Option<&StoreHandle>, _endpoint: Option<&LatticeEndpoint>, _args: &[String]) -> CommandResult {
println!("Goodbye!");
CommandResult::Quit
}
+10 -10
View File
@@ -1,6 +1,8 @@
//! Lattice Interactive CLI
mod commands;
mod node_commands;
mod store_commands;
use lattice_net::spawn_accept_loop;
use commands::CommandResult;
@@ -52,15 +54,17 @@ async fn main() {
}
let mut current_store: Option<StoreHandle> = match node.open_root_store() {
Ok(Some((h, open_info))) => {
Ok(Some(open_info)) => {
if open_info.entries_replayed > 0 {
println!("Root: {} (replayed {})", open_info.store_id, open_info.entries_replayed);
} else {
println!("Root: {}", open_info.store_id);
}
// Update shared store for accept loop
*shared_store.write().await = Some(h.clone());
Some(h)
let h = node.root_store().as_ref().cloned();
if let Some(ref handle) = h {
*shared_store.write().await = Some(handle.clone());
}
h
}
Ok(None) => {
println!("Status: Not initialized (use 'init')");
@@ -97,13 +101,8 @@ async fn main() {
};
let cmd_name = args.first().map(|s| s.as_str()).unwrap_or("");
if cmd_name == "quit" || cmd_name == "exit" {
println!("Goodbye!");
break;
}
match cmds.iter().find(|c| c.name == cmd_name) {
match cmds.iter().find(|c| c.name == cmd_name || (cmd_name == "exit" && c.name == "quit")) {
Some(cmd) => {
let cmd_args = &args[1..];
if cmd_args.len() < cmd.min_args || cmd_args.len() > cmd.max_args {
@@ -116,6 +115,7 @@ async fn main() {
*shared_store.write().await = Some(h.clone());
current_store = Some(h);
}
CommandResult::Quit => break,
}
}
}
+313
View File
@@ -0,0 +1,313 @@
//! Node commands - operations on the node (mesh, peers, status)
use crate::commands::{block_async, Command, CommandResult, Handler};
use lattice_core::{Node, StoreHandle, PeerStatus, Uuid};
use lattice_net::LatticeEndpoint;
use chrono::DateTime;
use std::time::Instant;
pub fn node_commands() -> Vec<Command> {
vec![
// Store management
Command { name: "init", args: "", desc: "Initialize root store", group: "node", min_args: 0, max_args: 0, handler: cmd_init as Handler },
Command { name: "create-store", args: "", desc: "Create a new store", group: "node", min_args: 0, max_args: 0, handler: cmd_create_store as Handler },
Command { name: "use", args: "<uuid>", desc: "Switch to a store", group: "node", min_args: 1, max_args: 1, handler: cmd_use_store as Handler },
Command { name: "list-stores", args: "", desc: "List all stores", group: "node", min_args: 0, max_args: 0, handler: cmd_list_stores as Handler },
Command { name: "node-status", args: "", desc: "Show node info", group: "node", min_args: 0, max_args: 0, handler: cmd_node_status as Handler },
// Peer management
Command { name: "invite", args: "<pubkey>", desc: "Invite a peer", group: "peers", min_args: 1, max_args: 1, handler: cmd_invite as Handler },
Command { name: "peers", args: "", desc: "List all peers", group: "peers", min_args: 0, max_args: 0, handler: cmd_peers as Handler },
Command { name: "remove", args: "<pubkey>", desc: "Remove a peer", group: "peers", min_args: 1, max_args: 1, handler: cmd_remove as Handler },
// Networking
Command { name: "join", args: "<node_id>", desc: "Join an existing mesh", group: "network", min_args: 1, max_args: 1, handler: cmd_join as Handler },
Command { name: "sync", args: "[node_id]", desc: "Sync with peers", group: "network", min_args: 0, max_args: 1, handler: cmd_sync as Handler },
]
}
// --- Store management ---
fn cmd_init(node: &Node, _store: Option<&StoreHandle>, _endpoint: Option<&LatticeEndpoint>, _args: &[String]) -> CommandResult {
match block_async(node.init()) {
Ok(store_id) => {
println!("Initialized with root store: {}", store_id);
println!("Node info stored in /nodes/{}/*", hex::encode(node.node_id()));
match node.root_store().as_ref() {
Some(h) => CommandResult::SwitchTo(h.clone()),
None => CommandResult::Ok,
}
}
Err(e) => {
eprintln!("Error: {}", e);
CommandResult::Ok
}
}
}
fn cmd_create_store(node: &Node, _store: Option<&StoreHandle>, _endpoint: Option<&LatticeEndpoint>, _args: &[String]) -> CommandResult {
match node.create_store() {
Ok(store_id) => {
println!("Created store: {}", store_id);
match node.open_store(store_id) {
Ok((handle, _)) => {
println!("Switched to new store");
CommandResult::SwitchTo(handle)
}
Err(e) => {
eprintln!("Warning: {}", e);
CommandResult::Ok
}
}
}
Err(e) => {
eprintln!("Error: {}", e);
CommandResult::Ok
}
}
}
fn cmd_use_store(node: &Node, _store: Option<&StoreHandle>, _endpoint: Option<&LatticeEndpoint>, args: &[String]) -> CommandResult {
let store_id = match Uuid::parse_str(&args[0]) {
Ok(id) => id,
Err(_) => {
eprintln!("Error: invalid UUID '{}'", args[0]);
return CommandResult::Ok;
}
};
let start = Instant::now();
match node.open_store(store_id) {
Ok((handle, info)) => {
if info.entries_replayed > 0 {
println!("Replayed {} entries ({:.2?})", info.entries_replayed, start.elapsed());
} else {
println!("Switched to store {}", store_id);
}
CommandResult::SwitchTo(handle)
}
Err(e) => {
eprintln!("Error: {}", e);
CommandResult::Ok
}
}
}
fn cmd_list_stores(node: &Node, store: Option<&StoreHandle>, _endpoint: Option<&LatticeEndpoint>, _args: &[String]) -> CommandResult {
let stores = match node.list_stores() {
Ok(s) => s,
Err(e) => {
eprintln!("Error: {}", e);
return CommandResult::Ok;
}
};
let current_id = store.map(|s| s.id());
if stores.is_empty() {
println!("No stores. Use 'init' or 'create-store'.");
} else {
for store_id in stores {
let marker = if Some(store_id) == current_id { " *" } else { "" };
println!("{}{}", store_id, marker);
}
}
CommandResult::Ok
}
// --- Info ---
fn cmd_node_status(node: &Node, _store: Option<&StoreHandle>, _endpoint: Option<&LatticeEndpoint>, _args: &[String]) -> CommandResult {
println!("Node ID: {}", hex::encode(node.node_id()));
if let Some(name) = node.name() {
println!("Name: {}", name);
}
println!("Data: {}", node.data_path().display());
match node.root_store_id() {
Ok(Some(id)) => println!("Root: {}", id),
Ok(None) => println!("Root: (not set)"),
Err(_) => println!("Root: (error)"),
}
// Count peers using node.list_peers()
if let Ok(peers) = block_async(node.list_peers()) {
let active = peers.iter().filter(|p| p.status == PeerStatus::Active).count();
let invited = peers.iter().filter(|p| p.status == PeerStatus::Invited).count();
println!("Peers: {} active, {} invited", active, invited);
}
CommandResult::Ok
}
// --- Peer management ---
fn cmd_invite(node: &Node, _store: Option<&StoreHandle>, _endpoint: Option<&LatticeEndpoint>, args: &[String]) -> CommandResult {
let pubkey_hex = &args[0];
let pubkey: [u8; 32] = match hex::decode(pubkey_hex) {
Ok(bytes) if bytes.len() == 32 => bytes.try_into().unwrap(),
_ => {
eprintln!("Invalid pubkey: expected 64 hex chars (32 bytes)");
return CommandResult::Ok;
}
};
match block_async(node.invite_peer(&pubkey)) {
Ok(()) => {
println!("Invited peer: {}", pubkey_hex);
println!(" Status: {} (will become active after sync)", PeerStatus::Invited.as_str());
}
Err(e) => eprintln!("Error: {}", e),
}
CommandResult::Ok
}
fn cmd_peers(node: &Node, _store: Option<&StoreHandle>, _endpoint: Option<&LatticeEndpoint>, _args: &[String]) -> CommandResult {
let peers = match block_async(node.list_peers()) {
Ok(p) => p,
Err(e) => {
eprintln!("Error: {}", e);
return CommandResult::Ok;
}
};
if peers.is_empty() {
println!("No peers found.");
return CommandResult::Ok;
}
// Group peers by status
let mut by_status: std::collections::HashMap<PeerStatus, Vec<&lattice_core::PeerInfo>> =
std::collections::HashMap::new();
for peer in &peers {
by_status.entry(peer.status).or_default().push(peer);
}
// Print grouped by status in order: active, invited, removed
let status_order = [PeerStatus::Active, PeerStatus::Invited, PeerStatus::Removed];
for status in &status_order {
if let Some(peer_list) = by_status.get(status) {
println!("\n[{}] ({}):", status.as_str(), peer_list.len());
let mut sorted: Vec<_> = peer_list.iter().collect();
sorted.sort_by(|a, b| a.pubkey.cmp(&b.pubkey));
for peer in sorted {
let added_str = peer.added_at
.and_then(|ts| DateTime::from_timestamp(ts as i64, 0))
.map(|dt| dt.format("%Y-%m-%d").to_string())
.unwrap_or_default();
let info_str = match (peer.name.as_ref(), added_str.is_empty()) {
(Some(name), false) => format!(" {} ({})", name, added_str),
(Some(name), true) => format!(" {}", name),
(None, false) => format!(" ({})", added_str),
(None, true) => String::new(),
};
println!(" {}{}", peer.pubkey, info_str);
}
}
}
CommandResult::Ok
}
fn cmd_remove(node: &Node, _store: Option<&StoreHandle>, _endpoint: Option<&LatticeEndpoint>, args: &[String]) -> CommandResult {
let pubkey_hex = &args[0];
let pubkey: [u8; 32] = match hex::decode(pubkey_hex) {
Ok(bytes) if bytes.len() == 32 => bytes.try_into().unwrap(),
_ => {
eprintln!("Invalid pubkey: expected 64 hex characters");
return CommandResult::Ok;
}
};
match block_async(node.remove_peer(&pubkey)) {
Ok(()) => println!("Removed peer: {}...", &pubkey_hex[..10]),
Err(e) => eprintln!("Error: {}", e),
}
CommandResult::Ok
}
// --- Networking ---
fn cmd_join(node: &Node, store: Option<&StoreHandle>, endpoint: Option<&LatticeEndpoint>, args: &[String]) -> CommandResult {
let endpoint = match endpoint {
Some(ep) => ep,
None => {
eprintln!("Iroh endpoint not started.");
return CommandResult::Ok;
}
};
if store.is_some() {
eprintln!("Already initialized. Use 'sync' to sync with peers.");
return CommandResult::Ok;
}
let peer_id = match lattice_net::parse_node_id(&args[0]) {
Ok(id) => id,
Err(e) => {
eprintln!("Invalid node ID: {}", e);
return CommandResult::Ok;
}
};
println!("Joining mesh via {}...", peer_id.fmt_short());
match block_async(lattice_net::join_mesh(node, endpoint, peer_id)) {
Ok(handle) => {
println!("Joined mesh! Use 'sync' command to sync entries.");
CommandResult::SwitchTo(handle)
}
Err(e) => {
eprintln!("Join failed: {}", e);
CommandResult::Ok
}
}
}
fn cmd_sync(node: &Node, store: Option<&StoreHandle>, endpoint: Option<&LatticeEndpoint>, args: &[String]) -> CommandResult {
let endpoint = match endpoint {
Some(ep) => ep,
None => {
eprintln!("Iroh endpoint not started.");
return CommandResult::Ok;
}
};
let store = match store {
Some(s) => s,
None => {
eprintln!("No store open. Use 'init' or 'join' first.");
return CommandResult::Ok;
}
};
if args.is_empty() {
// Sync with all active peers
match block_async(lattice_net::sync_all(node, endpoint, store)) {
Ok(results) => {
if results.is_empty() {
println!("No peers to sync with.");
} else {
let total: u64 = results.iter().map(|r| r.entries_applied).sum();
println!("\nSync complete! Applied {} entries from {} peer(s).", total, results.len());
}
}
Err(e) => eprintln!("Sync failed: {}", e),
}
} else {
// Sync with specific peer
let peer_id = match lattice_net::parse_node_id(&args[0]) {
Ok(id) => id,
Err(e) => {
eprintln!("Invalid node ID: {}", e);
return CommandResult::Ok;
}
};
println!("Syncing with {}...", peer_id.fmt_short());
match block_async(lattice_net::sync_with_peer(endpoint, store, peer_id)) {
Ok(result) => {
println!("Sync complete! Applied {} entries (peer sent {})",
result.entries_applied, result.entries_sent_by_peer);
}
Err(e) => eprintln!("Sync failed: {}", e),
}
}
CommandResult::Ok
}
+209
View File
@@ -0,0 +1,209 @@
//! Store commands - direct KV operations
use crate::commands::{block_async, Command, CommandResult, Handler};
use lattice_core::{Node, StoreHandle};
use lattice_net::LatticeEndpoint;
use std::time::Instant;
pub fn store_commands() -> Vec<Command> {
vec![
Command { name: "store-status", args: "", desc: "Show store info", group: "store", min_args: 0, max_args: 0, handler: cmd_store_status as Handler },
Command { name: "put", args: "<key> <value>", desc: "Store a key-value pair", group: "store", min_args: 2, max_args: 2, handler: cmd_put as Handler },
Command { name: "get", args: "<key> [-v]", desc: "Get value for key", group: "store", min_args: 1, max_args: 2, handler: cmd_get as Handler },
Command { name: "delete", args: "<key>", desc: "Delete a key", group: "store", min_args: 1, max_args: 1, handler: cmd_delete as Handler },
Command { name: "list", args: "[-v]", desc: "List all keys", group: "store", min_args: 0, max_args: 1, handler: cmd_list as Handler },
Command { name: "author-state", args: "[pubkey]", desc: "Show author sync state", group: "store", min_args: 0, max_args: 1, handler: cmd_author_state as Handler },
]
}
fn cmd_store_status(_node: &Node, store: Option<&StoreHandle>, _endpoint: Option<&LatticeEndpoint>, _args: &[String]) -> CommandResult {
let Some(h) = store else {
println!("No store selected. Use 'init' or 'use <uuid>'");
return CommandResult::Ok;
};
println!("Store ID: {}", h.id());
println!("Log Seq: {}", block_async(h.log_seq()));
println!("Applied: {}", block_async(h.applied_seq()).unwrap_or(0));
let all = block_async(h.list()).unwrap_or_default();
println!("Keys: {}", all.len());
// Show log directory size
let (file_count, total_size) = block_async(h.log_stats());
if file_count > 0 {
println!("Logs: {} files, {} bytes", file_count, total_size);
}
CommandResult::Ok
}
fn cmd_put(_node: &Node, store: Option<&StoreHandle>, _endpoint: Option<&LatticeEndpoint>, args: &[String]) -> CommandResult {
let Some(h) = store else {
println!("No store selected. Use 'init' or 'use <uuid>'");
return CommandResult::Ok;
};
let start = Instant::now();
match block_async(h.put(args[0].as_bytes(), args[1].as_bytes())) {
Ok(seq) => println!("OK (seq: {}, {:.2?})", seq, start.elapsed()),
Err(e) => eprintln!("Error: {}", e),
}
CommandResult::Ok
}
fn cmd_get(_node: &Node, store: Option<&StoreHandle>, _endpoint: Option<&LatticeEndpoint>, args: &[String]) -> CommandResult {
let Some(h) = store else {
println!("No store selected. Use 'init' or 'use <uuid>'");
return CommandResult::Ok;
};
let verbose = args.get(1).map(|a| a == "-v").unwrap_or(false);
let start = Instant::now();
let key = args[0].as_bytes();
if verbose {
// Show all heads
match block_async(h.get_heads(key)) {
Ok(heads) if heads.is_empty() => println!("(nil)"),
Ok(heads) => {
for (i, head) in heads.iter().enumerate() {
let winner = if i == 0 { "" } else { " " };
let tombstone = if head.tombstone { "" } else { "" };
let author_short = hex::encode(&head.author).chars().take(8).collect::<String>();
if head.tombstone {
println!("{} {} (deleted) (hlc:{}, author:{})",
winner, tombstone, head.hlc, author_short);
} else {
println!("{} {} (hlc:{}, author:{})",
winner, format_value(&head.value), head.hlc, author_short);
}
}
if heads.len() > 1 {
println!("{} heads (conflict)", heads.len());
}
println!("({:.2?})", start.elapsed());
}
Err(e) => eprintln!("Error: {}", e),
}
} else {
match block_async(h.get(key)) {
Ok(Some(v)) => {
let heads = block_async(h.get_heads(key)).unwrap_or_default();
if heads.len() > 1 {
println!("{} (⚠ {} heads)", format_value(&v), heads.len());
} else {
println!("{}", format_value(&v));
}
println!("({:.2?})", start.elapsed());
}
Ok(None) => println!("(nil)"),
Err(e) => eprintln!("Error: {}", e),
}
}
CommandResult::Ok
}
fn cmd_delete(_node: &Node, store: Option<&StoreHandle>, _endpoint: Option<&LatticeEndpoint>, args: &[String]) -> CommandResult {
let Some(h) = store else {
println!("No store selected. Use 'init' or 'use <uuid>'");
return CommandResult::Ok;
};
let start = Instant::now();
match block_async(h.delete(args[0].as_bytes())) {
Ok(seq) => println!("OK (seq: {}, {:.2?})", seq, start.elapsed()),
Err(e) => eprintln!("Error: {}", e),
}
CommandResult::Ok
}
fn cmd_list(_node: &Node, store: Option<&StoreHandle>, _endpoint: Option<&LatticeEndpoint>, args: &[String]) -> CommandResult {
let Some(h) = store else {
println!("No store selected. Use 'init' or 'use <uuid>'");
return CommandResult::Ok;
};
let verbose = args.first().map(|a| a == "-v").unwrap_or(false);
let start = Instant::now();
match block_async(h.list()) {
Ok(entries) => {
if entries.is_empty() {
println!("(empty)");
} else {
for (k, v) in &entries {
let key_str = format_value(k);
if verbose {
// Show all heads for this key
let heads = block_async(h.get_heads(k)).unwrap_or_default();
println!("{}:", key_str);
for (i, head) in heads.iter().enumerate() {
let winner = if i == 0 { "" } else { " " };
let author_short = hex::encode(&head.author).chars().take(8).collect::<String>();
if head.tombstone {
println!(" {} ⊗ (deleted) (hlc:{}, author:{})",
winner, head.hlc, author_short);
} else {
println!(" {} {} (hlc:{}, author:{})",
winner, format_value(&head.value), head.hlc, author_short);
}
}
} else {
// Check for multiple heads
let heads = block_async(h.get_heads(k)).unwrap_or_default();
if heads.len() > 1 {
println!("{} = {} (⚠ {} heads)", key_str, format_value(v), heads.len());
} else {
println!("{} = {}", key_str, format_value(v));
}
}
}
println!("({} keys, {:.2?})", entries.len(), start.elapsed());
}
}
Err(e) => eprintln!("Error: {}", e),
}
CommandResult::Ok
}
fn cmd_author_state(node: &Node, store: Option<&StoreHandle>, _endpoint: Option<&LatticeEndpoint>, args: &[String]) -> CommandResult {
let store = match store {
Some(s) => s,
None => {
eprintln!("Error: no store selected");
return CommandResult::Ok;
}
};
// Get author: from arg or default to self
let author_bytes: [u8; 32] = if args.is_empty() {
node.node_id()
} else {
let hex_str = args[0].trim_start_matches("0x");
match hex::decode(hex_str) {
Ok(bytes) if bytes.len() == 32 => bytes.try_into().unwrap(),
Ok(bytes) => {
eprintln!("Error: author must be 32 bytes, got {}", bytes.len());
return CommandResult::Ok;
}
Err(e) => {
eprintln!("Error: invalid hex: {}", e);
return CommandResult::Ok;
}
}
};
match block_async(store.author_state(&author_bytes)) {
Ok(Some(state)) => {
println!("Author: {}", hex::encode(&author_bytes));
println!(" seq: {}", state.seq);
println!(" hash: {}", hex::encode(&state.hash));
println!(" log_offset: {}", state.log_offset);
}
Ok(None) => {
println!("No state for author: {}", hex::encode(&author_bytes));
}
Err(e) => eprintln!("Error: {}", e),
}
CommandResult::Ok
}
fn format_value(v: &[u8]) -> String {
std::str::from_utf8(v).map(String::from).unwrap_or_else(|_| format!("0x{}", hex::encode(v)))
}