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:
2026-03-04 12:46:42 +01:00
parent 4d62a837a5
commit 029c701780
6 changed files with 646 additions and 5 deletions

View File

@@ -0,0 +1,3 @@
//! v2 REPL — interactive mode over the SDK.
//!
//! Placeholder module; full implementation will replace the v1 repl.

View File

@@ -20,6 +20,8 @@ use std::sync::atomic::{AtomicBool, Ordering};
use zeroize::Zeroizing;
pub mod client;
#[cfg(feature = "v2")]
pub mod v2_commands;
pub use client::commands::{
cmd_chat, cmd_check_key, cmd_create_group, cmd_demo_group, cmd_export, cmd_export_verify,

View File

@@ -1,20 +1,35 @@
//! quicproquo CLI client.
// ── v2 feature gate: when compiled with --features v2, use the SDK-based CLI.
#[cfg(feature = "v2")]
mod v2_commands;
#[cfg(feature = "v2")]
mod v2_main;
#[cfg(feature = "v2")]
fn main() {
v2_main::main();
}
// ── v1 CLI (default) ─────────────────────────────────────────────────────────
#[cfg(not(feature = "v2"))]
use std::path::{Path, PathBuf};
#[cfg(not(feature = "v2"))]
use anyhow::Context;
#[cfg(not(feature = "v2"))]
use clap::{Parser, Subcommand};
#[cfg(not(feature = "v2"))]
use quicproquo_client::{
cmd_chat, cmd_check_key, cmd_create_group, cmd_demo_group, cmd_export, cmd_export_verify,
cmd_fetch_key, cmd_health, cmd_invite, cmd_join, cmd_login, cmd_ping, cmd_recv, cmd_register,
cmd_register_state, cmd_refresh_keypackage, cmd_register_user, cmd_send, cmd_whoami,
init_auth, run_repl, set_insecure_skip_verify, ClientAuth,
};
#[cfg(feature = "tui")]
#[cfg(all(feature = "tui", not(feature = "v2")))]
use quicproquo_client::client::tui::run_tui;
// ── CLI ───────────────────────────────────────────────────────────────────────
#[cfg(not(feature = "v2"))]
#[derive(Debug, Parser)]
#[command(name = "qpq", about = "quicproquo CLI client", version)]
@@ -90,6 +105,7 @@ struct Args {
command: Option<Command>,
}
#[cfg(not(feature = "v2"))]
#[derive(Debug, Subcommand)]
enum Command {
/// Register a new user via OPAQUE (password never leaves the client).
@@ -424,7 +440,7 @@ enum Command {
}
// ── Helpers ───────────────────────────────────────────────────────────────────
#[cfg(not(feature = "v2"))]
/// Returns `qpq-{username}.bin` when `state` is still at the default
/// (`qpq-state.bin`) and a username has been provided. Otherwise returns
/// `state` unchanged. This lets `qpq --username alice` automatically isolate
@@ -440,7 +456,7 @@ fn derive_state_path(state: PathBuf, username: Option<&str>) -> PathBuf {
// ── Playbook execution ───────────────────────────────────────────────────────
#[cfg(feature = "playbook")]
#[cfg(all(feature = "playbook", not(feature = "v2")))]
async fn run_playbook(
playbook_path: &Path,
state: &Path,
@@ -511,6 +527,7 @@ async fn run_playbook(
// ── Entry point ───────────────────────────────────────────────────────────────
#[cfg(not(feature = "v2"))]
#[tokio::main]
async fn main() -> anyhow::Result<()> {
// Install the rustls crypto provider before any TLS operations.

View File

@@ -0,0 +1,128 @@
//! v2 CLI command implementations — thin wrappers over the SDK.
use quicproquo_sdk::client::QpqClient;
use quicproquo_sdk::error::SdkError;
/// Register a new user account via OPAQUE.
pub async fn cmd_register_user(
client: &mut QpqClient,
username: &str,
password: &str,
) -> Result<(), SdkError> {
client.register(username, password).await?;
let key = client.identity_key().unwrap_or_default();
println!("registered user: {username}");
println!("identity key : {}", hex::encode(key));
Ok(())
}
/// Log in via OPAQUE and print session info.
pub async fn cmd_login(
client: &mut QpqClient,
username: &str,
password: &str,
) -> Result<(), SdkError> {
client.login(username, password).await?;
println!("logged in as: {username}");
if let Some(key) = client.identity_key() {
println!("identity key: {}", hex::encode(key));
}
Ok(())
}
/// Print local identity information.
pub fn cmd_whoami(client: &QpqClient) {
match client.username() {
Some(u) => println!("username : {u}"),
None => println!("username : (not logged in)"),
}
match client.identity_key() {
Some(k) => println!("identity key: {}", hex::encode(k)),
None => println!("identity key: (none)"),
}
println!("connected : {}", client.is_connected());
println!("authenticated: {}", client.is_authenticated());
}
/// Health check — connect to the server and report status.
pub async fn cmd_health(client: &mut QpqClient) -> Result<(), SdkError> {
let start = std::time::Instant::now();
// The SDK connect() already establishes a QUIC connection.
// If we're already connected, just report success.
if !client.is_connected() {
client.connect().await?;
}
let rtt_ms = start.elapsed().as_millis();
println!("status : ok");
println!("rtt : {rtt_ms}ms");
Ok(())
}
/// Resolve a username to its identity key.
pub async fn cmd_resolve(client: &mut QpqClient, username: &str) -> Result<(), SdkError> {
let rpc = client.rpc()?;
match quicproquo_sdk::users::resolve_user(rpc, username).await? {
Some(key) => {
println!("{username} -> {}", hex::encode(&key));
}
None => {
println!("{username}: not found");
}
}
Ok(())
}
/// List registered devices.
pub async fn cmd_devices_list(client: &mut QpqClient) -> Result<(), SdkError> {
let rpc = client.rpc()?;
let devices = quicproquo_sdk::devices::list_devices(rpc).await?;
if devices.is_empty() {
println!("no devices registered");
} else {
println!("{:<36} {:<20} {}", "DEVICE ID", "NAME", "REGISTERED AT");
for d in &devices {
println!(
"{:<36} {:<20} {}",
hex::encode(&d.device_id),
d.device_name,
d.registered_at,
);
}
}
Ok(())
}
/// Register a new device.
pub async fn cmd_devices_register(
client: &mut QpqClient,
device_id: &str,
device_name: &str,
) -> Result<(), SdkError> {
let rpc = client.rpc()?;
let id_bytes = hex::decode(device_id)
.map_err(|e| SdkError::Other(anyhow::anyhow!("invalid device_id hex: {e}")))?;
let was_new = quicproquo_sdk::devices::register_device(rpc, &id_bytes, device_name).await?;
if was_new {
println!("device registered: {device_name}");
} else {
println!("device already registered: {device_name}");
}
Ok(())
}
/// Revoke a device.
pub async fn cmd_devices_revoke(
client: &mut QpqClient,
device_id: &str,
) -> Result<(), SdkError> {
let rpc = client.rpc()?;
let id_bytes = hex::decode(device_id)
.map_err(|e| SdkError::Other(anyhow::anyhow!("invalid device_id hex: {e}")))?;
let revoked = quicproquo_sdk::devices::revoke_device(rpc, &id_bytes).await?;
if revoked {
println!("device revoked: {device_id}");
} else {
println!("device not found: {device_id}");
}
Ok(())
}

View 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(())
}