Skip to main content

auths_cli/commands/
status.rs

1//! Status overview command for Auths.
2
3use crate::ux::format::{JsonResponse, Output, is_json_mode};
4use anyhow::{Result, anyhow};
5use auths_id::storage::attestation::AttestationSource;
6use auths_id::storage::identity::IdentityStorage;
7use auths_id::storage::layout;
8use auths_storage::git::{RegistryAttestationStorage, RegistryIdentityStorage};
9use chrono::{DateTime, Duration, Utc};
10use clap::Parser;
11use serde::Serialize;
12use std::fs;
13use std::path::PathBuf;
14
15#[cfg(unix)]
16use nix::sys::signal;
17#[cfg(unix)]
18use nix::unistd::Pid;
19
20/// Show identity and agent status overview.
21#[derive(Parser, Debug, Clone)]
22#[command(name = "status", about = "Show identity and agent status overview")]
23pub struct StatusCommand {}
24
25/// Full status report.
26#[derive(Debug, Serialize)]
27pub struct StatusReport {
28    pub identity: Option<IdentityStatus>,
29    pub agent: AgentStatusInfo,
30    pub devices: DevicesSummary,
31}
32
33/// Identity status information.
34#[derive(Debug, Serialize)]
35pub struct IdentityStatus {
36    pub controller_did: String,
37    #[serde(skip_serializing_if = "Option::is_none")]
38    pub alias: Option<String>,
39}
40
41/// Agent status information.
42#[derive(Debug, Serialize)]
43pub struct AgentStatusInfo {
44    pub running: bool,
45    #[serde(skip_serializing_if = "Option::is_none")]
46    pub pid: Option<u32>,
47    #[serde(skip_serializing_if = "Option::is_none")]
48    pub socket_path: Option<String>,
49}
50
51/// Devices summary.
52#[derive(Debug, Serialize)]
53pub struct DevicesSummary {
54    pub linked: usize,
55    pub revoked: usize,
56    pub expiring_soon: Vec<ExpiringDevice>,
57    pub devices_detail: Vec<DeviceStatus>,
58}
59
60/// Per-device status for expiry display.
61#[derive(Debug, Serialize)]
62pub struct DeviceStatus {
63    pub device_did: String,
64    pub revoked_at: Option<chrono::DateTime<chrono::Utc>>,
65    pub expires_at: Option<DateTime<Utc>>,
66}
67
68/// Device that is expiring soon.
69#[derive(Debug, Serialize)]
70pub struct ExpiringDevice {
71    pub device_did: String,
72    pub expires_in_days: i64,
73}
74
75/// Handle the status command.
76pub fn handle_status(_cmd: StatusCommand, repo: Option<PathBuf>) -> Result<()> {
77    // Determine repository path
78    let repo_path = resolve_repo_path(repo)?;
79
80    // Load identity
81    let identity = load_identity_status(&repo_path);
82
83    // Get agent status
84    let agent = get_agent_status();
85
86    // Load device attestations summary
87    let devices = load_devices_summary(&repo_path);
88
89    let report = StatusReport {
90        identity,
91        agent,
92        devices,
93    };
94
95    if is_json_mode() {
96        JsonResponse::success("status", report).print()?;
97    } else {
98        print_status(&report);
99    }
100
101    Ok(())
102}
103
104/// Print status in human-readable format.
105fn print_status(report: &StatusReport) {
106    let out = Output::new();
107
108    // Identity
109    if let Some(ref id) = report.identity {
110        let did_display = truncate_did(&id.controller_did, 40);
111        out.println(&format!("Identity:   {}", out.info(&did_display)));
112        if let Some(ref alias) = id.alias {
113            out.println(&format!("Alias:      {}", alias));
114        }
115    } else {
116        out.println(&format!("Identity:   {}", out.dim("not initialized")));
117    }
118
119    // Agent
120    if report.agent.running {
121        let pid_str = report
122            .agent
123            .pid
124            .map(|p| format!("pid {}", p))
125            .unwrap_or_default();
126        let socket_str = report
127            .agent
128            .socket_path
129            .as_ref()
130            .map(|s| format!(", socket {}", s))
131            .unwrap_or_default();
132        out.println(&format!(
133            "Agent:      {} ({}{})",
134            out.success("running"),
135            pid_str,
136            socket_str
137        ));
138    } else {
139        out.println(&format!("Agent:      {}", out.warn("stopped")));
140    }
141
142    // Devices
143    let mut parts = Vec::new();
144    if report.devices.linked > 0 {
145        parts.push(format!("{} linked", report.devices.linked));
146    }
147    if report.devices.revoked > 0 {
148        parts.push(format!("{} revoked", report.devices.revoked));
149    }
150    if !report.devices.expiring_soon.is_empty() {
151        let expiring_count = report.devices.expiring_soon.len();
152        let min_days = report
153            .devices
154            .expiring_soon
155            .iter()
156            .map(|e| e.expires_in_days)
157            .min()
158            .unwrap_or(0);
159        if min_days == 0 {
160            parts.push(format!("{} expiring today", expiring_count));
161        } else if min_days == 1 {
162            parts.push(format!("{} expiring in 1 day", expiring_count));
163        } else {
164            parts.push(format!("{} expiring in {} days", expiring_count, min_days));
165        }
166    }
167
168    if parts.is_empty() {
169        out.println(&format!("Devices:    {}", out.dim("none")));
170    } else {
171        out.println(&format!("Devices:    {}", parts.join(", ")));
172    }
173
174    // Per-device expiry detail
175    if !report.devices.devices_detail.is_empty() {
176        out.newline();
177        let now = Utc::now();
178        for device in &report.devices.devices_detail {
179            if device.revoked_at.is_some() {
180                continue;
181            }
182            let did_display = truncate_did(&device.device_did, 40);
183            out.println(&format!("  {}", out.dim(&did_display)));
184            display_device_expiry(device.expires_at, &out, now);
185        }
186    }
187}
188
189/// Display color-coded device expiry information.
190fn display_device_expiry(expires_at: Option<DateTime<Utc>>, out: &Output, now: DateTime<Utc>) {
191    let Some(expires_at) = expires_at else {
192        out.println(&format!("  Expires: {}", out.info("never")));
193        return;
194    };
195
196    let remaining = expires_at - now;
197    let days = remaining.num_days();
198
199    let (label, color_fn): (&str, fn(&Output, &str) -> String) = match days {
200        d if d < 0 => ("EXPIRED", Output::error),
201        0..=6 => ("expiring soon", Output::warn),
202        7..=29 => ("expiring", Output::warn),
203        _ => ("active", Output::success),
204    };
205
206    let display = format!(
207        "{} ({}, {}d remaining)",
208        expires_at.format("%Y-%m-%d"),
209        label,
210        days
211    );
212    out.println(&format!("  Expires: {}", color_fn(out, &display)));
213
214    if (0..=7).contains(&days) {
215        out.print_warn("  Run `auths device extend` to renew.");
216    }
217}
218
219/// Load identity status from the repository.
220fn load_identity_status(repo_path: &PathBuf) -> Option<IdentityStatus> {
221    if crate::factories::storage::open_git_repo(repo_path).is_err() {
222        return None;
223    }
224
225    let storage = RegistryIdentityStorage::new(repo_path);
226    match storage.load_identity() {
227        Ok(identity) => Some(IdentityStatus {
228            controller_did: identity.controller_did.to_string(),
229            alias: None, // Would need to look up from keychain
230        }),
231        Err(_) => None,
232    }
233}
234
235/// Get agent status by checking PID file and socket.
236fn get_agent_status() -> AgentStatusInfo {
237    let auths_dir = match get_auths_dir() {
238        Ok(dir) => dir,
239        Err(_) => {
240            return AgentStatusInfo {
241                running: false,
242                pid: None,
243                socket_path: None,
244            };
245        }
246    };
247
248    let pid_path = auths_dir.join("agent.pid");
249    let socket_path = auths_dir.join("agent.sock");
250
251    // Read PID file
252    let pid = fs::read_to_string(&pid_path)
253        .ok()
254        .and_then(|content| content.trim().parse::<u32>().ok());
255
256    // Check if process is running
257    let running = pid.map(is_process_running).unwrap_or(false);
258    let socket_exists = socket_path.exists();
259
260    AgentStatusInfo {
261        running: running && socket_exists,
262        pid: if running { pid } else { None },
263        socket_path: if socket_exists && running {
264            Some(socket_path.to_string_lossy().to_string())
265        } else {
266            None
267        },
268    }
269}
270
271/// Load devices summary from attestations.
272fn load_devices_summary(repo_path: &PathBuf) -> DevicesSummary {
273    if crate::factories::storage::open_git_repo(repo_path).is_err() {
274        return DevicesSummary {
275            linked: 0,
276            revoked: 0,
277            expiring_soon: Vec::new(),
278            devices_detail: Vec::new(),
279        };
280    }
281
282    let storage = RegistryAttestationStorage::new(repo_path);
283    let attestations = match storage.load_all_attestations() {
284        Ok(a) => a,
285        Err(_) => {
286            return DevicesSummary {
287                linked: 0,
288                revoked: 0,
289                expiring_soon: Vec::new(),
290                devices_detail: Vec::new(),
291            };
292        }
293    };
294
295    // Group by device and get latest attestation per device
296    let mut latest_by_device: std::collections::HashMap<
297        String,
298        &auths_verifier::core::Attestation,
299    > = std::collections::HashMap::new();
300
301    for att in &attestations {
302        let key = att.subject.as_str().to_string();
303        latest_by_device
304            .entry(key)
305            .and_modify(|existing| {
306                // Keep the one with later timestamp
307                if att.timestamp > existing.timestamp {
308                    *existing = att;
309                }
310            })
311            .or_insert(att);
312    }
313
314    let now = Utc::now();
315    let threshold = now + Duration::days(7);
316    let mut linked = 0;
317    let mut revoked = 0;
318    let mut expiring_soon = Vec::new();
319    let mut devices_detail = Vec::new();
320
321    for (device_did, att) in &latest_by_device {
322        devices_detail.push(DeviceStatus {
323            device_did: device_did.clone(),
324            revoked_at: att.revoked_at,
325            expires_at: att.expires_at,
326        });
327
328        if att.is_revoked() {
329            revoked += 1;
330        } else {
331            linked += 1;
332            // Check if expiring soon
333            if let Some(expires_at) = att.expires_at
334                && expires_at <= threshold
335                && expires_at > now
336            {
337                let days_left = (expires_at - now).num_days();
338                expiring_soon.push(ExpiringDevice {
339                    device_did: device_did.clone(),
340                    expires_in_days: days_left,
341                });
342            }
343        }
344    }
345
346    // Sort expiring devices by days remaining
347    expiring_soon.sort_by_key(|e| e.expires_in_days);
348
349    DevicesSummary {
350        linked,
351        revoked,
352        expiring_soon,
353        devices_detail,
354    }
355}
356
357/// Get the auths directory path (~/.auths), respecting AUTHS_HOME.
358fn get_auths_dir() -> Result<PathBuf> {
359    auths_core::paths::auths_home().map_err(|e| anyhow!(e))
360}
361
362/// Resolve the repository path from optional argument or default (~/.auths).
363fn resolve_repo_path(repo_arg: Option<PathBuf>) -> Result<PathBuf> {
364    layout::resolve_repo_path(repo_arg).map_err(|e| anyhow!(e))
365}
366
367/// Check if a process with the given PID is running.
368#[cfg(unix)]
369fn is_process_running(pid: u32) -> bool {
370    signal::kill(Pid::from_raw(pid as i32), None).is_ok()
371}
372
373#[cfg(not(unix))]
374fn is_process_running(_pid: u32) -> bool {
375    false
376}
377
378/// Truncate a DID for display.
379fn truncate_did(did: &str, max_len: usize) -> String {
380    if did.len() <= max_len {
381        did.to_string()
382    } else {
383        format!("{}...", &did[..max_len - 3])
384    }
385}
386
387impl crate::commands::executable::ExecutableCommand for StatusCommand {
388    fn execute(&self, ctx: &crate::config::CliConfig) -> anyhow::Result<()> {
389        handle_status(self.clone(), ctx.repo_path.clone())
390    }
391}
392
393#[cfg(test)]
394mod tests {
395    use super::*;
396
397    #[test]
398    fn test_truncate_did_short() {
399        let did = "did:key:z6Mk";
400        assert_eq!(truncate_did(did, 20), did);
401    }
402
403    #[test]
404    fn test_truncate_did_long() {
405        let did = "did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK";
406        let truncated = truncate_did(did, 24);
407        assert!(truncated.ends_with("..."));
408        assert_eq!(truncated.len(), 24);
409    }
410
411    #[test]
412    fn test_get_auths_dir() {
413        let dir = get_auths_dir().unwrap();
414        assert!(dir.ends_with(".auths"));
415    }
416}