feat(client): v2 CLI binary over SDK with simplified commands
Add v2_main.rs and v2_commands.rs as thin wrappers over QpqClient from quicproquo-sdk. Activated via --features v2 (feature-gated so v1 main.rs remains the default). Includes auto-server-launch, clap-based command surface (register-user, login, whoami, health, resolve, dm, group, devices), and ClientConfig-based connection setup. MLS-dependent commands (send, recv, group create/invite) print stubs pointing to the REPL.
This commit is contained in:
484
crates/quicproquo-client/src/v2_main.rs
Normal file
484
crates/quicproquo-client/src/v2_main.rs
Normal file
@@ -0,0 +1,484 @@
|
||||
//! v2 CLI entry point — thin shell over `quicproquo_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 quicproquo_sdk::client::QpqClient;
|
||||
use quicproquo_sdk::config::ClientConfig;
|
||||
|
||||
use crate::v2_commands;
|
||||
|
||||
// ── CLI ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Parser)]
|
||||
#[command(name = "qpq", about = "quicproquo 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<String>,
|
||||
|
||||
/// Path to the client state file (identity key, MLS state).
|
||||
#[arg(long, global = true, default_value = "qpq-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 qpq-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,
|
||||
},
|
||||
}
|
||||
|
||||
#[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,
|
||||
},
|
||||
}
|
||||
|
||||
// ── Auto-server launch ───────────────────────────────────────────────────────
|
||||
|
||||
/// RAII guard that kills an auto-started server process on drop.
|
||||
struct ServerGuard(Option<std::process::Child>);
|
||||
|
||||
impl Drop for ServerGuard {
|
||||
fn drop(&mut self) {
|
||||
if let Some(ref mut child) = self.0 {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Find the `qpq-server` binary: same directory as current exe, then PATH.
|
||||
fn find_server_binary() -> Option<PathBuf> {
|
||||
if let Ok(exe) = std::env::current_exe() {
|
||||
let sibling = exe.with_file_name("qpq-server");
|
||||
if sibling.exists() {
|
||||
return Some(sibling);
|
||||
}
|
||||
}
|
||||
std::env::var_os("PATH").and_then(|paths| {
|
||||
std::env::split_paths(&paths)
|
||||
.map(|dir| dir.join("qpq-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 qpq-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<ServerGuard> {
|
||||
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 qpq-server binary not found; \
|
||||
start a server manually or install qpq-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 qpq-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<ClientConfig> {
|
||||
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<QpqClient> {
|
||||
let config = build_config(args)?;
|
||||
let mut client = QpqClient::new(config);
|
||||
|
||||
// Try loading identity from state file.
|
||||
if args.state.exists() {
|
||||
match quicproquo_sdk::state::load_state(&args.state, args.db_password.as_deref()) {
|
||||
Ok(stored) => {
|
||||
let keypair = quicproquo_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()
|
||||
.expect("failed to create tokio runtime");
|
||||
|
||||
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 quicproquo_sdk::state::load_state(
|
||||
&args.state,
|
||||
args.db_password.as_deref(),
|
||||
) {
|
||||
Ok(stored) => {
|
||||
let keypair =
|
||||
quicproquo_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")?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
Reference in New Issue
Block a user