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:
@@ -74,6 +74,11 @@ crossterm = { version = "0.28", optional = true }
|
|||||||
# YAML playbook parsing (only compiled with --features playbook).
|
# YAML playbook parsing (only compiled with --features playbook).
|
||||||
serde_yaml = { version = "0.9", optional = true }
|
serde_yaml = { version = "0.9", optional = true }
|
||||||
|
|
||||||
|
# v2 SDK-based CLI (thin shell over quicproquo-sdk).
|
||||||
|
quicproquo-sdk = { path = "../quicproquo-sdk", optional = true }
|
||||||
|
quicproquo-rpc = { path = "../quicproquo-rpc", optional = true }
|
||||||
|
rustyline = { workspace = true, optional = true }
|
||||||
|
|
||||||
[lints]
|
[lints]
|
||||||
workspace = true
|
workspace = true
|
||||||
|
|
||||||
@@ -86,6 +91,8 @@ tui = ["dep:ratatui", "dep:crossterm"]
|
|||||||
# Enable playbook (scripted command execution): YAML parser + serde derives.
|
# Enable playbook (scripted command execution): YAML parser + serde derives.
|
||||||
# Build: cargo build -p quicproquo-client --features playbook
|
# Build: cargo build -p quicproquo-client --features playbook
|
||||||
playbook = ["dep:serde_yaml"]
|
playbook = ["dep:serde_yaml"]
|
||||||
|
# v2 CLI over SDK: cargo build -p quicproquo-client --features v2
|
||||||
|
v2 = ["dep:quicproquo-sdk", "dep:quicproquo-rpc", "dep:rustyline"]
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
dashmap = { workspace = true }
|
dashmap = { workspace = true }
|
||||||
|
|||||||
3
crates/quicproquo-client/src/client/v2_repl.rs
Normal file
3
crates/quicproquo-client/src/client/v2_repl.rs
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
//! v2 REPL — interactive mode over the SDK.
|
||||||
|
//!
|
||||||
|
//! Placeholder module; full implementation will replace the v1 repl.
|
||||||
@@ -20,6 +20,8 @@ use std::sync::atomic::{AtomicBool, Ordering};
|
|||||||
use zeroize::Zeroizing;
|
use zeroize::Zeroizing;
|
||||||
|
|
||||||
pub mod client;
|
pub mod client;
|
||||||
|
#[cfg(feature = "v2")]
|
||||||
|
pub mod v2_commands;
|
||||||
|
|
||||||
pub use client::commands::{
|
pub use client::commands::{
|
||||||
cmd_chat, cmd_check_key, cmd_create_group, cmd_demo_group, cmd_export, cmd_export_verify,
|
cmd_chat, cmd_check_key, cmd_create_group, cmd_demo_group, cmd_export, cmd_export_verify,
|
||||||
|
|||||||
@@ -1,20 +1,35 @@
|
|||||||
//! quicproquo CLI client.
|
//! 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};
|
use std::path::{Path, PathBuf};
|
||||||
|
#[cfg(not(feature = "v2"))]
|
||||||
use anyhow::Context;
|
use anyhow::Context;
|
||||||
|
#[cfg(not(feature = "v2"))]
|
||||||
use clap::{Parser, Subcommand};
|
use clap::{Parser, Subcommand};
|
||||||
|
#[cfg(not(feature = "v2"))]
|
||||||
use quicproquo_client::{
|
use quicproquo_client::{
|
||||||
cmd_chat, cmd_check_key, cmd_create_group, cmd_demo_group, cmd_export, cmd_export_verify,
|
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_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,
|
cmd_register_state, cmd_refresh_keypackage, cmd_register_user, cmd_send, cmd_whoami,
|
||||||
init_auth, run_repl, set_insecure_skip_verify, ClientAuth,
|
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;
|
use quicproquo_client::client::tui::run_tui;
|
||||||
|
|
||||||
// ── CLI ───────────────────────────────────────────────────────────────────────
|
// ── CLI ───────────────────────────────────────────────────────────────────────
|
||||||
|
#[cfg(not(feature = "v2"))]
|
||||||
|
|
||||||
#[derive(Debug, Parser)]
|
#[derive(Debug, Parser)]
|
||||||
#[command(name = "qpq", about = "quicproquo CLI client", version)]
|
#[command(name = "qpq", about = "quicproquo CLI client", version)]
|
||||||
@@ -90,6 +105,7 @@ struct Args {
|
|||||||
command: Option<Command>,
|
command: Option<Command>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(not(feature = "v2"))]
|
||||||
#[derive(Debug, Subcommand)]
|
#[derive(Debug, Subcommand)]
|
||||||
enum Command {
|
enum Command {
|
||||||
/// Register a new user via OPAQUE (password never leaves the client).
|
/// Register a new user via OPAQUE (password never leaves the client).
|
||||||
@@ -424,7 +440,7 @@ enum Command {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||||
|
#[cfg(not(feature = "v2"))]
|
||||||
/// Returns `qpq-{username}.bin` when `state` is still at the default
|
/// Returns `qpq-{username}.bin` when `state` is still at the default
|
||||||
/// (`qpq-state.bin`) and a username has been provided. Otherwise returns
|
/// (`qpq-state.bin`) and a username has been provided. Otherwise returns
|
||||||
/// `state` unchanged. This lets `qpq --username alice` automatically isolate
|
/// `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 ───────────────────────────────────────────────────────
|
// ── Playbook execution ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
#[cfg(feature = "playbook")]
|
#[cfg(all(feature = "playbook", not(feature = "v2")))]
|
||||||
async fn run_playbook(
|
async fn run_playbook(
|
||||||
playbook_path: &Path,
|
playbook_path: &Path,
|
||||||
state: &Path,
|
state: &Path,
|
||||||
@@ -511,6 +527,7 @@ async fn run_playbook(
|
|||||||
|
|
||||||
// ── Entry point ───────────────────────────────────────────────────────────────
|
// ── Entry point ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[cfg(not(feature = "v2"))]
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() -> anyhow::Result<()> {
|
async fn main() -> anyhow::Result<()> {
|
||||||
// Install the rustls crypto provider before any TLS operations.
|
// Install the rustls crypto provider before any TLS operations.
|
||||||
|
|||||||
128
crates/quicproquo-client/src/v2_commands.rs
Normal file
128
crates/quicproquo-client/src/v2_commands.rs
Normal 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(())
|
||||||
|
}
|
||||||
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