pub mod commands;
use anyhow::{Context, Result};
use serde::Serialize;
use std::time::Duration;
#[derive(Debug, Clone, Copy)]
pub enum OutputFormat {
Text,
Json,
}
pub struct DaemonClient {
client: reqwest::Client,
base_url: String,
format: OutputFormat,
api_token: Option<String>,
}
impl DaemonClient {
pub fn new(
name: Option<&str>,
api_override: Option<&str>,
format: OutputFormat,
) -> Result<Self> {
let data_dir = dirs::data_dir().context("cannot determine data directory")?;
let dir_name = match name {
Some(n) => format!("x0x-{n}"),
None => "x0x".to_string(),
};
let base_url = if let Some(api) = api_override {
if api.starts_with("http://") || api.starts_with("https://") {
api.to_string()
} else {
format!("http://{api}")
}
} else {
Self::discover_api(name, &data_dir, &dir_name)?
};
let api_token = std::env::var("X0X_API_TOKEN")
.ok()
.filter(|t| !t.is_empty())
.or_else(|| {
let token_path = data_dir.join(&dir_name).join("api-token");
std::fs::read_to_string(&token_path)
.ok()
.map(|t| t.trim().to_string())
.filter(|t| !t.is_empty())
});
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(30))
.build()
.context("failed to create HTTP client")?;
Ok(Self {
client,
base_url,
format,
api_token,
})
}
fn discover_api(
name: Option<&str>,
data_dir: &std::path::Path,
dir_name: &str,
) -> Result<String> {
let port_file = data_dir.join(dir_name).join("api.port");
if port_file.exists() {
let addr = std::fs::read_to_string(&port_file)
.context("failed to read port file")?
.trim()
.to_string();
if !addr.is_empty() {
return Ok(format!("http://{addr}"));
}
}
if let Some(instance_name) = name {
anyhow::bail!(
"Named instance '{instance_name}' is not running. Start it with: x0x --name {instance_name} start"
);
}
Ok("http://127.0.0.1:12700".to_string())
}
fn auth_headers(&self) -> reqwest::header::HeaderMap {
let mut headers = reqwest::header::HeaderMap::new();
if let Some(ref token) = self.api_token {
if let Ok(val) = reqwest::header::HeaderValue::from_str(&format!("Bearer {token}")) {
headers.insert(reqwest::header::AUTHORIZATION, val);
}
}
headers
}
pub async fn ensure_running(&self) -> Result<()> {
let resp = self
.client
.get(format!("{}/health", self.base_url))
.timeout(Duration::from_secs(2))
.send()
.await;
match resp {
Ok(r) if r.status().is_success() => Ok(()),
_ => anyhow::bail!("Daemon is not running. Start it with: x0x start"),
}
}
pub async fn get(&self, path: &str) -> Result<serde_json::Value> {
let resp = self
.client
.get(format!("{}{}", self.base_url, path))
.headers(self.auth_headers())
.send()
.await
.context("request failed — is x0xd running?")?;
self.handle_response(resp).await
}
pub async fn get_query(&self, path: &str, query: &[(&str, &str)]) -> Result<serde_json::Value> {
let resp = self
.client
.get(format!("{}{}", self.base_url, path))
.headers(self.auth_headers())
.query(query)
.send()
.await
.context("request failed")?;
self.handle_response(resp).await
}
pub async fn post<T: Serialize + ?Sized>(
&self,
path: &str,
body: &T,
) -> Result<serde_json::Value> {
let resp = self
.client
.post(format!("{}{}", self.base_url, path))
.headers(self.auth_headers())
.json(body)
.send()
.await
.context("request failed")?;
self.handle_response(resp).await
}
pub async fn post_empty(&self, path: &str) -> Result<serde_json::Value> {
let resp = self
.client
.post(format!("{}{}", self.base_url, path))
.headers(self.auth_headers())
.send()
.await
.context("request failed")?;
self.handle_response(resp).await
}
pub async fn patch<T: Serialize + ?Sized>(
&self,
path: &str,
body: &T,
) -> Result<serde_json::Value> {
let resp = self
.client
.patch(format!("{}{}", self.base_url, path))
.headers(self.auth_headers())
.json(body)
.send()
.await
.context("request failed")?;
self.handle_response(resp).await
}
pub async fn put<T: Serialize + ?Sized>(
&self,
path: &str,
body: &T,
) -> Result<serde_json::Value> {
let resp = self
.client
.put(format!("{}{}", self.base_url, path))
.headers(self.auth_headers())
.json(body)
.send()
.await
.context("request failed")?;
self.handle_response(resp).await
}
pub async fn delete(&self, path: &str) -> Result<serde_json::Value> {
let resp = self
.client
.delete(format!("{}{}", self.base_url, path))
.headers(self.auth_headers())
.send()
.await
.context("request failed")?;
self.handle_response(resp).await
}
pub async fn get_stream(&self, path: &str) -> Result<reqwest::Response> {
let resp = self
.client
.get(format!("{}{}", self.base_url, path))
.headers(self.auth_headers())
.timeout(Duration::from_secs(86400)) .send()
.await
.context("request failed")?;
if !resp.status().is_success() {
let status = resp.status();
let body: serde_json::Value = resp.json().await.unwrap_or_default();
let msg = body
.get("error")
.and_then(|e| e.as_str())
.unwrap_or("unknown error");
anyhow::bail!("{} (HTTP {})", msg, status.as_u16());
}
Ok(resp)
}
async fn handle_response(&self, resp: reqwest::Response) -> Result<serde_json::Value> {
let status = resp.status();
let body: serde_json::Value = resp.json().await.context("failed to parse response")?;
if !status.is_success() {
let msg = body
.get("error")
.and_then(|e| e.as_str())
.unwrap_or("unknown error");
anyhow::bail!("{} (HTTP {})", msg, status.as_u16());
}
Ok(body)
}
pub fn format(&self) -> OutputFormat {
self.format
}
pub fn base_url(&self) -> &str {
&self.base_url
}
}
pub fn print_value(format: OutputFormat, value: &serde_json::Value) {
match format {
OutputFormat::Json => {
if let Ok(s) = serde_json::to_string_pretty(value) {
println!("{s}");
}
}
OutputFormat::Text => {
print_value_text(value, 0);
}
}
}
fn print_value_text(value: &serde_json::Value, indent: usize) {
let pad = " ".repeat(indent);
match value {
serde_json::Value::Object(map) => {
for (key, val) in map {
match val {
serde_json::Value::Object(_) | serde_json::Value::Array(_) => {
println!("{pad}{key}:");
print_value_text(val, indent + 2);
}
_ => {
println!("{pad}{key}: {}", format_scalar(val));
}
}
}
}
serde_json::Value::Array(arr) => {
for item in arr {
print_value_text(item, indent);
if indent == 0 && !arr.is_empty() {
println!();
}
}
}
_ => println!("{pad}{}", format_scalar(value)),
}
}
fn format_scalar(value: &serde_json::Value) -> String {
match value {
serde_json::Value::String(s) => s.clone(),
serde_json::Value::Number(n) => n.to_string(),
serde_json::Value::Bool(b) => b.to_string(),
serde_json::Value::Null => "null".to_string(),
other => other.to_string(),
}
}
pub fn print_error(msg: &str) {
eprintln!("error: {msg}");
}