//! v2 CLI entry point — thin shell over `quicprochat_sdk::QpqClient`. //! //! Activated via `--features v2`. Replaces the v1 Cap'n Proto RPC main //! with a simplified command surface backed by the SDK. use std::path::{Path, PathBuf}; use std::process::Command as ProcessCommand; use std::time::Duration; use anyhow::Context; use clap::{Parser, Subcommand}; use quicprochat_sdk::client::QpqClient; use quicprochat_sdk::config::ClientConfig; use crate::v2_commands; // ── CLI ─────────────────────────────────────────────────────────────────────── #[derive(Debug, Parser)] #[command(name = "qpc", about = "quicprochat CLI client (v2)", version)] struct Args { /// Server address (host:port). #[arg(long, global = true, default_value = "127.0.0.1:7000", env = "QPQ_SERVER")] server: String, /// TLS server name (must match certificate SAN). #[arg(long, global = true, default_value = "localhost", env = "QPQ_SERVER_NAME")] server_name: String, /// Path to local conversation database. #[arg(long, global = true, default_value = "conversations.db", env = "QPQ_CONV_DB")] db_path: PathBuf, /// Password for encrypting the local database. #[arg(long, global = true, env = "QPQ_DB_PASSWORD")] db_password: Option, /// Path to the client state file (identity key, MLS state). #[arg(long, global = true, default_value = "qpc-state.bin", env = "QPQ_STATE")] state: PathBuf, /// DANGER: Skip TLS certificate verification. Development only. #[arg( long = "danger-accept-invalid-certs", global = true, env = "QPQ_DANGER_ACCEPT_INVALID_CERTS" )] danger_accept_invalid_certs: bool, /// Do not auto-start a local qpc-server. #[arg(long, global = true, env = "QPQ_NO_SERVER")] no_server: bool, #[command(subcommand)] command: Cmd, } #[derive(Debug, Subcommand)] enum Cmd { /// Register a new user via OPAQUE (password never leaves the client). RegisterUser { /// Username for the new account. #[arg(long)] username: String, /// Password (used in OPAQUE PAKE; server never sees it). #[arg(long)] password: String, }, /// Log in via OPAQUE and receive a session token. Login { #[arg(long)] username: String, #[arg(long)] password: String, }, /// Show local identity info. Whoami, /// Server health check. Health, /// Send a message to a conversation. Send { /// Conversation name (group name or DM peer username). #[arg(long)] to: String, /// Message text. #[arg(long)] msg: String, }, /// Receive pending messages from a conversation. Recv { /// Conversation name. #[arg(long)] from: String, }, /// Start or resume a DM with a user. Dm { /// Peer username. username: String, }, /// Group management commands. Group { #[command(subcommand)] action: GroupCmd, }, /// Resolve a username to its identity key. Resolve { /// Username to look up. username: String, }, /// Device management. Devices { #[command(subcommand)] action: DevicesCmd, }, /// Account recovery management. Recovery { #[command(subcommand)] action: RecoveryCmd, }, /// Offline outbox management. Outbox { #[command(subcommand)] action: OutboxCmd, }, } #[derive(Debug, Subcommand)] enum GroupCmd { /// Create a new group. Create { /// Group name. name: String, }, /// Invite a user to a group. Invite { /// Group name. #[arg(long)] group: String, /// Username to invite. #[arg(long)] user: String, }, } #[derive(Debug, Subcommand)] enum DevicesCmd { /// List registered devices. List, /// Register a new device. Register { /// Device ID (hex). #[arg(long)] id: String, /// Human-readable device name. #[arg(long)] name: String, }, /// Revoke a device. Revoke { /// Device ID (hex). #[arg(long)] id: String, }, } #[derive(Debug, Subcommand)] enum RecoveryCmd { /// Generate recovery codes and upload encrypted bundles. Setup, /// Recover account from a recovery code. Restore { /// Recovery code (e.g. "A3B7K9"). code: String, }, } #[derive(Debug, Subcommand)] enum OutboxCmd { /// Show pending outbox entries. List, /// Retry sending all pending outbox entries. Retry, /// Clear permanently failed outbox entries. Clear, } // ── Auto-server launch ─────────────────────────────────────────────────────── /// RAII guard that kills an auto-started server process on drop. struct ServerGuard(Option); impl Drop for ServerGuard { fn drop(&mut self) { if let Some(ref mut child) = self.0 { let _ = child.kill(); let _ = child.wait(); } } } /// Find the `qpc-server` binary: same directory as current exe, then PATH. fn find_server_binary() -> Option { if let Ok(exe) = std::env::current_exe() { let sibling = exe.with_file_name("qpc-server"); if sibling.exists() { return Some(sibling); } } std::env::var_os("PATH").and_then(|paths| { std::env::split_paths(&paths) .map(|dir| dir.join("qpc-server")) .find(|p| p.exists()) }) } /// Try a QUIC health probe to the server address. async fn probe_server(server_addr: &str) -> bool { use std::net::ToSocketAddrs; let addr = match server_addr.to_socket_addrs() { Ok(mut addrs) => match addrs.next() { Some(a) => a, None => return false, }, Err(_) => return false, }; // Simple TCP probe — if the port is open, the server is likely running. tokio::net::TcpStream::connect(addr) .await .is_ok() } /// Start a local qpc-server if one isn't already running. /// Returns a guard that kills the child on drop (if we started one). async fn ensure_server_running( server_addr: &str, data_dir: &Path, no_server: bool, ) -> anyhow::Result { if no_server { return Ok(ServerGuard(None)); } if probe_server(server_addr).await { return Ok(ServerGuard(None)); } let binary = find_server_binary().ok_or_else(|| { anyhow::anyhow!( "server at {server_addr} is not reachable and qpc-server binary not found; \ start a server manually or install qpc-server" ) })?; let cert_path = data_dir.join("server-cert.der"); let key_path = data_dir.join("server-key.der"); eprintln!("starting server on {server_addr}..."); let child = ProcessCommand::new(&binary) .args([ "--allow-insecure-auth", "--listen", server_addr, "--tls-cert", &cert_path.to_string_lossy(), "--tls-key", &key_path.to_string_lossy(), ]) .stdout(std::process::Stdio::null()) .stderr(std::process::Stdio::null()) .spawn() .with_context(|| format!("failed to spawn {}", binary.display()))?; let guard = ServerGuard(Some(child)); // Poll until the server is ready. let mut delay = Duration::from_millis(100); let max_wait = Duration::from_secs(3); let start = std::time::Instant::now(); loop { tokio::time::sleep(delay).await; if probe_server(server_addr).await { eprintln!("server ready"); return Ok(guard); } if start.elapsed() > max_wait { anyhow::bail!( "auto-started qpc-server but it did not become ready within {max_wait:?}" ); } delay = (delay * 2).min(Duration::from_secs(1)); } } // ── Helpers ────────────────────────────────────────────────────────────────── /// Build a `ClientConfig` from CLI args. fn build_config(args: &Args) -> anyhow::Result { let server_addr = args .server .parse() .with_context(|| format!("invalid server address: {}", args.server))?; Ok(ClientConfig { server_addr, server_name: args.server_name.clone(), db_path: args.db_path.clone(), db_password: args.db_password.clone(), state_path: args.state.clone(), accept_invalid_certs: args.danger_accept_invalid_certs, ..ClientConfig::default() }) } /// Build, connect, and return a `QpqClient`. Loads identity from state file /// if it exists. async fn connect_client(args: &Args) -> anyhow::Result { let config = build_config(args)?; let mut client = QpqClient::new(config); // Try loading identity from state file. if args.state.exists() { match quicprochat_sdk::state::load_state(&args.state, args.db_password.as_deref()) { Ok(stored) => { let keypair = quicprochat_core::IdentityKeypair::from_seed(stored.identity_seed); client.set_identity_key(keypair.public_key_bytes().to_vec()); } Err(e) => { tracing::debug!("could not load state from {}: {e}", args.state.display()); } } } client.connect().await.context("failed to connect to server")?; Ok(client) } // ── Entry point ────────────────────────────────────────────────────────────── pub fn main() { // Install the rustls crypto provider before any TLS operations. let _ = rustls::crypto::ring::default_provider().install_default(); tracing_subscriber::fmt() .with_env_filter( tracing_subscriber::EnvFilter::try_from_default_env() .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("warn")), ) .init(); let args = Args::parse(); let rt = tokio::runtime::Builder::new_multi_thread() .enable_all() .build() .unwrap_or_else(|e| { eprintln!("fatal: {e}"); std::process::exit(1); }); if let Err(e) = rt.block_on(run(args)) { eprintln!("error: {e:#}"); std::process::exit(1); } } async fn run(args: Args) -> anyhow::Result<()> { // Auto-start server if needed (except for whoami which is local-only). let data_dir = args.state.parent().unwrap_or_else(|| Path::new(".")); let _server_guard = match args.command { Cmd::Whoami => ServerGuard(None), _ => ensure_server_running(&args.server, data_dir, args.no_server).await?, }; match args.command { Cmd::RegisterUser { ref username, ref password, } => { let mut client = connect_client(&args).await?; v2_commands::cmd_register_user(&mut client, username, password) .await .context("register-user failed")?; } Cmd::Login { ref username, ref password, } => { let mut client = connect_client(&args).await?; v2_commands::cmd_login(&mut client, username, password) .await .context("login failed")?; } Cmd::Whoami => { // Whoami is local-only — create client without connecting. let config = build_config(&args)?; let mut client = QpqClient::new(config); if args.state.exists() { match quicprochat_sdk::state::load_state( &args.state, args.db_password.as_deref(), ) { Ok(stored) => { let keypair = quicprochat_core::IdentityKeypair::from_seed(stored.identity_seed); client.set_identity_key(keypair.public_key_bytes().to_vec()); } Err(e) => { eprintln!("warning: could not load state: {e}"); } } } v2_commands::cmd_whoami(&client); } Cmd::Health => { let mut client = connect_client(&args).await?; v2_commands::cmd_health(&mut client) .await .context("health check failed")?; } Cmd::Resolve { ref username } => { let mut client = connect_client(&args).await?; v2_commands::cmd_resolve(&mut client, username) .await .context("resolve failed")?; } Cmd::Dm { ref username } => { let mut client = connect_client(&args).await?; v2_commands::cmd_resolve(&mut client, username) .await .context("dm setup failed")?; // For now, print the resolved key. Full DM creation requires // MLS group state, which will be handled in the REPL flow. println!("(DM creation with full MLS setup is available in the REPL)"); } Cmd::Send { ref to, ref msg } => { let _ = (to, msg); let _client = connect_client(&args).await?; // Full send requires MLS group state restoration — deferred to REPL. println!("(send is currently available in the REPL; one-shot send coming soon)"); } Cmd::Recv { ref from } => { let _ = from; let _client = connect_client(&args).await?; println!("(recv is currently available in the REPL; one-shot recv coming soon)"); } Cmd::Group { action: GroupCmd::Create { ref name }, } => { let _ = name; let _client = connect_client(&args).await?; println!("(group create is currently available in the REPL; one-shot coming soon)"); } Cmd::Group { action: GroupCmd::Invite { ref group, ref user, }, } => { let _ = (group, user); let _client = connect_client(&args).await?; println!("(group invite is currently available in the REPL; one-shot coming soon)"); } Cmd::Devices { action: DevicesCmd::List, } => { let mut client = connect_client(&args).await?; v2_commands::cmd_devices_list(&mut client) .await .context("devices list failed")?; } Cmd::Devices { action: DevicesCmd::Register { ref id, ref name }, } => { let mut client = connect_client(&args).await?; v2_commands::cmd_devices_register(&mut client, id, name) .await .context("device register failed")?; } Cmd::Devices { action: DevicesCmd::Revoke { ref id }, } => { let mut client = connect_client(&args).await?; v2_commands::cmd_devices_revoke(&mut client, id) .await .context("device revoke failed")?; } Cmd::Recovery { action: RecoveryCmd::Setup, } => { let mut client = connect_client(&args).await?; v2_commands::cmd_recovery_setup(&mut client) .await .context("recovery setup failed")?; } Cmd::Recovery { action: RecoveryCmd::Restore { ref code }, } => { let mut client = connect_client(&args).await?; v2_commands::cmd_recovery_restore(&mut client, code) .await .context("recovery restore failed")?; } Cmd::Outbox { action: OutboxCmd::List, } => { let mut client = connect_client(&args).await?; v2_commands::cmd_outbox_list(&client) .context("outbox list failed")?; } Cmd::Outbox { action: OutboxCmd::Retry, } => { let mut client = connect_client(&args).await?; v2_commands::cmd_outbox_retry(&mut client) .await .context("outbox retry failed")?; } Cmd::Outbox { action: OutboxCmd::Clear, } => { let mut client = connect_client(&args).await?; v2_commands::cmd_outbox_clear(&client) .context("outbox clear failed")?; } } Ok(()) }