use crate::ux::format::{JsonResponse, Output, is_json_mode};
use anyhow::{Result, anyhow};
use auths_id::storage::attestation::AttestationSource;
use auths_id::storage::identity::IdentityStorage;
use auths_id::storage::layout;
use auths_storage::git::{RegistryAttestationStorage, RegistryIdentityStorage};
use chrono::{DateTime, Duration, Utc};
use clap::Parser;
use serde::Serialize;
use std::fs;
use std::path::PathBuf;
#[cfg(unix)]
use nix::sys::signal;
#[cfg(unix)]
use nix::unistd::Pid;
#[derive(Parser, Debug, Clone)]
#[command(name = "status", about = "Show identity and agent status overview")]
pub struct StatusCommand {}
#[derive(Debug, Serialize)]
pub struct StatusReport {
pub identity: Option<IdentityStatus>,
pub agent: AgentStatusInfo,
pub devices: DevicesSummary,
}
#[derive(Debug, Serialize)]
pub struct IdentityStatus {
pub controller_did: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub alias: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct AgentStatusInfo {
pub running: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub pid: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub socket_path: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct DevicesSummary {
pub linked: usize,
pub revoked: usize,
pub expiring_soon: Vec<ExpiringDevice>,
pub devices_detail: Vec<DeviceStatus>,
}
#[derive(Debug, Serialize)]
pub struct DeviceStatus {
pub device_did: String,
pub revoked_at: Option<chrono::DateTime<chrono::Utc>>,
pub expires_at: Option<DateTime<Utc>>,
}
#[derive(Debug, Serialize)]
pub struct ExpiringDevice {
pub device_did: String,
pub expires_in_days: i64,
}
pub fn handle_status(_cmd: StatusCommand, repo: Option<PathBuf>) -> Result<()> {
let repo_path = resolve_repo_path(repo)?;
let identity = load_identity_status(&repo_path);
let agent = get_agent_status();
let devices = load_devices_summary(&repo_path);
let report = StatusReport {
identity,
agent,
devices,
};
if is_json_mode() {
JsonResponse::success("status", report).print()?;
} else {
print_status(&report);
}
Ok(())
}
fn print_status(report: &StatusReport) {
let out = Output::new();
if let Some(ref id) = report.identity {
out.println(&format!("Identity: {}", out.info(&id.controller_did)));
if let Some(ref alias) = id.alias {
out.println(&format!("Alias: {}", alias));
}
} else {
out.println(&format!("Identity: {}", out.dim("not initialized")));
}
if report.agent.running {
let pid_str = report
.agent
.pid
.map(|p| format!("pid {}", p))
.unwrap_or_default();
let socket_str = report
.agent
.socket_path
.as_ref()
.map(|s| format!(", socket {}", s))
.unwrap_or_default();
out.println(&format!(
"Agent: {} ({}{})",
out.success("running"),
pid_str,
socket_str
));
} else {
out.println(&format!("Agent: {}", out.warn("stopped")));
}
let mut parts = Vec::new();
if report.devices.linked > 0 {
parts.push(format!("{} linked", report.devices.linked));
}
if report.devices.revoked > 0 {
parts.push(format!("{} revoked", report.devices.revoked));
}
if !report.devices.expiring_soon.is_empty() {
let expiring_count = report.devices.expiring_soon.len();
let min_days = report
.devices
.expiring_soon
.iter()
.map(|e| e.expires_in_days)
.min()
.unwrap_or(0);
if min_days == 0 {
parts.push(format!("{} expiring today", expiring_count));
} else if min_days == 1 {
parts.push(format!("{} expiring in 1 day", expiring_count));
} else {
parts.push(format!("{} expiring in {} days", expiring_count, min_days));
}
}
if parts.is_empty() {
out.println(&format!("Devices: {}", out.dim("none")));
} else {
out.println(&format!("Devices: {}", parts.join(", ")));
}
if !report.devices.devices_detail.is_empty() {
out.newline();
let now = Utc::now();
for device in &report.devices.devices_detail {
if device.revoked_at.is_some() {
continue;
}
out.println(&format!(" {}", out.dim(&device.device_did)));
display_device_expiry(device.expires_at, &out, now);
}
}
}
fn display_device_expiry(expires_at: Option<DateTime<Utc>>, out: &Output, now: DateTime<Utc>) {
let Some(expires_at) = expires_at else {
out.println(&format!(" Expires: {}", out.info("never")));
return;
};
let remaining = expires_at - now;
let days = remaining.num_days();
let (label, color_fn): (&str, fn(&Output, &str) -> String) = match days {
d if d < 0 => ("EXPIRED", Output::error),
0..=6 => ("expiring soon", Output::warn),
7..=29 => ("expiring", Output::warn),
_ => ("active", Output::success),
};
let display = format!(
"{} ({}, {}d remaining)",
expires_at.format("%Y-%m-%d"),
label,
days
);
out.println(&format!(" Expires: {}", color_fn(out, &display)));
if (0..=7).contains(&days) {
out.print_warn(" Run `auths device extend` to renew.");
}
}
fn load_identity_status(repo_path: &PathBuf) -> Option<IdentityStatus> {
if crate::factories::storage::open_git_repo(repo_path).is_err() {
return None;
}
let storage = RegistryIdentityStorage::new(repo_path);
match storage.load_identity() {
Ok(identity) => Some(IdentityStatus {
controller_did: identity.controller_did.to_string(),
alias: None, }),
Err(_) => None,
}
}
fn get_agent_status() -> AgentStatusInfo {
let auths_dir = match get_auths_dir() {
Ok(dir) => dir,
Err(_) => {
return AgentStatusInfo {
running: false,
pid: None,
socket_path: None,
};
}
};
let pid_path = auths_dir.join("agent.pid");
let socket_path = auths_dir.join("agent.sock");
let pid = fs::read_to_string(&pid_path)
.ok()
.and_then(|content| content.trim().parse::<u32>().ok());
let running = pid.map(is_process_running).unwrap_or(false);
let socket_exists = socket_path.exists();
AgentStatusInfo {
running: running && socket_exists,
pid: if running { pid } else { None },
socket_path: if socket_exists && running {
Some(socket_path.to_string_lossy().to_string())
} else {
None
},
}
}
fn load_devices_summary(repo_path: &PathBuf) -> DevicesSummary {
if crate::factories::storage::open_git_repo(repo_path).is_err() {
return DevicesSummary {
linked: 0,
revoked: 0,
expiring_soon: Vec::new(),
devices_detail: Vec::new(),
};
}
let storage = RegistryAttestationStorage::new(repo_path);
let attestations = match storage.load_all_attestations() {
Ok(a) => a,
Err(_) => {
return DevicesSummary {
linked: 0,
revoked: 0,
expiring_soon: Vec::new(),
devices_detail: Vec::new(),
};
}
};
let mut latest_by_device: std::collections::HashMap<
String,
&auths_verifier::core::Attestation,
> = std::collections::HashMap::new();
for att in &attestations {
let key = att.subject.as_str().to_string();
latest_by_device
.entry(key)
.and_modify(|existing| {
if att.timestamp > existing.timestamp {
*existing = att;
}
})
.or_insert(att);
}
let now = Utc::now();
let threshold = now + Duration::days(7);
let mut linked = 0;
let mut revoked = 0;
let mut expiring_soon = Vec::new();
let mut devices_detail = Vec::new();
for (device_did, att) in &latest_by_device {
devices_detail.push(DeviceStatus {
device_did: device_did.clone(),
revoked_at: att.revoked_at,
expires_at: att.expires_at,
});
if att.is_revoked() {
revoked += 1;
} else {
linked += 1;
if let Some(expires_at) = att.expires_at
&& expires_at <= threshold
&& expires_at > now
{
let days_left = (expires_at - now).num_days();
expiring_soon.push(ExpiringDevice {
device_did: device_did.clone(),
expires_in_days: days_left,
});
}
}
}
expiring_soon.sort_by_key(|e| e.expires_in_days);
DevicesSummary {
linked,
revoked,
expiring_soon,
devices_detail,
}
}
fn get_auths_dir() -> Result<PathBuf> {
auths_core::paths::auths_home().map_err(|e| anyhow!(e))
}
fn resolve_repo_path(repo_arg: Option<PathBuf>) -> Result<PathBuf> {
layout::resolve_repo_path(repo_arg).map_err(|e| anyhow!(e))
}
#[cfg(unix)]
fn is_process_running(pid: u32) -> bool {
signal::kill(Pid::from_raw(pid as i32), None).is_ok()
}
#[cfg(not(unix))]
fn is_process_running(_pid: u32) -> bool {
false
}
impl crate::commands::executable::ExecutableCommand for StatusCommand {
fn execute(&self, ctx: &crate::config::CliConfig) -> anyhow::Result<()> {
handle_status(self.clone(), ctx.repo_path.clone())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_get_auths_dir() {
let dir = get_auths_dir().unwrap();
assert!(dir.ends_with(".auths"));
}
}