feat: add comprehensive CLI with API client, session management, and data seeding

- Binary renamed from 'api' to 'xevion' with full CLI command structure
- Authentication: login/logout with session persistence to .xevion-session
- API commands: projects, tags, settings CRUD operations with JSON/table output
- Serve command: run production server with configurable listen addresses
- Seed command: moved from bin/ to CLI subcommand for database initialization
- HTTP client abstraction supporting both TCP and Unix socket connections
This commit is contained in:
2026-01-13 19:55:45 -06:00
parent b6d377a143
commit aa56d31067
18 changed files with 1849 additions and 243 deletions
+128
View File
@@ -0,0 +1,128 @@
use serde::{Deserialize, Serialize};
use time::{Duration, OffsetDateTime};
use crate::cli::client::{ApiClient, Session, check_response};
use crate::cli::output;
#[derive(Serialize)]
struct LoginRequest {
username: String,
password: String,
}
#[derive(Deserialize)]
struct LoginResponse {
success: bool,
username: String,
}
#[derive(Deserialize)]
struct SessionResponse {
authenticated: bool,
username: String,
}
/// Login and save session
pub async fn login(
mut client: ApiClient,
username: &str,
password: &str,
) -> Result<(), Box<dyn std::error::Error>> {
let request = LoginRequest {
username: username.to_string(),
password: password.to_string(),
};
let response = client.post("/api/login", &request).await?;
// Extract session cookie from response headers
let session_token = response
.headers()
.get_all("set-cookie")
.iter()
.find_map(|v| {
let s = v.to_str().ok()?;
if s.starts_with("admin_session=") {
// Extract just the token value
let token = s.strip_prefix("admin_session=")?.split(';').next()?;
Some(token.to_string())
} else {
None
}
});
let response = check_response(response).await?;
let login_response: LoginResponse = response.json().await?;
if !login_response.success {
output::error("Login failed");
return Ok(());
}
let session_token = session_token.ok_or("No session cookie received")?;
let session = Session {
api_url: client.api_url.clone(),
session_token,
username: login_response.username.clone(),
created_at: OffsetDateTime::now_utc(),
expires_at: OffsetDateTime::now_utc() + Duration::days(7),
};
client.save_session(session)?;
output::success(&format!("Logged in as {}", login_response.username));
Ok(())
}
/// Clear saved session
pub async fn logout(mut client: ApiClient) -> Result<(), Box<dyn std::error::Error>> {
// Try to call logout endpoint if we have a session
if client.is_authenticated() {
let _ = client.post("/api/logout", &()).await;
}
client.clear_session()?;
output::success("Logged out");
Ok(())
}
/// Check current session status
pub async fn session(client: ApiClient, json: bool) -> Result<(), Box<dyn std::error::Error>> {
// First check local session
if let Some(session) = client.session() {
// Verify with server
let response = client.get("/api/session").await?;
let response = check_response(response).await?;
let session_response: SessionResponse = response.json().await?;
if json {
println!(
"{}",
serde_json::json!({
"authenticated": session_response.authenticated,
"username": session_response.username,
"api_url": session.api_url,
"expires_at": session.expires_at.format(&time::format_description::well_known::Rfc3339).unwrap(),
})
);
} else if session_response.authenticated {
output::print_session(&session_response.username, &session.api_url);
} else {
output::error("Session expired or invalid");
}
} else {
if json {
println!(
"{}",
serde_json::json!({
"authenticated": false,
})
);
} else {
output::info("Not logged in");
}
}
Ok(())
}
+21
View File
@@ -0,0 +1,21 @@
pub mod auth;
pub mod projects;
pub mod settings;
pub mod tags;
use crate::cli::client::ApiClient;
use crate::cli::{ApiArgs, ApiCommand};
/// Run an API subcommand
pub async fn run(args: ApiArgs) -> Result<(), Box<dyn std::error::Error>> {
let client = ApiClient::new(args.api_url, args.session);
match args.command {
ApiCommand::Login { username, password } => auth::login(client, &username, &password).await,
ApiCommand::Logout => auth::logout(client).await,
ApiCommand::Session => auth::session(client, args.json).await,
ApiCommand::Projects(cmd) => projects::run(client, cmd, args.json).await,
ApiCommand::Tags(cmd) => tags::run(client, cmd, args.json).await,
ApiCommand::Settings(cmd) => settings::run(client, cmd, args.json).await,
}
}
+363
View File
@@ -0,0 +1,363 @@
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::cli::client::{ApiClient, ApiError, check_response};
use crate::cli::output;
use crate::cli::{ProjectsCommand, TagOp, parse_create_tags, parse_update_tags};
use crate::db::{ApiAdminProject, ApiTag, ProjectStatus};
/// Request to create a project
#[derive(Serialize)]
struct CreateProjectRequest {
name: String,
#[serde(skip_serializing_if = "Option::is_none")]
slug: Option<String>,
short_description: String,
description: String,
status: ProjectStatus,
#[serde(skip_serializing_if = "Option::is_none")]
github_repo: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
demo_url: Option<String>,
tag_ids: Vec<String>,
}
/// Request to update a project
#[derive(Serialize)]
struct UpdateProjectRequest {
name: String,
#[serde(skip_serializing_if = "Option::is_none")]
slug: Option<String>,
short_description: String,
description: String,
status: ProjectStatus,
#[serde(skip_serializing_if = "Option::is_none")]
github_repo: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
demo_url: Option<String>,
tag_ids: Vec<String>,
}
/// Run a projects subcommand
pub async fn run(
client: ApiClient,
command: ProjectsCommand,
json: bool,
) -> Result<(), Box<dyn std::error::Error>> {
match command {
ProjectsCommand::List => list(client, json).await,
ProjectsCommand::Get { reference } => get(client, &reference, json).await,
ProjectsCommand::Create {
name,
short_desc,
desc,
slug,
status,
github_repo,
demo_url,
tags,
} => {
let tag_slugs = tags.map(|s| parse_create_tags(&s)).unwrap_or_default();
create(
client,
&name,
&short_desc,
&desc,
slug,
&status,
github_repo,
demo_url,
tag_slugs,
json,
)
.await
}
ProjectsCommand::Update {
reference,
name,
slug,
short_desc,
desc,
status,
github_repo,
demo_url,
tags,
} => {
let tag_ops = match tags {
Some(s) => parse_update_tags(&s)?,
None => vec![],
};
update(
client,
&reference,
name,
slug,
short_desc,
desc,
status,
github_repo,
demo_url,
tag_ops,
json,
)
.await
}
ProjectsCommand::Delete { reference } => delete(client, &reference, json).await,
}
}
/// List all projects
async fn list(client: ApiClient, json: bool) -> Result<(), Box<dyn std::error::Error>> {
let response = client.get_auth("/api/projects").await?;
let response = check_response(response).await?;
let projects: Vec<ApiAdminProject> = response.json().await?;
if json {
println!("{}", serde_json::to_string_pretty(&projects)?);
} else {
output::print_projects_table(&projects);
}
Ok(())
}
/// Get a project by slug or UUID
async fn get(
client: ApiClient,
reference: &str,
json: bool,
) -> Result<(), Box<dyn std::error::Error>> {
let project = resolve_project(&client, reference).await?;
if json {
println!("{}", serde_json::to_string_pretty(&project)?);
} else {
output::print_project(&project);
}
Ok(())
}
/// Create a new project
#[allow(clippy::too_many_arguments)]
async fn create(
client: ApiClient,
name: &str,
short_desc: &str,
desc: &str,
slug: Option<String>,
status: &str,
github_repo: Option<String>,
demo_url: Option<String>,
tag_slugs: Vec<String>,
json: bool,
) -> Result<(), Box<dyn std::error::Error>> {
// Resolve tag slugs to IDs
let tag_ids = resolve_tag_ids(&client, &tag_slugs).await?;
let status = parse_status(status)?;
let request = CreateProjectRequest {
name: name.to_string(),
slug,
short_description: short_desc.to_string(),
description: desc.to_string(),
status,
github_repo: github_repo.filter(|s| !s.is_empty()),
demo_url: demo_url.filter(|s| !s.is_empty()),
tag_ids,
};
let response = client.post_auth("/api/projects", &request).await?;
let response = check_response(response).await?;
let project: ApiAdminProject = response.json().await?;
if json {
println!("{}", serde_json::to_string_pretty(&project)?);
} else {
output::success(&format!("Created project: {}", project.project.name));
output::print_project(&project);
}
Ok(())
}
/// Update an existing project
#[allow(clippy::too_many_arguments)]
async fn update(
client: ApiClient,
reference: &str,
name: Option<String>,
slug: Option<String>,
short_desc: Option<String>,
desc: Option<String>,
status: Option<String>,
github_repo: Option<String>,
demo_url: Option<String>,
tag_ops: Vec<TagOp>,
json: bool,
) -> Result<(), Box<dyn std::error::Error>> {
// First fetch the current project
let current = resolve_project(&client, reference).await?;
// Apply tag operations
let mut current_tag_ids: Vec<String> = current.tags.iter().map(|t| t.id.clone()).collect();
for op in tag_ops {
match op {
TagOp::Add(slug_or_id) => {
let tag_id = resolve_tag_id(&client, &slug_or_id).await?;
if !current_tag_ids.contains(&tag_id) {
current_tag_ids.push(tag_id);
}
}
TagOp::Remove(slug_or_id) => {
let tag_id = resolve_tag_id(&client, &slug_or_id).await?;
current_tag_ids.retain(|id| id != &tag_id);
}
}
}
// Merge updates with current values
let status = if let Some(s) = status {
parse_status(&s)?
} else {
parse_status(&current.status)?
};
let request = UpdateProjectRequest {
name: name.unwrap_or(current.project.name),
slug,
short_description: short_desc.unwrap_or(current.project.short_description),
description: desc.unwrap_or(current.description),
status,
github_repo: match github_repo {
Some(s) if s.is_empty() => None,
Some(s) => Some(s),
None => current.github_repo,
},
demo_url: match demo_url {
Some(s) if s.is_empty() => None,
Some(s) => Some(s),
None => current.demo_url,
},
tag_ids: current_tag_ids,
};
let response = client
.put_auth(&format!("/api/projects/{}", current.project.id), &request)
.await?;
let response = check_response(response).await?;
let project: ApiAdminProject = response.json().await?;
if json {
println!("{}", serde_json::to_string_pretty(&project)?);
} else {
output::success(&format!("Updated project: {}", project.project.name));
output::print_project(&project);
}
Ok(())
}
/// Delete a project
async fn delete(
client: ApiClient,
reference: &str,
json: bool,
) -> Result<(), Box<dyn std::error::Error>> {
// First resolve to get the ID
let project = resolve_project(&client, reference).await?;
let response = client
.delete_auth(&format!("/api/projects/{}", project.project.id))
.await?;
let response = check_response(response).await?;
let deleted: ApiAdminProject = response.json().await?;
if json {
println!("{}", serde_json::to_string_pretty(&deleted)?);
} else {
output::success(&format!("Deleted project: {}", deleted.project.name));
}
Ok(())
}
/// Resolve a project reference (slug or UUID) to a full project
async fn resolve_project(
client: &ApiClient,
reference: &str,
) -> Result<ApiAdminProject, Box<dyn std::error::Error>> {
// Try as UUID first
if Uuid::parse_str(reference).is_ok() {
let response = client
.get_auth(&format!("/api/projects/{}", reference))
.await?;
let response = check_response(response).await?;
return Ok(response.json().await?);
}
// Otherwise search by slug in the list
let response = client.get_auth("/api/projects").await?;
let response = check_response(response).await?;
let projects: Vec<ApiAdminProject> = response.json().await?;
projects
.into_iter()
.find(|p| p.project.slug == reference)
.ok_or_else(|| ApiError::Parse(format!("Project not found: {}", reference)).into())
}
/// Resolve tag slugs to IDs
async fn resolve_tag_ids(
client: &ApiClient,
slugs: &[String],
) -> Result<Vec<String>, Box<dyn std::error::Error>> {
if slugs.is_empty() {
return Ok(vec![]);
}
let mut ids = Vec::new();
for slug in slugs {
ids.push(resolve_tag_id(client, slug).await?);
}
Ok(ids)
}
/// Resolve a single tag slug or ID to an ID
async fn resolve_tag_id(
client: &ApiClient,
slug_or_id: &str,
) -> Result<String, Box<dyn std::error::Error>> {
// Try as UUID first
if Uuid::parse_str(slug_or_id).is_ok() {
return Ok(slug_or_id.to_string());
}
// Otherwise look up by slug
#[derive(Deserialize)]
struct TagResponse {
tag: ApiTag,
}
let response = client.get(&format!("/api/tags/{}", slug_or_id)).await?;
let response = check_response(response).await?;
let tag_response: TagResponse = response.json().await?;
Ok(tag_response.tag.id)
}
/// Parse status string to ProjectStatus
fn parse_status(s: &str) -> Result<ProjectStatus, Box<dyn std::error::Error>> {
match s.to_lowercase().as_str() {
"active" => Ok(ProjectStatus::Active),
"maintained" => Ok(ProjectStatus::Maintained),
"archived" => Ok(ProjectStatus::Archived),
"hidden" => Ok(ProjectStatus::Hidden),
_ => Err(format!(
"Invalid status '{}'. Valid values: active, maintained, archived, hidden",
s
)
.into()),
}
}
+88
View File
@@ -0,0 +1,88 @@
use crate::cli::SettingsCommand;
use crate::cli::client::{ApiClient, check_response};
use crate::cli::output;
use crate::db::{ApiSiteSettings, UpdateSiteIdentityRequest, UpdateSiteSettingsRequest};
/// Run a settings subcommand
pub async fn run(
client: ApiClient,
command: SettingsCommand,
json: bool,
) -> Result<(), Box<dyn std::error::Error>> {
match command {
SettingsCommand::Get => get(client, json).await,
SettingsCommand::Update {
display_name,
occupation,
bio,
site_title,
} => update(client, display_name, occupation, bio, site_title, json).await,
}
}
/// Get current site settings
async fn get(client: ApiClient, json: bool) -> Result<(), Box<dyn std::error::Error>> {
let response = client.get("/api/settings").await?;
let response = check_response(response).await?;
let settings: ApiSiteSettings = response.json().await?;
if json {
println!("{}", serde_json::to_string_pretty(&settings)?);
} else {
output::print_settings(&settings);
}
Ok(())
}
/// Update site settings
async fn update(
client: ApiClient,
display_name: Option<String>,
occupation: Option<String>,
bio: Option<String>,
site_title: Option<String>,
json: bool,
) -> Result<(), Box<dyn std::error::Error>> {
// First fetch current settings
let response = client.get("/api/settings").await?;
let response = check_response(response).await?;
let current: ApiSiteSettings = response.json().await?;
// Merge updates
let request = UpdateSiteSettingsRequest {
identity: UpdateSiteIdentityRequest {
display_name: display_name.unwrap_or(current.identity.display_name),
occupation: occupation.unwrap_or(current.identity.occupation),
bio: bio.unwrap_or(current.identity.bio),
site_title: site_title.unwrap_or(current.identity.site_title),
},
// Keep existing social links unchanged
social_links: current
.social_links
.into_iter()
.map(|link| crate::db::UpdateSocialLinkRequest {
id: link.id,
platform: link.platform,
label: link.label,
value: link.value,
icon: link.icon,
visible: link.visible,
display_order: link.display_order,
})
.collect(),
};
let response = client.put_auth("/api/settings", &request).await?;
let response = check_response(response).await?;
let settings: ApiSiteSettings = response.json().await?;
if json {
println!("{}", serde_json::to_string_pretty(&settings)?);
} else {
output::success("Updated site settings");
output::print_settings(&settings);
}
Ok(())
}
+172
View File
@@ -0,0 +1,172 @@
use serde::Deserialize;
use crate::cli::TagsCommand;
use crate::cli::client::{ApiClient, check_response};
use crate::cli::output;
use crate::db::{ApiTag, ApiTagWithCount};
use crate::handlers::{CreateTagRequest, UpdateTagRequest};
/// Response for get tag endpoint
#[derive(Deserialize)]
struct GetTagResponse {
tag: ApiTag,
projects: Vec<serde_json::Value>,
}
/// Run a tags subcommand
pub async fn run(
client: ApiClient,
command: TagsCommand,
json: bool,
) -> Result<(), Box<dyn std::error::Error>> {
match command {
TagsCommand::List => list(client, json).await,
TagsCommand::Get { slug } => get(client, &slug, json).await,
TagsCommand::Create {
name,
slug,
icon,
color,
} => create(client, &name, slug, icon, color, json).await,
TagsCommand::Update {
slug,
name,
new_slug,
icon,
color,
} => update(client, &slug, name, new_slug, icon, color, json).await,
}
}
/// List all tags
async fn list(client: ApiClient, json: bool) -> Result<(), Box<dyn std::error::Error>> {
let response = client.get("/api/tags").await?;
let response = check_response(response).await?;
let tags: Vec<ApiTagWithCount> = response.json().await?;
if json {
println!("{}", serde_json::to_string_pretty(&tags)?);
} else {
output::print_tags_table(&tags);
}
Ok(())
}
/// Get a tag by slug
async fn get(client: ApiClient, slug: &str, json: bool) -> Result<(), Box<dyn std::error::Error>> {
let response = client.get(&format!("/api/tags/{}", slug)).await?;
let response = check_response(response).await?;
let tag_response: GetTagResponse = response.json().await?;
if json {
println!(
"{}",
serde_json::to_string_pretty(&serde_json::json!({
"tag": tag_response.tag,
"projects": tag_response.projects,
}))?
);
} else {
output::print_tag(&tag_response.tag);
if !tag_response.projects.is_empty() {
output::info(&format!(
"{} associated project(s)",
tag_response.projects.len()
));
}
}
Ok(())
}
/// Create a new tag
async fn create(
client: ApiClient,
name: &str,
slug: Option<String>,
icon: Option<String>,
color: Option<String>,
json: bool,
) -> Result<(), Box<dyn std::error::Error>> {
// Validate color if provided
if let Some(ref c) = color {
if !c.chars().all(|ch| ch.is_ascii_hexdigit()) || c.len() != 6 {
return Err("Color must be a 6-character hex string (e.g., '3b82f6')".into());
}
}
let request = CreateTagRequest {
name: name.to_string(),
slug,
icon: icon.filter(|s| !s.is_empty()),
color: color.filter(|s| !s.is_empty()),
};
let response = client.post_auth("/api/tags", &request).await?;
let response = check_response(response).await?;
let tag: ApiTag = response.json().await?;
if json {
println!("{}", serde_json::to_string_pretty(&tag)?);
} else {
output::success(&format!("Created tag: {}", tag.name));
output::print_tag(&tag);
}
Ok(())
}
/// Update an existing tag
async fn update(
client: ApiClient,
slug: &str,
name: Option<String>,
new_slug: Option<String>,
icon: Option<String>,
color: Option<String>,
json: bool,
) -> Result<(), Box<dyn std::error::Error>> {
// Validate color if provided
if let Some(ref c) = color {
if !c.is_empty() && (!c.chars().all(|ch| ch.is_ascii_hexdigit()) || c.len() != 6) {
return Err("Color must be a 6-character hex string (e.g., '3b82f6')".into());
}
}
// First fetch the current tag
let response = client.get(&format!("/api/tags/{}", slug)).await?;
let response = check_response(response).await?;
let current: GetTagResponse = response.json().await?;
// Merge updates
let request = UpdateTagRequest {
name: name.unwrap_or(current.tag.name),
slug: new_slug,
icon: match icon {
Some(s) if s.is_empty() => None,
Some(s) => Some(s),
None => current.tag.icon,
},
color: match color {
Some(s) if s.is_empty() => None,
Some(s) => Some(s),
None => current.tag.color,
},
};
let response = client
.put_auth(&format!("/api/tags/{}", slug), &request)
.await?;
let response = check_response(response).await?;
let tag: ApiTag = response.json().await?;
if json {
println!("{}", serde_json::to_string_pretty(&tag)?);
} else {
output::success(&format!("Updated tag: {}", tag.name));
output::print_tag(&tag);
}
Ok(())
}
+226
View File
@@ -0,0 +1,226 @@
use reqwest::{Client, Response, StatusCode};
use serde::{Deserialize, Serialize};
use std::path::Path;
use time::OffsetDateTime;
/// Session data stored in the session file
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Session {
pub api_url: String,
pub session_token: String,
pub username: String,
#[serde(with = "time::serde::rfc3339")]
pub created_at: OffsetDateTime,
#[serde(with = "time::serde::rfc3339")]
pub expires_at: OffsetDateTime,
}
/// API client with session management
pub struct ApiClient {
pub api_url: String,
session_path: String,
session: Option<Session>,
client: Client,
}
#[derive(Debug)]
pub enum ApiError {
/// HTTP request failed
Request(reqwest::Error),
/// Server returned an error response
Http { status: StatusCode, body: String },
/// Failed to parse response
Parse(String),
/// Session file error
Session(String),
/// Not authenticated
Unauthorized,
}
impl std::fmt::Display for ApiError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ApiError::Request(e) => write!(f, "Request failed: {}", e),
ApiError::Http { status, body } => {
write!(f, "HTTP {}: {}", status, body)
}
ApiError::Parse(msg) => write!(f, "Parse error: {}", msg),
ApiError::Session(msg) => write!(f, "Session error: {}", msg),
ApiError::Unauthorized => write!(f, "Not authenticated. Run 'xevion api login' first."),
}
}
}
impl std::error::Error for ApiError {}
impl From<reqwest::Error> for ApiError {
fn from(e: reqwest::Error) -> Self {
ApiError::Request(e)
}
}
impl ApiClient {
/// Create a new API client
pub fn new(api_url: String, session_path: String) -> Self {
let session = Self::load_session_from_path(&session_path);
Self {
api_url,
session_path,
session,
client: Client::new(),
}
}
fn load_session_from_path(path: &str) -> Option<Session> {
let path = Path::new(path);
if !path.exists() {
return None;
}
let content = std::fs::read_to_string(path).ok()?;
let session: Session = serde_json::from_str(&content).ok()?;
// Check if session is expired
if session.expires_at < OffsetDateTime::now_utc() {
return None;
}
Some(session)
}
/// Save session to file
pub fn save_session(&mut self, session: Session) -> Result<(), ApiError> {
let content = serde_json::to_string_pretty(&session)
.map_err(|e| ApiError::Session(format!("Failed to serialize session: {}", e)))?;
std::fs::write(&self.session_path, content)
.map_err(|e| ApiError::Session(format!("Failed to write session file: {}", e)))?;
self.session = Some(session);
Ok(())
}
/// Clear the session
pub fn clear_session(&mut self) -> Result<(), ApiError> {
let path = Path::new(&self.session_path);
if path.exists() {
std::fs::remove_file(path)
.map_err(|e| ApiError::Session(format!("Failed to remove session file: {}", e)))?;
}
self.session = None;
Ok(())
}
/// Get current session if valid
pub fn session(&self) -> Option<&Session> {
self.session.as_ref()
}
/// Check if we have a valid session
pub fn is_authenticated(&self) -> bool {
self.session.is_some()
}
/// Build the full URL for an endpoint
fn url(&self, path: &str) -> String {
format!("{}{}", self.api_url, path)
}
/// Make a GET request
pub async fn get(&self, path: &str) -> Result<Response, ApiError> {
let mut request = self.client.get(self.url(path));
if let Some(session) = &self.session {
request = request.header("Cookie", format!("admin_session={}", session.session_token));
}
let response = request.send().await?;
Ok(response)
}
/// Make an authenticated GET request (fails if not authenticated)
pub async fn get_auth(&self, path: &str) -> Result<Response, ApiError> {
if !self.is_authenticated() {
return Err(ApiError::Unauthorized);
}
self.get(path).await
}
/// Make a POST request with JSON body
pub async fn post<T: Serialize>(&self, path: &str, body: &T) -> Result<Response, ApiError> {
let mut request = self.client.post(self.url(path)).json(body);
if let Some(session) = &self.session {
request = request.header("Cookie", format!("admin_session={}", session.session_token));
}
let response = request.send().await?;
Ok(response)
}
/// Make an authenticated POST request
pub async fn post_auth<T: Serialize>(
&self,
path: &str,
body: &T,
) -> Result<Response, ApiError> {
if !self.is_authenticated() {
return Err(ApiError::Unauthorized);
}
self.post(path, body).await
}
/// Make a PUT request with JSON body
pub async fn put<T: Serialize>(&self, path: &str, body: &T) -> Result<Response, ApiError> {
let mut request = self.client.put(self.url(path)).json(body);
if let Some(session) = &self.session {
request = request.header("Cookie", format!("admin_session={}", session.session_token));
}
let response = request.send().await?;
Ok(response)
}
/// Make an authenticated PUT request
pub async fn put_auth<T: Serialize>(&self, path: &str, body: &T) -> Result<Response, ApiError> {
if !self.is_authenticated() {
return Err(ApiError::Unauthorized);
}
self.put(path, body).await
}
/// Make a DELETE request
pub async fn delete(&self, path: &str) -> Result<Response, ApiError> {
let mut request = self.client.delete(self.url(path));
if let Some(session) = &self.session {
request = request.header("Cookie", format!("admin_session={}", session.session_token));
}
let response = request.send().await?;
Ok(response)
}
/// Make an authenticated DELETE request
pub async fn delete_auth(&self, path: &str) -> Result<Response, ApiError> {
if !self.is_authenticated() {
return Err(ApiError::Unauthorized);
}
self.delete(path).await
}
}
/// Helper to check response and extract error message
pub async fn check_response(response: Response) -> Result<Response, ApiError> {
let status = response.status();
if status.is_success() {
Ok(response)
} else {
let body = response
.text()
.await
.unwrap_or_else(|_| "Unknown error".to_string());
Err(ApiError::Http { status, body })
}
}
+301
View File
@@ -0,0 +1,301 @@
pub mod api;
pub mod client;
pub mod output;
pub mod seed;
pub mod serve;
use clap::{Parser, Subcommand};
use crate::config::ListenAddr;
/// xevion.dev - Personal portfolio server and API client
#[derive(Parser, Debug)]
#[command(name = "xevion")]
#[command(about = "Personal portfolio server with API client for content management")]
#[command(version)]
pub struct Cli {
#[command(subcommand)]
pub command: Option<Command>,
/// Address(es) to listen on (TCP or Unix socket)
#[arg(long, env = "LISTEN_ADDR", value_delimiter = ',')]
pub listen: Vec<ListenAddr>,
/// Downstream SSR server URL
#[arg(long, env = "DOWNSTREAM_URL")]
pub downstream: Option<String>,
/// Trust X-Request-ID header from specified source
#[arg(long, env = "TRUST_REQUEST_ID")]
pub trust_request_id: Option<String>,
}
#[derive(Subcommand, Debug)]
pub enum Command {
/// Seed the database with sample data
Seed,
/// API client for managing content remotely
Api(ApiArgs),
}
#[derive(Parser, Debug)]
pub struct ApiArgs {
/// API base URL
#[arg(long, env = "API_BASE_URL", default_value = "http://localhost:8080")]
pub api_url: String,
/// Session file path
#[arg(long, env = "XEVION_SESSION", default_value = ".xevion-session")]
pub session: String,
/// Output raw JSON instead of formatted text
#[arg(long, global = true)]
pub json: bool,
#[command(subcommand)]
pub command: ApiCommand,
}
#[derive(Subcommand, Debug)]
pub enum ApiCommand {
/// Login and save session
Login {
/// Username
username: String,
/// Password
password: String,
},
/// Clear saved session
Logout,
/// Check current session status
Session,
/// Project management
#[command(subcommand)]
Projects(ProjectsCommand),
/// Tag management
#[command(subcommand)]
Tags(TagsCommand),
/// Site settings management
#[command(subcommand)]
Settings(SettingsCommand),
}
#[derive(Subcommand, Debug)]
pub enum ProjectsCommand {
/// List all projects
List,
/// Get project details by slug or UUID
Get {
/// Project slug or UUID
#[arg(name = "ref")]
reference: String,
},
/// Create a new project
Create {
/// Project name
name: String,
/// Short description
#[arg(short = 's', long)]
short_desc: String,
/// Full description
#[arg(short = 'd', long)]
desc: String,
/// URL slug (auto-generated from name if omitted)
#[arg(long)]
slug: Option<String>,
/// Project status
#[arg(long, default_value = "active")]
status: String,
/// GitHub repository (e.g., "Xevion/xevion.dev")
#[arg(long)]
github_repo: Option<String>,
/// Demo URL
#[arg(long)]
demo_url: Option<String>,
/// Tags to add (comma-separated, + prefix optional)
#[arg(short = 't', long)]
tags: Option<String>,
},
/// Update an existing project
Update {
/// Project slug or UUID
#[arg(name = "ref")]
reference: String,
/// Project name
#[arg(short = 'n', long)]
name: Option<String>,
/// URL slug
#[arg(long)]
slug: Option<String>,
/// Short description
#[arg(short = 's', long)]
short_desc: Option<String>,
/// Full description
#[arg(short = 'd', long)]
desc: Option<String>,
/// Project status
#[arg(long)]
status: Option<String>,
/// GitHub repository (use "" to clear)
#[arg(long)]
github_repo: Option<String>,
/// Demo URL (use "" to clear)
#[arg(long)]
demo_url: Option<String>,
/// Tag changes: +tag to add, -tag to remove (comma-separated)
#[arg(short = 't', long)]
tags: Option<String>,
},
/// Delete a project
Delete {
/// Project slug or UUID
#[arg(name = "ref")]
reference: String,
},
}
#[derive(Subcommand, Debug)]
pub enum TagsCommand {
/// List all tags with project counts
List,
/// Get tag details with associated projects
Get {
/// Tag slug
slug: String,
},
/// Create a new tag
Create {
/// Tag name
name: String,
/// URL slug (auto-generated from name if omitted)
#[arg(long)]
slug: Option<String>,
/// Icon identifier (e.g., "simple-icons:rust")
#[arg(long)]
icon: Option<String>,
/// Color hex without # (e.g., "3b82f6")
#[arg(long)]
color: Option<String>,
},
/// Update an existing tag
Update {
/// Tag slug
slug: String,
/// Tag name
#[arg(short = 'n', long)]
name: Option<String>,
/// New URL slug
#[arg(long = "new-slug")]
new_slug: Option<String>,
/// Icon identifier (use "" to clear)
#[arg(long)]
icon: Option<String>,
/// Color hex (use "" to clear)
#[arg(long)]
color: Option<String>,
},
}
#[derive(Subcommand, Debug)]
pub enum SettingsCommand {
/// Get current site settings
Get,
/// Update site settings
Update {
/// Display name
#[arg(long)]
display_name: Option<String>,
/// Occupation/title
#[arg(long)]
occupation: Option<String>,
/// Bio text
#[arg(long)]
bio: Option<String>,
/// Site title
#[arg(long)]
site_title: Option<String>,
},
}
/// Tag operation for updates
#[derive(Debug, Clone)]
pub enum TagOp {
Add(String),
Remove(String),
}
/// Parse tags for create command (+ prefix optional)
pub fn parse_create_tags(s: &str) -> Vec<String> {
s.split(',')
.map(|t| t.trim())
.filter(|t| !t.is_empty())
.map(|t| t.strip_prefix('+').unwrap_or(t).to_string())
.collect()
}
/// Parse tags for update command (+ or - prefix required)
pub fn parse_update_tags(s: &str) -> Result<Vec<TagOp>, String> {
s.split(',')
.map(|t| t.trim())
.filter(|t| !t.is_empty())
.map(|t| {
if let Some(tag) = t.strip_prefix('+') {
if tag.is_empty() {
Err("Tag name cannot be empty after '+'".to_string())
} else {
Ok(TagOp::Add(tag.to_string()))
}
} else if let Some(tag) = t.strip_prefix('-') {
if tag.is_empty() {
Err("Tag name cannot be empty after '-'".to_string())
} else {
Ok(TagOp::Remove(tag.to_string()))
}
} else {
Err(format!(
"Tag '{}' requires prefix: use +{} to add, -{} to remove",
t, t, t
))
}
})
.collect()
}
+242
View File
@@ -0,0 +1,242 @@
use nu_ansi_term::{Color, Style};
use crate::db::{ApiAdminProject, ApiSiteSettings, ApiTag, ApiTagWithCount};
/// Print a success message
pub fn success(msg: &str) {
println!("{} {}", Color::Green.paint(""), msg);
}
/// Print an error message
pub fn error(msg: &str) {
eprintln!("{} {}", Color::Red.paint(""), msg);
}
/// Print an info message
pub fn info(msg: &str) {
println!("{} {}", Color::Blue.paint(""), msg);
}
/// Print a project in formatted output
pub fn print_project(project: &ApiAdminProject) {
let header = Style::new().bold();
let dim = Style::new().dimmed();
println!("{}", header.paint(&project.project.name));
println!(" {} {}", dim.paint("ID:"), project.project.id);
println!(" {} {}", dim.paint("Slug:"), project.project.slug);
println!(
" {} {}",
dim.paint("Status:"),
format_status(&project.status)
);
println!(
" {} {}",
dim.paint("Description:"),
project.project.short_description
);
if let Some(ref repo) = project.github_repo {
println!(" {} {}", dim.paint("GitHub:"), repo);
}
if let Some(ref url) = project.demo_url {
println!(" {} {}", dim.paint("Demo:"), url);
}
if !project.tags.is_empty() {
let tags: Vec<_> = project.tags.iter().map(|t| t.slug.as_str()).collect();
println!(" {} {}", dim.paint("Tags:"), tags.join(", "));
}
println!(" {} {}", dim.paint("Updated:"), project.updated_at);
}
/// Print a list of projects in table format
pub fn print_projects_table(projects: &[ApiAdminProject]) {
if projects.is_empty() {
info("No projects found");
return;
}
let header = Style::new().bold().underline();
let dim = Style::new().dimmed();
// Calculate column widths
let name_width = projects
.iter()
.map(|p| p.project.name.len())
.max()
.unwrap_or(4)
.max(4);
let slug_width = projects
.iter()
.map(|p| p.project.slug.len())
.max()
.unwrap_or(4)
.max(4);
// Header
println!(
"{:name_width$} {:slug_width$} {:10} {}",
header.paint("NAME"),
header.paint("SLUG"),
header.paint("STATUS"),
header.paint("TAGS"),
);
// Rows
for project in projects {
let tags: Vec<_> = project.tags.iter().map(|t| t.slug.as_str()).collect();
let tags_str = if tags.is_empty() {
dim.paint("-").to_string()
} else {
tags.join(", ")
};
println!(
"{:name_width$} {:slug_width$} {:10} {}",
project.project.name,
dim.paint(&project.project.slug),
format_status(&project.status),
tags_str,
);
}
println!();
info(&format!("{} project(s)", projects.len()));
}
/// Print a tag in formatted output
pub fn print_tag(tag: &ApiTag) {
let header = Style::new().bold();
let dim = Style::new().dimmed();
println!("{}", header.paint(&tag.name));
println!(" {} {}", dim.paint("ID:"), tag.id);
println!(" {} {}", dim.paint("Slug:"), tag.slug);
if let Some(ref icon) = tag.icon {
println!(" {} {}", dim.paint("Icon:"), icon);
}
if let Some(ref color) = tag.color {
println!(" {} #{}", dim.paint("Color:"), color);
}
}
/// Print a list of tags in table format
pub fn print_tags_table(tags: &[ApiTagWithCount]) {
if tags.is_empty() {
info("No tags found");
return;
}
let header = Style::new().bold().underline();
let dim = Style::new().dimmed();
// Calculate column widths
let name_width = tags
.iter()
.map(|t| t.tag.name.len())
.max()
.unwrap_or(4)
.max(4);
let slug_width = tags
.iter()
.map(|t| t.tag.slug.len())
.max()
.unwrap_or(4)
.max(4);
// Header
println!(
"{:name_width$} {:slug_width$} {:8} {:20} {}",
header.paint("NAME"),
header.paint("SLUG"),
header.paint("PROJECTS"),
header.paint("ICON"),
header.paint("COLOR"),
);
// Rows
for tag in tags {
let icon = tag.tag.icon.as_deref().unwrap_or("-");
let color = tag
.tag
.color
.as_ref()
.map(|c| format!("#{}", c))
.unwrap_or_else(|| "-".to_string());
println!(
"{:name_width$} {:slug_width$} {:8} {:20} {}",
tag.tag.name,
dim.paint(&tag.tag.slug),
tag.project_count,
dim.paint(icon),
dim.paint(&color),
);
}
println!();
info(&format!("{} tag(s)", tags.len()));
}
/// Print site settings in formatted output
pub fn print_settings(settings: &ApiSiteSettings) {
let header = Style::new().bold();
let dim = Style::new().dimmed();
println!("{}", header.paint("Site Identity"));
println!(
" {} {}",
dim.paint("Display Name:"),
settings.identity.display_name
);
println!(
" {} {}",
dim.paint("Occupation:"),
settings.identity.occupation
);
println!(" {} {}", dim.paint("Bio:"), settings.identity.bio);
println!(
" {} {}",
dim.paint("Site Title:"),
settings.identity.site_title
);
if !settings.social_links.is_empty() {
println!();
println!("{}", header.paint("Social Links"));
for link in &settings.social_links {
let visibility = if link.visible { "" } else { " (hidden)" };
println!(
" {} {}: {}{}",
dim.paint(format!("[{}]", link.display_order)),
link.label,
link.value,
dim.paint(visibility)
);
}
}
}
/// Format project status with color
fn format_status(status: &str) -> String {
match status {
"active" => Color::Green.paint(status).to_string(),
"maintained" => Color::Blue.paint(status).to_string(),
"archived" => Color::Yellow.paint(status).to_string(),
"hidden" => Color::Red.paint(status).to_string(),
_ => status.to_string(),
}
}
/// Print session info
pub fn print_session(username: &str, api_url: &str) {
let dim = Style::new().dimmed();
success(&format!(
"Logged in as {}",
Style::new().bold().paint(username)
));
println!(" {} {}", dim.paint("API:"), api_url);
}
+204
View File
@@ -0,0 +1,204 @@
use sqlx::PgPool;
/// Seed the database with sample data
pub async fn run(pool: &PgPool) -> Result<(), Box<dyn std::error::Error>> {
println!("Seeding database...");
// Clear existing data (tags will cascade delete project_tags and tag_cooccurrence)
sqlx::query("DELETE FROM tags").execute(pool).await?;
sqlx::query("DELETE FROM projects").execute(pool).await?;
// Seed projects with diverse data
let projects = vec![
(
"xevion-dev",
"xevion.dev",
"Personal portfolio and project showcase",
"Personal portfolio site with fuzzy tag discovery and ISR caching",
"active",
Some("Xevion/xevion.dev"),
None,
),
(
"contest",
"Contest",
"Competitive programming archive",
"Archive and analysis platform for competitive programming problems",
"active",
Some("Xevion/contest"),
Some("https://contest.xevion.dev"),
),
(
"reforge",
"Reforge",
"Rocket League replay parser",
"Rust library for parsing and manipulating Replay files from Rocket League",
"maintained",
Some("Xevion/reforge"),
None,
),
(
"algorithms",
"Algorithms",
"Algorithm implementations in Python",
"Collection of algorithm implementations and data structures in Python",
"archived",
Some("Xevion/algorithms"),
None,
),
(
"wordplay",
"WordPlay",
"Real-time multiplayer word game",
"Interactive word game with real-time multiplayer using WebSockets",
"maintained",
Some("Xevion/wordplay"),
Some("https://wordplay.example.com"),
),
(
"dotfiles",
"Dotfiles",
"Development environment configs",
"Personal configuration files and development environment setup scripts",
"active",
Some("Xevion/dotfiles"),
None,
),
];
let project_count = projects.len();
for (slug, name, short_desc, desc, status, repo, demo) in projects {
sqlx::query(
r#"
INSERT INTO projects (slug, name, short_description, description, status, github_repo, demo_url)
VALUES ($1, $2, $3, $4, $5::project_status, $6, $7)
"#,
)
.bind(slug)
.bind(name)
.bind(short_desc)
.bind(desc)
.bind(status)
.bind(repo)
.bind(demo)
.execute(pool)
.await?;
}
println!(" Seeded {} projects", project_count);
// Seed tags
let tags = vec![
("rust", "Rust", "simple-icons:rust"),
("python", "Python", "simple-icons:python"),
("typescript", "TypeScript", "simple-icons:typescript"),
("javascript", "JavaScript", "simple-icons:javascript"),
("web", "Web", "lucide:globe"),
("cli", "CLI", "lucide:terminal"),
("library", "Library", "lucide:package"),
("game", "Game", "lucide:gamepad-2"),
("data-structures", "Data Structures", "lucide:database"),
("algorithms", "Algorithms", "lucide:cpu"),
("multiplayer", "Multiplayer", "lucide:users"),
("config", "Config", "lucide:settings"),
];
let mut tag_ids = std::collections::HashMap::new();
for (slug, name, icon) in tags {
let result = sqlx::query!(
r#"
INSERT INTO tags (slug, name, icon)
VALUES ($1, $2, $3)
RETURNING id
"#,
slug,
name,
icon
)
.fetch_one(pool)
.await?;
tag_ids.insert(slug, result.id);
}
println!(" Seeded {} tags", tag_ids.len());
// Associate tags with projects
let project_tag_associations = vec![
// xevion-dev
("xevion-dev", vec!["rust", "web", "typescript"]),
// Contest
(
"contest",
vec!["python", "web", "algorithms", "data-structures"],
),
// Reforge
("reforge", vec!["rust", "library", "game"]),
// Algorithms
(
"algorithms",
vec!["python", "algorithms", "data-structures"],
),
// WordPlay
(
"wordplay",
vec!["typescript", "javascript", "web", "game", "multiplayer"],
),
// Dotfiles
("dotfiles", vec!["config", "cli"]),
];
let mut association_count = 0;
for (project_slug, tag_slugs) in project_tag_associations {
let project_id = sqlx::query!("SELECT id FROM projects WHERE slug = $1", project_slug)
.fetch_one(pool)
.await?
.id;
for tag_slug in tag_slugs {
if let Some(&tag_id) = tag_ids.get(tag_slug) {
sqlx::query!(
"INSERT INTO project_tags (project_id, tag_id) VALUES ($1, $2)",
project_id,
tag_id
)
.execute(pool)
.await?;
association_count += 1;
}
}
}
println!(" Created {} project-tag associations", association_count);
// Recalculate tag cooccurrence
sqlx::query!("DELETE FROM tag_cooccurrence")
.execute(pool)
.await?;
sqlx::query!(
r#"
INSERT INTO tag_cooccurrence (tag_a, tag_b, count)
SELECT
LEAST(t1.tag_id, t2.tag_id) as tag_a,
GREATEST(t1.tag_id, t2.tag_id) as tag_b,
COUNT(*)::int as count
FROM project_tags t1
JOIN project_tags t2 ON t1.project_id = t2.project_id
WHERE t1.tag_id < t2.tag_id
GROUP BY tag_a, tag_b
HAVING COUNT(*) > 0
"#
)
.execute(pool)
.await?;
println!(" Recalculated tag cooccurrence");
println!("Database seeded successfully!");
Ok(())
}
+226
View File
@@ -0,0 +1,226 @@
use std::collections::HashSet;
use std::net::SocketAddr;
use std::sync::Arc;
use std::time::Duration;
use tower_http::{cors::CorsLayer, limit::RequestBodyLimitLayer};
use crate::cache::{IsrCache, IsrCacheConfig};
use crate::config::ListenAddr;
use crate::middleware::RequestIdLayer;
use crate::state::AppState;
use crate::tarpit::{TarpitConfig, TarpitState};
use crate::{auth, db, health, http, og, proxy, routes};
/// Run the web server
pub async fn run(
listen: Vec<ListenAddr>,
downstream: String,
trust_request_id: Option<String>,
) -> Result<(), Box<dyn std::error::Error>> {
// Load database URL from environment (fail-fast)
let database_url =
std::env::var("DATABASE_URL").expect("DATABASE_URL must be set in environment");
// Create connection pool
let pool = db::create_pool(&database_url)
.await
.expect("Failed to connect to database");
// Check and run migrations on startup
let migrator = sqlx::migrate!();
// Query applied migrations directly from the database
let applied_versions: HashSet<i64> =
sqlx::query_scalar::<_, i64>("SELECT version FROM _sqlx_migrations ORDER BY version")
.fetch_all(&pool)
.await
.unwrap_or_default()
.into_iter()
.collect();
let pending: Vec<_> = migrator
.iter()
.filter(|m| !m.migration_type.is_down_migration())
.filter(|m| !applied_versions.contains(&m.version))
.map(|m| m.description.as_ref())
.collect();
if pending.is_empty() {
let last_version = applied_versions.iter().max();
let last_name = last_version
.and_then(|v| migrator.iter().find(|m| m.version == *v))
.map(|m| m.description.as_ref());
tracing::debug!(last_migration = ?last_name, "Database schema is current");
} else {
tracing::warn!(migrations = ?pending, "Pending database migrations");
}
migrator.run(&pool).await.unwrap_or_else(|e| {
tracing::error!(error = %e, "Migration failed");
std::process::exit(1);
});
if !pending.is_empty() {
tracing::info!(count = pending.len(), "Migrations applied");
}
// Ensure admin user exists
auth::ensure_admin_user(&pool)
.await
.expect("Failed to ensure admin user exists");
// Initialize session manager
let session_manager = Arc::new(
auth::SessionManager::new(pool.clone())
.await
.expect("Failed to initialize session manager"),
);
// Spawn background task to cleanup expired sessions
tokio::spawn({
let session_manager = session_manager.clone();
async move {
let mut interval = tokio::time::interval(Duration::from_secs(3600)); // Every hour
loop {
interval.tick().await;
if let Err(e) = session_manager.cleanup_expired().await {
tracing::error!(error = %e, "Failed to cleanup expired sessions");
}
}
}
});
if listen.is_empty() {
eprintln!("Error: At least one --listen address is required");
std::process::exit(1);
}
// Create socket-aware HTTP client
let client = http::HttpClient::new(&downstream).expect("Failed to create HTTP client");
// Create health checker
let client_for_health = client.clone();
let pool_for_health = pool.clone();
let health_checker = Arc::new(health::HealthChecker::new(move || {
let client = client_for_health.clone();
let pool = pool_for_health.clone();
async move { proxy::perform_health_check(client, Some(pool)).await }
}));
let tarpit_config = TarpitConfig::from_env();
let tarpit_state = Arc::new(TarpitState::new(tarpit_config));
tracing::info!(
enabled = tarpit_state.config.enabled,
delay_min_ms = tarpit_state.config.delay_min_ms,
delay_max_ms = tarpit_state.config.delay_max_ms,
max_global = tarpit_state.config.max_global_connections,
max_per_ip = tarpit_state.config.max_connections_per_ip,
"Tarpit initialized"
);
// Initialize ISR cache
let isr_cache_config = IsrCacheConfig::from_env();
let isr_cache = Arc::new(IsrCache::new(isr_cache_config.clone()));
tracing::info!(
enabled = isr_cache_config.enabled,
max_entries = isr_cache_config.max_entries,
fresh_sec = isr_cache_config.fresh_duration.as_secs(),
stale_sec = isr_cache_config.stale_duration.as_secs(),
"ISR cache initialized"
);
let state = Arc::new(AppState {
client,
health_checker,
tarpit_state,
pool: pool.clone(),
session_manager: session_manager.clone(),
isr_cache,
});
// Regenerate common OGP images on startup
tokio::spawn({
let state = state.clone();
async move {
og::regenerate_common_images(state).await;
}
});
// Apply middleware to router
fn apply_middleware(
router: axum::Router<Arc<AppState>>,
trust_request_id: Option<String>,
) -> axum::Router<Arc<AppState>> {
router
.layer(RequestIdLayer::new(trust_request_id))
.layer(CorsLayer::permissive())
.layer(RequestBodyLimitLayer::new(1_048_576))
}
let mut tasks = Vec::new();
for listen_addr in &listen {
let state = state.clone();
let trust_request_id = trust_request_id.clone();
let listen_addr = listen_addr.clone();
let task = tokio::spawn(async move {
match listen_addr {
ListenAddr::Tcp(addr) => {
let app = apply_middleware(
routes::build_base_router().fallback(proxy::fallback_handler_tcp),
trust_request_id,
)
.with_state(state);
let listener = tokio::net::TcpListener::bind(addr)
.await
.expect("Failed to bind TCP listener");
let url = if addr.is_ipv6() {
format!("http://[{}]:{}", addr.ip(), addr.port())
} else {
format!("http://{}:{}", addr.ip(), addr.port())
};
tracing::info!(url, "Listening on TCP");
axum::serve(
listener,
app.into_make_service_with_connect_info::<SocketAddr>(),
)
.await
.expect("Server error on TCP listener");
}
ListenAddr::Unix(path) => {
let app = apply_middleware(
routes::build_base_router().fallback(proxy::fallback_handler_unix),
trust_request_id,
)
.with_state(state);
let _ = std::fs::remove_file(&path);
let listener = tokio::net::UnixListener::bind(&path)
.expect("Failed to bind Unix socket listener");
tracing::info!(socket = %path.display(), "Listening on Unix socket");
axum::serve(listener, app)
.await
.expect("Server error on Unix socket listener");
}
}
});
tasks.push(task);
}
for task in tasks {
task.await.expect("Listener task panicked");
}
Ok(())
}