1use 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#[derive(Parser, Debug, Clone)]
22#[command(name = "status", about = "Show identity and agent status overview")]
23pub struct StatusCommand {}
24
25#[derive(Debug, Serialize)]
27pub struct StatusReport {
28 pub identity: Option<IdentityStatus>,
29 pub agent: AgentStatusInfo,
30 pub devices: DevicesSummary,
31}
32
33#[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#[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#[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#[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#[derive(Debug, Serialize)]
70pub struct ExpiringDevice {
71 pub device_did: String,
72 pub expires_in_days: i64,
73}
74
75pub fn handle_status(_cmd: StatusCommand, repo: Option<PathBuf>) -> Result<()> {
77 let repo_path = resolve_repo_path(repo)?;
79
80 let identity = load_identity_status(&repo_path);
82
83 let agent = get_agent_status();
85
86 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
104fn print_status(report: &StatusReport) {
106 let out = Output::new();
107
108 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 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 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 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
189fn 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
219fn 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, }),
231 Err(_) => None,
232 }
233}
234
235fn 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 let pid = fs::read_to_string(&pid_path)
253 .ok()
254 .and_then(|content| content.trim().parse::<u32>().ok());
255
256 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
271fn 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 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 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 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 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
357fn get_auths_dir() -> Result<PathBuf> {
359 auths_core::paths::auths_home().map_err(|e| anyhow!(e))
360}
361
362fn resolve_repo_path(repo_arg: Option<PathBuf>) -> Result<PathBuf> {
364 layout::resolve_repo_path(repo_arg).map_err(|e| anyhow!(e))
365}
366
367#[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
378fn 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}