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_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#[derive(Parser, Debug, Clone)]
21#[command(name = "status", about = "Show identity and agent status overview")]
22pub struct StatusCommand {}
23
24#[derive(Debug, Serialize)]
26pub struct StatusReport {
27 pub identity: Option<IdentityStatus>,
28 pub agent: AgentStatusInfo,
29 pub devices: DevicesSummary,
30}
31
32#[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#[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#[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#[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#[derive(Debug, Serialize)]
69pub struct ExpiringDevice {
70 pub device_did: String,
71 pub expires_in_days: i64,
72}
73
74pub fn handle_status(_cmd: StatusCommand, repo: Option<PathBuf>) -> Result<()> {
76 let repo_path = resolve_repo_path(repo)?;
78
79 let identity = load_identity_status(&repo_path);
81
82 let agent = get_agent_status();
84
85 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
103fn print_status(report: &StatusReport) {
105 let out = Output::new();
106
107 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 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 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 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
188fn 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
218fn 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, }),
230 Err(_) => None,
231 }
232}
233
234fn 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 let pid = fs::read_to_string(&pid_path)
252 .ok()
253 .and_then(|content| content.trim().parse::<u32>().ok());
254
255 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
270fn 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 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 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 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 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
356fn get_auths_dir() -> Result<PathBuf> {
358 auths_core::paths::auths_home().map_err(|e| anyhow!(e))
359}
360
361fn 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 let cwd = std::env::current_dir()?;
368 if crate::factories::storage::discover_git_repo(&cwd).is_ok() {
369 Ok(cwd)
370 } else {
371 get_auths_dir()
373 }
374 }
375 }
376}
377
378#[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
389fn 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}