1use crate::adapters::doctor_fixes::GitSigningConfigFix;
4use crate::adapters::system_diagnostic::PosixDiagnosticAdapter;
5use crate::ux::format::{JsonResponse, Output, is_json_mode};
6use anyhow::Result;
7use auths_sdk::keychain;
8use auths_sdk::ports::diagnostics::{
9 CheckCategory, CheckResult, ConfigIssue, DiagnosticFix, FixApplied,
10};
11use auths_sdk::workflows::diagnostics::DiagnosticsWorkflow;
12use chrono::{DateTime, Utc};
13use clap::Parser;
14use serde::Serialize;
15use std::io::IsTerminal;
16
17#[derive(Parser, Debug, Clone)]
19#[command(
20 name = "doctor",
21 about = "Run comprehensive health checks",
22 after_help = "Examples:
23 auths doctor # Check all health aspects
24 auths doctor --fix # Auto-fix identified issues
25 auths doctor --json # JSON output
26
27Exit Codes:
28 0 — All checks pass
29 1 — Critical check failed (Auths is non-functional)
30 2 — Critical checks pass, advisory checks fail (environment could be better)
31
32Related:
33 auths status — Show identity and device status
34 auths init — Initialize a new identity"
35)]
36pub struct DoctorCommand {
37 #[clap(long)]
39 pub fix: bool,
40}
41
42#[derive(Debug, Serialize)]
44pub struct Check {
45 name: String,
46 passed: bool,
47 detail: String,
48 suggestion: Option<String>,
49 #[serde(skip_serializing_if = "is_advisory")]
50 category: CheckCategory,
51}
52
53fn is_advisory(cat: &CheckCategory) -> bool {
54 *cat == CheckCategory::Advisory
55}
56
57#[derive(Debug, Serialize)]
59pub struct DoctorReport {
60 pub version: String,
61 pub checks: Vec<Check>,
62 pub all_pass: bool,
63 #[serde(skip_serializing_if = "Vec::is_empty")]
64 pub fixes_applied: Vec<FixApplied>,
65}
66
67pub fn handle_doctor(cmd: DoctorCommand) -> Result<()> {
69 let checks = run_checks();
70 let all_pass = checks.iter().all(|c| c.passed);
71
72 let (final_checks, fixes_applied) = if cmd.fix && !all_pass {
73 let out = if !is_json_mode() {
74 Some(Output::new())
75 } else {
76 None
77 };
78 let fixes = apply_fixes(&checks, out.as_ref());
79 if !fixes.is_empty() {
80 let rechecked = run_checks();
81 (rechecked, fixes)
82 } else {
83 (checks, fixes)
84 }
85 } else {
86 (checks, Vec::new())
87 };
88
89 let all_pass = final_checks.iter().all(|c| c.passed);
90
91 let exit_code = compute_exit_code(&final_checks);
93
94 let report = DoctorReport {
95 version: env!("CARGO_PKG_VERSION").to_string(),
96 checks: final_checks,
97 all_pass,
98 fixes_applied,
99 };
100
101 if is_json_mode() {
102 JsonResponse {
103 success: all_pass,
104 command: "doctor".to_string(),
105 data: Some(report),
106 error: if !all_pass {
107 Some("some health checks failed".to_string())
108 } else {
109 None
110 },
111 }
112 .print()?;
113 } else {
114 print_report(&report);
115 }
116
117 if exit_code != 0 {
118 std::process::exit(exit_code);
119 }
120
121 Ok(())
122}
123
124fn compute_exit_code(checks: &[Check]) -> i32 {
131 let critical_failures = checks
132 .iter()
133 .any(|c| !c.passed && c.category == CheckCategory::Critical);
134
135 if critical_failures {
136 return 1;
137 }
138
139 let advisory_failures = checks
140 .iter()
141 .any(|c| !c.passed && c.category == CheckCategory::Advisory);
142
143 if advisory_failures {
144 return 2;
145 }
146
147 0
148}
149
150#[allow(clippy::disallowed_methods)] fn run_checks() -> Vec<Check> {
153 let now = Utc::now();
154 let adapter = PosixDiagnosticAdapter;
155 let workflow = DiagnosticsWorkflow::new(&adapter, &adapter);
156
157 let mut checks = Vec::new();
158
159 if let Ok(report) = workflow.run() {
160 for cr in report.checks {
161 let suggestion = if cr.passed {
162 None
163 } else {
164 suggestion_for_check(&cr.name)
165 };
166 checks.push(Check {
167 name: cr.name.clone(),
168 passed: cr.passed,
169 detail: format_check_detail(&cr),
170 suggestion,
171 category: cr.category,
172 });
173 }
174 }
175
176 checks.push(check_keychain_accessible());
178 checks.push(check_auths_repo());
179 checks.push(check_identity_valid(now));
180
181 checks.push(check_registry_connectivity());
183
184 checks
185}
186
187fn apply_fixes(checks: &[Check], out: Option<&Output>) -> Vec<FixApplied> {
188 let failed: Vec<CheckResult> = checks
189 .iter()
190 .filter(|c| !c.passed)
191 .map(|c| CheckResult {
192 name: c.name.clone(),
193 passed: c.passed,
194 message: Some(c.detail.clone()),
195 config_issues: Vec::new(),
196 category: c.category,
197 })
198 .collect();
199
200 let fixes = build_available_fixes();
201 let interactive = std::io::stdin().is_terminal();
202 let mut applied = Vec::new();
203
204 for fix in &fixes {
205 let applicable: Vec<&CheckResult> = failed.iter().filter(|c| fix.can_fix(c)).collect();
206 if applicable.is_empty() {
207 continue;
208 }
209
210 if !fix.is_safe() && !interactive {
211 if let Some(o) = out {
212 o.print_warn(&format!(
213 "Skipping unsafe fix '{}' (non-interactive mode)",
214 fix.name()
215 ));
216 }
217 continue;
218 }
219
220 if !fix.is_safe() && interactive {
221 let confirm = dialoguer::Confirm::new()
222 .with_prompt(format!(
223 "Apply fix '{}'? (may overwrite existing git config)",
224 fix.name()
225 ))
226 .default(true)
227 .interact()
228 .unwrap_or(false);
229 if !confirm {
230 continue;
231 }
232 }
233
234 match fix.apply() {
235 Ok(message) => {
236 if let Some(o) = out {
237 o.print_success(&format!("Fixed: {}", message));
238 }
239 applied.push(FixApplied {
240 name: fix.name().to_string(),
241 message,
242 });
243 }
244 Err(e) => {
245 if let Some(o) = out {
246 o.print_error(&format!("Fix '{}' failed: {}", fix.name(), e));
247 }
248 }
249 }
250 }
251
252 applied
253}
254
255fn build_available_fixes() -> Vec<Box<dyn DiagnosticFix>> {
256 let mut fixes: Vec<Box<dyn DiagnosticFix>> = Vec::new();
257
258 if let Ok(sign_path) = which::which("auths-sign") {
259 let key_alias = resolve_key_alias().unwrap_or_else(|| "main".to_string());
260 fixes.push(Box::new(GitSigningConfigFix::new(sign_path, key_alias)));
261 }
262
263 fixes
264}
265
266fn resolve_key_alias() -> Option<String> {
267 let keychain = keychain::get_platform_keychain().ok()?;
268 let aliases = keychain.list_aliases().ok()?;
269 aliases
270 .into_iter()
271 .find(|a| !a.to_string().contains("--next-"))
272 .map(|a| a.to_string())
273}
274
275fn format_check_detail(cr: &CheckResult) -> String {
276 if !cr.config_issues.is_empty() {
277 let parts: Vec<String> = cr
278 .config_issues
279 .iter()
280 .map(|issue| match issue {
281 ConfigIssue::Mismatch {
282 key,
283 expected,
284 actual,
285 } => {
286 format!("{key} (is '{actual}', expected '{expected}')")
287 }
288 ConfigIssue::Absent(key) => format!("{key} (not set)"),
289 })
290 .collect();
291 return format!("Missing or wrong: {}", parts.join(", "));
292 }
293 cr.message.clone().unwrap_or_default()
294}
295
296fn suggestion_for_check(name: &str) -> Option<String> {
297 match name {
298 "Git installed" => {
299 Some("Install Git for your platform (see: https://git-scm.com/downloads)".to_string())
300 }
301 "Git version" => Some(
302 "Upgrade Git to 2.34.0+ for SSH signing: https://git-scm.com/downloads".to_string(),
303 ),
304 "Git user identity" => Some(
305 "Run: git config --global user.name \"Your Name\" && git config --global user.email \"you@example.com\"".to_string(),
306 ),
307 "ssh-keygen installed" => {
308 let hint = if cfg!(target_os = "macos") {
309 "ssh-keygen is normally pre-installed on macOS. Check your PATH."
310 } else if cfg!(target_os = "windows") {
311 "Install OpenSSH via Settings > Apps > Optional features, or `winget install Microsoft.OpenSSH.Client`."
312 } else {
313 "Install OpenSSH: `sudo apt install openssh-client` (Debian/Ubuntu) or `sudo dnf install openssh-clients` (Fedora/RHEL)."
314 };
315 Some(hint.to_string())
316 }
317 "SSH version" => Some(
318 "Upgrade OpenSSH to 8.2+ for -Y find-principals support. Check with: ssh -V".to_string(),
319 ),
320 "Git signing config" => Some("Run: auths doctor --fix".to_string()),
321 "Auths directory" => Some("Run: auths init --profile developer".to_string()),
322 "Allowed signers file" => Some("Run: auths doctor --fix".to_string()),
323 "Registry connectivity" => {
324 Some("Check your internet connection or try again later.".to_string())
325 }
326 _ => None,
327 }
328}
329
330fn check_keychain_accessible() -> Check {
331 let (passed, detail, suggestion) = match keychain::get_platform_keychain() {
332 Ok(keychain) => (
333 true,
334 format!("{} (accessible)", keychain.backend_name()),
335 None,
336 ),
337 Err(e) => (
338 false,
339 format!("inaccessible: {e}"),
340 Some("Run: auths init --profile developer".to_string()),
341 ),
342 };
343 Check {
344 name: "System keychain".to_string(),
345 passed,
346 detail,
347 suggestion,
348 category: CheckCategory::Critical,
349 }
350}
351
352fn check_auths_repo() -> Check {
353 let (passed, detail, suggestion) = match auths_sdk::paths::auths_home() {
354 Ok(path) => {
355 if !path.exists() {
356 (
357 false,
358 format!("{} (not found)", path.display()),
359 Some("Run: auths init --profile developer".to_string()),
360 )
361 } else {
362 match crate::factories::storage::open_git_repo(&path) {
363 Ok(_) => (
364 true,
365 format!("{} (valid git repository)", path.display()),
366 None,
367 ),
368 Err(_) => (
369 false,
370 format!("{} (exists but not a valid git repo)", path.display()),
371 Some("Run: auths init --profile developer".to_string()),
372 ),
373 }
374 }
375 }
376 Err(e) => (
377 false,
378 format!("Cannot resolve path: {e}"),
379 Some("Run: auths init --profile developer".to_string()),
380 ),
381 };
382 Check {
383 name: "Auths directory".to_string(),
384 passed,
385 detail,
386 suggestion,
387 category: CheckCategory::Critical,
388 }
389}
390
391fn check_identity_valid(now: DateTime<Utc>) -> Check {
392 let (passed, detail, suggestion) = match keychain::get_platform_keychain() {
393 Ok(keychain) => match keychain.list_aliases() {
394 Ok(aliases) if aliases.is_empty() => (
395 false,
396 "No keys found in keychain".to_string(),
397 Some("Run: auths init --profile developer (or: auths id create)".to_string()),
398 ),
399 Ok(aliases) => {
400 let key_count = aliases.len();
401 let expiry_info = check_attestation_expiry(now);
402 match expiry_info {
403 ExpiryStatus::AllExpired(msg) => (
404 false,
405 format!("{key_count} key(s) found, but {msg}"),
406 Some("Run: auths device extend".to_string()),
407 ),
408 ExpiryStatus::ExpiringSoon(msg) => {
409 (true, format!("{key_count} key(s) found ({msg})"), None)
410 }
411 ExpiryStatus::Ok | ExpiryStatus::NoAttestations => {
412 (true, format!("{key_count} key(s) found"), None)
413 }
414 }
415 }
416 Err(e) => (
417 false,
418 format!("Failed to list keys: {e}"),
419 Some("Run: auths doctor (check keychain is accessible first)".to_string()),
420 ),
421 },
422 Err(_) => (
423 false,
424 "Keychain not accessible".to_string(),
425 Some("Run: auths init --profile developer".to_string()),
426 ),
427 };
428 Check {
429 name: "Auths identity".to_string(),
430 passed,
431 detail,
432 suggestion,
433 category: CheckCategory::Critical,
434 }
435}
436
437enum ExpiryStatus {
438 Ok,
439 NoAttestations,
440 ExpiringSoon(String),
441 AllExpired(String),
442}
443
444fn check_attestation_expiry(now: DateTime<Utc>) -> ExpiryStatus {
445 use auths_sdk::storage::RegistryAttestationStorage;
446
447 let repo_path = match auths_sdk::paths::auths_home() {
448 Ok(p) if p.exists() => p,
449 _ => return ExpiryStatus::NoAttestations,
450 };
451
452 let storage = RegistryAttestationStorage::new(&repo_path);
453 let attestations = match storage
454 .load_all_enriched()
455 .map(|v| v.into_iter().map(|e| e.attestation).collect::<Vec<_>>())
456 {
457 Ok(a) => a,
458 Err(_) => return ExpiryStatus::NoAttestations,
459 };
460
461 if attestations.is_empty() {
462 return ExpiryStatus::NoAttestations;
463 }
464
465 let active: Vec<_> = attestations
466 .iter()
467 .filter(|a| a.revoked_at.is_none())
468 .collect();
469
470 if active.is_empty() {
471 return ExpiryStatus::AllExpired("all attestations revoked".to_string());
472 }
473
474 let with_expiry: Vec<_> = active.iter().filter(|a| a.expires_at.is_some()).collect();
475
476 if with_expiry.is_empty() {
477 return ExpiryStatus::Ok;
478 }
479
480 let all_expired = with_expiry
481 .iter()
482 .all(|a| a.expires_at.is_some_and(|exp| exp < now));
483
484 if all_expired {
485 return ExpiryStatus::AllExpired("all attestations expired".to_string());
486 }
487
488 let warn_threshold = now + chrono::Duration::days(7);
489 let expiring_soon = with_expiry
490 .iter()
491 .any(|a| a.expires_at.is_some_and(|exp| exp < warn_threshold));
492
493 if expiring_soon {
494 return ExpiryStatus::ExpiringSoon("some attestations expiring within 7 days".to_string());
495 }
496
497 ExpiryStatus::Ok
498}
499
500fn check_registry_connectivity() -> Check {
501 use auths_sdk::registration::DEFAULT_REGISTRY_URL;
502
503 let url = format!("{DEFAULT_REGISTRY_URL}/health");
504 let client = reqwest::blocking::Client::builder()
505 .timeout(std::time::Duration::from_secs(5))
506 .build();
507
508 let (passed, detail) = match client {
509 Ok(client) => match client.get(&url).send() {
510 Ok(resp) if resp.status().is_success() => {
511 (true, format!("{DEFAULT_REGISTRY_URL} (reachable)"))
512 }
513 Ok(resp) => (
514 false,
515 format!("{DEFAULT_REGISTRY_URL} (HTTP {})", resp.status()),
516 ),
517 Err(e) => (false, format!("unreachable: {e}")),
518 },
519 Err(e) => (false, format!("HTTP client error: {e}")),
520 };
521
522 Check {
523 name: "Registry connectivity".to_string(),
524 passed,
525 detail,
526 suggestion: if passed {
527 None
528 } else {
529 suggestion_for_check("Registry connectivity")
530 },
531 category: CheckCategory::Advisory,
532 }
533}
534
535fn print_report(report: &DoctorReport) {
537 let out = Output::new();
538
539 out.print_heading(&format!("Auths Doctor (v{})", report.version));
540 out.println("--------------------------");
541 out.newline();
542
543 for check in &report.checks {
544 let (icon, name_styled) = if check.passed {
545 (out.success("✓"), out.bold(&check.name))
546 } else {
547 (out.error("✗"), out.error(&check.name))
548 };
549
550 out.println(&format!("[{icon}] {name_styled}: {}", check.detail));
551
552 if let Some(ref suggestion) = check.suggestion {
553 out.println(&format!(" -> {}", out.dim(suggestion)));
554 }
555 }
556
557 if !report.fixes_applied.is_empty() {
558 out.newline();
559 out.print_heading("Fixes applied:");
560 for fix in &report.fixes_applied {
561 out.println(&format!(" {} — {}", out.success(&fix.name), fix.message));
562 }
563 }
564
565 out.newline();
566
567 let passed_count = report.checks.iter().filter(|c| c.passed).count();
568 let failed_count = report.checks.len() - passed_count;
569
570 let summary = format!(
571 "Summary: {} passed, {} failed",
572 out.success(&passed_count.to_string()),
573 out.error(&failed_count.to_string())
574 );
575 out.println(&summary);
576 out.newline();
577
578 if report.all_pass {
579 out.print_success("All checks passed! Your system is ready.");
580 } else {
581 out.print_error("Some checks failed. Please review the suggestions above.");
582 }
583}
584
585impl crate::commands::executable::ExecutableCommand for DoctorCommand {
586 fn execute(&self, _ctx: &crate::config::CliConfig) -> anyhow::Result<()> {
587 handle_doctor(self.clone())
588 }
589}
590
591#[cfg(test)]
592mod tests {
593 #[test]
594 fn test_keychain_check_suggestion_is_exact_command() {
595 let suggestion = "Run: auths init --profile developer";
596 assert!(
597 suggestion.starts_with("Run:"),
598 "suggestion must start with 'Run:'"
599 );
600 }
601
602 #[test]
603 fn test_workflow_includes_version_and_user_checks() {
604 use super::*;
605 let adapter = PosixDiagnosticAdapter;
606 let workflow = DiagnosticsWorkflow::new(&adapter, &adapter);
607 let report = workflow.run().unwrap();
608
609 let check_names: Vec<&str> = report.checks.iter().map(|c| c.name.as_str()).collect();
610 assert!(
611 check_names.contains(&"Git signing config"),
612 "signing config check must exist"
613 );
614 assert!(
615 check_names.contains(&"Git version"),
616 "git version check must exist"
617 );
618 assert!(
619 check_names.contains(&"Git user identity"),
620 "git user identity check must exist"
621 );
622 }
623
624 #[test]
625 fn test_all_failed_checks_have_exact_runnable_suggestions() {
626 let suggestions: Vec<Option<String>> = vec![
627 Some("Run: auths init --profile developer".to_string()),
628 Some("Run: auths id init".to_string()),
629 Some("Run: git config --global gpg.format ssh".to_string()),
630 Some("Run: auths init --profile developer".to_string()),
631 ];
632 for text in suggestions.into_iter().flatten() {
633 assert!(text.starts_with("Run:"), "bad suggestion: {}", text);
634 }
635 }
636
637 #[test]
638 fn test_suggestion_for_all_new_checks() {
639 use super::suggestion_for_check;
640 assert!(suggestion_for_check("Git version").is_some());
641 assert!(suggestion_for_check("Git user identity").is_some());
642 assert!(suggestion_for_check("Auths directory").is_some());
643 assert!(suggestion_for_check("Registry connectivity").is_some());
644 }
645}