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