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_commit_hook_installed());
183 checks.push(check_repo_hooks_path_override());
184
185 checks.push(check_registry_connectivity());
187
188 checks
189}
190
191fn check_commit_hook_installed() -> Check {
194 let (passed, detail) = match auths_sdk::paths::auths_home() {
195 Ok(home) if !home.join("commit-trailers").exists() => {
196 (
198 true,
199 "no identity initialized (hook not applicable)".to_string(),
200 )
201 }
202 Ok(home) => {
203 let hook_current = auths_sdk::workflows::commit_hooks::hook_is_current(&home);
204 let hooks_path_set =
205 crate::subprocess::git_command(&["config", "--global", "core.hooksPath"])
206 .output()
207 .ok()
208 .filter(|o| o.status.success())
209 .map(|o| {
210 String::from_utf8_lossy(&o.stdout).trim()
211 == home.join("githooks").to_string_lossy()
212 })
213 .unwrap_or(false);
214 match (hook_current, hooks_path_set) {
215 (true, true) => (true, "prepare-commit-msg hook installed".to_string()),
216 (false, _) => (false, "hook file missing or stale".to_string()),
217 (true, false) => (false, "core.hooksPath not pointing at the hook".to_string()),
218 }
219 }
220 Err(e) => (false, format!("could not locate ~/.auths: {e}")),
221 };
222 Check {
223 name: "Commit trailer hook".to_string(),
224 passed,
225 detail,
226 suggestion: if passed {
227 None
228 } else {
229 Some("Re-run `auths init` to reinstall the commit hook.".to_string())
230 },
231 category: CheckCategory::Advisory,
232 }
233}
234
235fn check_repo_hooks_path_override() -> Check {
239 let local_override = crate::subprocess::git_command(&["config", "--local", "core.hooksPath"])
240 .output()
241 .ok()
242 .filter(|o| o.status.success())
243 .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
244 .filter(|path| !path.is_empty());
245 let (passed, detail, suggestion) = match local_override {
246 None => (
247 true,
248 "no repo-local core.hooksPath override".to_string(),
249 None,
250 ),
251 Some(path) => (
252 false,
253 format!(
254 "this repo overrides core.hooksPath to '{path}' — the auths trailer hook is bypassed"
255 ),
256 Some(format!(
257 "Copy the prepare-commit-msg hook from ~/.auths/githooks/ into '{path}' (or chain it from your hook manager)."
258 )),
259 ),
260 };
261 Check {
262 name: "Repo hook override".to_string(),
263 passed,
264 detail,
265 suggestion,
266 category: CheckCategory::Advisory,
267 }
268}
269
270fn apply_fixes(checks: &[Check], out: Option<&Output>) -> Vec<FixApplied> {
271 let failed: Vec<CheckResult> = checks
272 .iter()
273 .filter(|c| !c.passed)
274 .map(|c| CheckResult {
275 name: c.name.clone(),
276 passed: c.passed,
277 message: Some(c.detail.clone()),
278 config_issues: Vec::new(),
279 category: c.category,
280 })
281 .collect();
282
283 let fixes = build_available_fixes();
284 let interactive = std::io::stdin().is_terminal();
285 let mut applied = Vec::new();
286
287 for fix in &fixes {
288 let applicable: Vec<&CheckResult> = failed.iter().filter(|c| fix.can_fix(c)).collect();
289 if applicable.is_empty() {
290 continue;
291 }
292
293 if !fix.is_safe() && !interactive {
294 if let Some(o) = out {
295 o.print_warn(&format!(
296 "Skipping unsafe fix '{}' (non-interactive mode)",
297 fix.name()
298 ));
299 }
300 continue;
301 }
302
303 if !fix.is_safe() && interactive {
304 let confirm = dialoguer::Confirm::new()
305 .with_prompt(format!(
306 "Apply fix '{}'? (may overwrite existing git config)",
307 fix.name()
308 ))
309 .default(true)
310 .interact()
311 .unwrap_or(false);
312 if !confirm {
313 continue;
314 }
315 }
316
317 match fix.apply() {
318 Ok(message) => {
319 if let Some(o) = out {
320 o.print_success(&format!("Fixed: {}", message));
321 }
322 applied.push(FixApplied {
323 name: fix.name().to_string(),
324 message,
325 });
326 }
327 Err(e) => {
328 if let Some(o) = out {
329 o.print_error(&format!("Fix '{}' failed: {}", fix.name(), e));
330 }
331 }
332 }
333 }
334
335 applied
336}
337
338fn build_available_fixes() -> Vec<Box<dyn DiagnosticFix>> {
339 let mut fixes: Vec<Box<dyn DiagnosticFix>> = Vec::new();
340
341 if let Ok(sign_path) = which::which("auths-sign") {
342 let key_alias = resolve_key_alias().unwrap_or_else(|| "main".to_string());
343 fixes.push(Box::new(GitSigningConfigFix::new(sign_path, key_alias)));
344 }
345
346 fixes
347}
348
349fn resolve_key_alias() -> Option<String> {
350 let keychain = keychain::get_platform_keychain().ok()?;
351 let aliases = keychain.list_aliases().ok()?;
352 aliases
353 .into_iter()
354 .find(|a| !a.to_string().contains("--next-"))
355 .map(|a| a.to_string())
356}
357
358fn format_check_detail(cr: &CheckResult) -> String {
359 if !cr.config_issues.is_empty() {
360 let parts: Vec<String> = cr
361 .config_issues
362 .iter()
363 .map(|issue| match issue {
364 ConfigIssue::Mismatch {
365 key,
366 expected,
367 actual,
368 } => {
369 format!("{key} (is '{actual}', expected '{expected}')")
370 }
371 ConfigIssue::Absent(key) => format!("{key} (not set)"),
372 })
373 .collect();
374 return format!("Missing or wrong: {}", parts.join(", "));
375 }
376 cr.message.clone().unwrap_or_default()
377}
378
379fn suggestion_for_check(name: &str) -> Option<String> {
380 match name {
381 "Git installed" => {
382 Some("Install Git for your platform (see: https://git-scm.com/downloads)".to_string())
383 }
384 "Git version" => Some(
385 "Upgrade Git to 2.34.0+ for SSH signing: https://git-scm.com/downloads".to_string(),
386 ),
387 "Git user identity" => Some(
388 "Run: git config --global user.name \"Your Name\" && git config --global user.email \"you@example.com\"".to_string(),
389 ),
390 "ssh-keygen installed" => {
391 let hint = if cfg!(target_os = "macos") {
392 "ssh-keygen is normally pre-installed on macOS. Check your PATH."
393 } else if cfg!(target_os = "windows") {
394 "Install OpenSSH via Settings > Apps > Optional features, or `winget install Microsoft.OpenSSH.Client`."
395 } else {
396 "Install OpenSSH: `sudo apt install openssh-client` (Debian/Ubuntu) or `sudo dnf install openssh-clients` (Fedora/RHEL)."
397 };
398 Some(hint.to_string())
399 }
400 "SSH version" => Some(
401 "Upgrade OpenSSH to 8.2+ for -Y find-principals support. Check with: ssh -V".to_string(),
402 ),
403 "Git signing config" => Some("Run: auths doctor --fix".to_string()),
404 "Auths directory" => Some("Run: auths init --profile developer".to_string()),
405 "Allowed signers file" => Some("Run: auths doctor --fix".to_string()),
406 "Registry connectivity" => {
407 Some("Check your internet connection or try again later.".to_string())
408 }
409 _ => None,
410 }
411}
412
413fn check_keychain_accessible() -> Check {
414 let (passed, detail, suggestion) = match keychain::get_platform_keychain() {
415 Ok(keychain) => (
416 true,
417 format!("{} (accessible)", keychain.backend_name()),
418 None,
419 ),
420 Err(e) => (
421 false,
422 format!("inaccessible: {e}"),
423 Some("Run: auths init --profile developer".to_string()),
424 ),
425 };
426 Check {
427 name: "System keychain".to_string(),
428 passed,
429 detail,
430 suggestion,
431 category: CheckCategory::Critical,
432 }
433}
434
435fn check_auths_repo() -> Check {
436 let (passed, detail, suggestion) = match auths_sdk::paths::auths_home() {
437 Ok(path) => {
438 if !path.exists() {
439 (
440 false,
441 format!("{} (not found)", path.display()),
442 Some("Run: auths init --profile developer".to_string()),
443 )
444 } else {
445 match crate::factories::storage::open_git_repo(&path) {
446 Ok(_) => (
447 true,
448 format!("{} (valid git repository)", path.display()),
449 None,
450 ),
451 Err(_) => (
452 false,
453 format!("{} (exists but not a valid git repo)", path.display()),
454 Some("Run: auths init --profile developer".to_string()),
455 ),
456 }
457 }
458 }
459 Err(e) => (
460 false,
461 format!("Cannot resolve path: {e}"),
462 Some("Run: auths init --profile developer".to_string()),
463 ),
464 };
465 Check {
466 name: "Auths directory".to_string(),
467 passed,
468 detail,
469 suggestion,
470 category: CheckCategory::Critical,
471 }
472}
473
474fn check_identity_valid(now: DateTime<Utc>) -> Check {
475 let (passed, detail, suggestion) = match keychain::get_platform_keychain() {
476 Ok(keychain) => match keychain.list_aliases() {
477 Ok(aliases) if aliases.is_empty() => (
478 false,
479 "No keys found in keychain".to_string(),
480 Some("Run: auths init --profile developer (or: auths id create)".to_string()),
481 ),
482 Ok(aliases) => {
483 let key_count = aliases.len();
484 let expiry_info = check_attestation_expiry(now);
485 match expiry_info {
486 ExpiryStatus::AllExpired(msg) => (
487 false,
488 format!("{key_count} key(s) found, but {msg}"),
489 Some("Run: auths device extend".to_string()),
490 ),
491 ExpiryStatus::ExpiringSoon(msg) => {
492 (true, format!("{key_count} key(s) found ({msg})"), None)
493 }
494 ExpiryStatus::Ok | ExpiryStatus::NoAttestations => {
495 (true, format!("{key_count} key(s) found"), None)
496 }
497 }
498 }
499 Err(e) => (
500 false,
501 format!("Failed to list keys: {e}"),
502 Some("Run: auths doctor (check keychain is accessible first)".to_string()),
503 ),
504 },
505 Err(_) => (
506 false,
507 "Keychain not accessible".to_string(),
508 Some("Run: auths init --profile developer".to_string()),
509 ),
510 };
511 Check {
512 name: "Auths identity".to_string(),
513 passed,
514 detail,
515 suggestion,
516 category: CheckCategory::Critical,
517 }
518}
519
520enum ExpiryStatus {
521 Ok,
522 NoAttestations,
523 ExpiringSoon(String),
524 AllExpired(String),
525}
526
527fn check_attestation_expiry(now: DateTime<Utc>) -> ExpiryStatus {
528 use auths_sdk::storage::RegistryAttestationStorage;
529
530 let repo_path = match auths_sdk::paths::auths_home() {
531 Ok(p) if p.exists() => p,
532 _ => return ExpiryStatus::NoAttestations,
533 };
534
535 let storage = RegistryAttestationStorage::new(&repo_path);
536 let attestations = match storage
537 .load_all_enriched()
538 .map(|v| v.into_iter().map(|e| e.attestation).collect::<Vec<_>>())
539 {
540 Ok(a) => a,
541 Err(_) => return ExpiryStatus::NoAttestations,
542 };
543
544 if attestations.is_empty() {
545 return ExpiryStatus::NoAttestations;
546 }
547
548 let active: Vec<_> = attestations
549 .iter()
550 .filter(|a| a.revoked_at.is_none())
551 .collect();
552
553 if active.is_empty() {
554 return ExpiryStatus::AllExpired("all attestations revoked".to_string());
555 }
556
557 let with_expiry: Vec<_> = active.iter().filter(|a| a.expires_at.is_some()).collect();
558
559 if with_expiry.is_empty() {
560 return ExpiryStatus::Ok;
561 }
562
563 let all_expired = with_expiry
564 .iter()
565 .all(|a| a.expires_at.is_some_and(|exp| exp < now));
566
567 if all_expired {
568 return ExpiryStatus::AllExpired("all attestations expired".to_string());
569 }
570
571 let warn_threshold = now + chrono::Duration::days(7);
572 let expiring_soon = with_expiry
573 .iter()
574 .any(|a| a.expires_at.is_some_and(|exp| exp < warn_threshold));
575
576 if expiring_soon {
577 return ExpiryStatus::ExpiringSoon("some attestations expiring within 7 days".to_string());
578 }
579
580 ExpiryStatus::Ok
581}
582
583fn check_registry_connectivity() -> Check {
584 use auths_sdk::registration::DEFAULT_REGISTRY_URL;
585
586 let url = format!("{DEFAULT_REGISTRY_URL}/health");
587 let client = reqwest::blocking::Client::builder()
588 .timeout(std::time::Duration::from_secs(5))
589 .build();
590
591 let (passed, detail) = match client {
592 Ok(client) => match client.get(&url).send() {
593 Ok(resp) if resp.status().is_success() => {
594 (true, format!("{DEFAULT_REGISTRY_URL} (reachable)"))
595 }
596 Ok(resp) => (
597 false,
598 format!("{DEFAULT_REGISTRY_URL} (HTTP {})", resp.status()),
599 ),
600 Err(e) => (false, format!("unreachable: {e}")),
601 },
602 Err(e) => (false, format!("HTTP client error: {e}")),
603 };
604
605 Check {
606 name: "Registry connectivity".to_string(),
607 passed,
608 detail,
609 suggestion: if passed {
610 None
611 } else {
612 suggestion_for_check("Registry connectivity")
613 },
614 category: CheckCategory::Advisory,
615 }
616}
617
618fn print_report(report: &DoctorReport) {
620 let out = Output::new();
621
622 out.print_heading(&format!("Auths Doctor (v{})", report.version));
623 out.println("--------------------------");
624 out.newline();
625
626 for check in &report.checks {
627 let (icon, name_styled) = if check.passed {
628 (out.success("✓"), out.bold(&check.name))
629 } else {
630 (out.error("✗"), out.error(&check.name))
631 };
632
633 out.println(&format!("[{icon}] {name_styled}: {}", check.detail));
634
635 if let Some(ref suggestion) = check.suggestion {
636 out.println(&format!(" -> {}", out.dim(suggestion)));
637 }
638 }
639
640 if !report.fixes_applied.is_empty() {
641 out.newline();
642 out.print_heading("Fixes applied:");
643 for fix in &report.fixes_applied {
644 out.println(&format!(" {} — {}", out.success(&fix.name), fix.message));
645 }
646 }
647
648 out.newline();
649
650 let passed_count = report.checks.iter().filter(|c| c.passed).count();
651 let failed_count = report.checks.len() - passed_count;
652
653 let summary = format!(
654 "Summary: {} passed, {} failed",
655 out.success(&passed_count.to_string()),
656 out.error(&failed_count.to_string())
657 );
658 out.println(&summary);
659 out.newline();
660
661 if report.all_pass {
662 out.print_success("All checks passed! Your system is ready.");
663 } else {
664 out.print_error("Some checks failed. Please review the suggestions above.");
665 }
666}
667
668impl crate::commands::executable::ExecutableCommand for DoctorCommand {
669 fn execute(&self, _ctx: &crate::config::CliConfig) -> anyhow::Result<()> {
670 handle_doctor(self.clone())
671 }
672}
673
674#[cfg(test)]
675mod tests {
676 #[test]
677 fn test_keychain_check_suggestion_is_exact_command() {
678 let suggestion = "Run: auths init --profile developer";
679 assert!(
680 suggestion.starts_with("Run:"),
681 "suggestion must start with 'Run:'"
682 );
683 }
684
685 #[test]
686 fn test_workflow_includes_version_and_user_checks() {
687 use super::*;
688 let adapter = PosixDiagnosticAdapter;
689 let workflow = DiagnosticsWorkflow::new(&adapter, &adapter);
690 let report = workflow.run().unwrap();
691
692 let check_names: Vec<&str> = report.checks.iter().map(|c| c.name.as_str()).collect();
693 assert!(
694 check_names.contains(&"Git signing config"),
695 "signing config check must exist"
696 );
697 assert!(
698 check_names.contains(&"Git version"),
699 "git version check must exist"
700 );
701 assert!(
702 check_names.contains(&"Git user identity"),
703 "git user identity check must exist"
704 );
705 }
706
707 #[test]
708 fn test_all_failed_checks_have_exact_runnable_suggestions() {
709 let suggestions: Vec<Option<String>> = vec![
710 Some("Run: auths init --profile developer".to_string()),
711 Some("Run: auths id init".to_string()),
712 Some("Run: git config --global gpg.format ssh".to_string()),
713 Some("Run: auths init --profile developer".to_string()),
714 ];
715 for text in suggestions.into_iter().flatten() {
716 assert!(text.starts_with("Run:"), "bad suggestion: {}", text);
717 }
718 }
719
720 #[test]
721 fn test_suggestion_for_all_new_checks() {
722 use super::suggestion_for_check;
723 assert!(suggestion_for_check("Git version").is_some());
724 assert!(suggestion_for_check("Git user identity").is_some());
725 assert!(suggestion_for_check("Auths directory").is_some());
726 assert!(suggestion_for_check("Registry connectivity").is_some());
727 }
728}