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::paths::resolve_registry_path;
9use auths_sdk::ports::diagnostics::{
10 CheckCategory, CheckResult, ConfigIssue, DiagnosticFix, FixApplied,
11};
12use auths_sdk::workflows::diagnostics::DiagnosticsWorkflow;
13use chrono::{DateTime, Utc};
14use clap::Parser;
15use serde::Serialize;
16use std::io::IsTerminal;
17use std::path::PathBuf;
18
19#[derive(Parser, Debug, Clone)]
21#[command(
22 name = "doctor",
23 about = "Run comprehensive health checks",
24 after_help = "Examples:
25 auths doctor # Check all health aspects
26 auths doctor --fix # Auto-fix identified issues
27 auths doctor --json # JSON output
28
29Exit Codes:
30 0 — All checks pass
31 1 — Critical check failed (Auths is non-functional)
32 2 — Critical checks pass, advisory checks fail (environment could be better)
33
34Related:
35 auths status — Show identity and device status
36 auths init — Initialize a new identity"
37)]
38pub struct DoctorCommand {
39 #[clap(long)]
41 pub fix: bool,
42}
43
44#[derive(Debug, Serialize)]
46pub struct Check {
47 name: String,
48 passed: bool,
49 detail: String,
50 suggestion: Option<String>,
51 #[serde(skip_serializing_if = "is_advisory")]
52 category: CheckCategory,
53}
54
55fn is_advisory(cat: &CheckCategory) -> bool {
56 *cat == CheckCategory::Advisory
57}
58
59#[derive(Debug, Serialize)]
61pub struct DoctorReport {
62 pub version: String,
63 pub checks: Vec<Check>,
64 pub all_pass: bool,
65 #[serde(skip_serializing_if = "Vec::is_empty")]
66 pub fixes_applied: Vec<FixApplied>,
67}
68
69pub fn handle_doctor(cmd: DoctorCommand, repo_override: Option<PathBuf>) -> Result<()> {
81 let checks = run_checks(repo_override.clone());
82 let all_pass = checks.iter().all(|c| c.passed);
83
84 let (final_checks, fixes_applied) = if cmd.fix && !all_pass {
85 let out = if !is_json_mode() {
86 Some(Output::new())
87 } else {
88 None
89 };
90 let fixes = apply_fixes(&checks, out.as_ref());
91 if !fixes.is_empty() {
92 let rechecked = run_checks(repo_override.clone());
93 (rechecked, fixes)
94 } else {
95 (checks, fixes)
96 }
97 } else {
98 (checks, Vec::new())
99 };
100
101 let all_pass = final_checks.iter().all(|c| c.passed);
102
103 let exit_code = compute_exit_code(&final_checks);
105
106 let report = DoctorReport {
107 version: env!("CARGO_PKG_VERSION").to_string(),
108 checks: final_checks,
109 all_pass,
110 fixes_applied,
111 };
112
113 if is_json_mode() {
114 JsonResponse {
115 success: all_pass,
116 command: "doctor".to_string(),
117 data: Some(report),
118 error: if !all_pass {
119 Some("some health checks failed".to_string())
120 } else {
121 None
122 },
123 }
124 .print()?;
125 } else {
126 print_report(&report);
127 }
128
129 if exit_code != 0 {
130 std::process::exit(exit_code);
131 }
132
133 Ok(())
134}
135
136fn compute_exit_code(checks: &[Check]) -> i32 {
143 let critical_failures = checks
144 .iter()
145 .any(|c| !c.passed && c.category == CheckCategory::Critical);
146
147 if critical_failures {
148 return 1;
149 }
150
151 let advisory_failures = checks
152 .iter()
153 .any(|c| !c.passed && c.category == CheckCategory::Advisory);
154
155 if advisory_failures {
156 return 2;
157 }
158
159 0
160}
161
162#[allow(clippy::disallowed_methods)] fn run_checks(repo_override: Option<PathBuf>) -> Vec<Check> {
165 let now = Utc::now();
166 let adapter = PosixDiagnosticAdapter;
167 let workflow = DiagnosticsWorkflow::new(&adapter, &adapter);
168
169 let mut checks = Vec::new();
170
171 if let Ok(report) = workflow.run() {
172 for cr in report.checks {
173 let suggestion = if cr.passed {
174 None
175 } else {
176 suggestion_for_check(&cr.name)
177 };
178 checks.push(Check {
179 name: cr.name.clone(),
180 passed: cr.passed,
181 detail: format_check_detail(&cr),
182 suggestion,
183 category: cr.category,
184 });
185 }
186 }
187
188 checks.push(check_keychain_accessible());
190 checks.push(check_identity_home(repo_override.clone()));
191 checks.push(check_auths_repo(repo_override.clone()));
192 checks.push(check_identity_valid(now, repo_override.clone()));
193
194 checks.push(check_commit_hook_installed(repo_override));
196 checks.push(check_repo_hooks_path_override());
197
198 checks.push(check_registry_connectivity());
200
201 checks
202}
203
204fn check_commit_hook_installed(repo_override: Option<PathBuf>) -> Check {
207 let (passed, detail) = match resolve_registry_path(repo_override) {
208 Ok(home) if !home.join("commit-trailers").exists() => {
209 (
211 true,
212 "no identity initialized (hook not applicable)".to_string(),
213 )
214 }
215 Ok(home) => {
216 let hook_current = auths_sdk::workflows::commit_hooks::hook_is_current(&home);
217 let hooks_path_set = crate::subprocess::git_command(&["config", "core.hooksPath"])
223 .output()
224 .ok()
225 .filter(|o| o.status.success())
226 .map(|o| {
227 String::from_utf8_lossy(&o.stdout).trim()
228 == home.join("githooks").to_string_lossy()
229 })
230 .unwrap_or(false);
231 match (hook_current, hooks_path_set) {
232 (true, true) => (true, "prepare-commit-msg hook installed".to_string()),
233 (false, _) => (false, "hook file missing or stale".to_string()),
234 (true, false) => (false, "core.hooksPath not pointing at the hook".to_string()),
235 }
236 }
237 Err(e) => (false, format!("could not locate ~/.auths: {e}")),
238 };
239 Check {
240 name: "Commit trailer hook".to_string(),
241 passed,
242 detail,
243 suggestion: if passed {
244 None
245 } else {
246 Some("Re-run `auths init` to reinstall the commit hook.".to_string())
247 },
248 category: CheckCategory::Advisory,
249 }
250}
251
252fn check_repo_hooks_path_override() -> Check {
256 let local_override = crate::subprocess::git_command(&["config", "--local", "core.hooksPath"])
257 .output()
258 .ok()
259 .filter(|o| o.status.success())
260 .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
261 .filter(|path| !path.is_empty());
262 let (passed, detail, suggestion) = match local_override {
263 None => (
264 true,
265 "no repo-local core.hooksPath override".to_string(),
266 None,
267 ),
268 Some(path) => (
269 false,
270 format!(
271 "this repo overrides core.hooksPath to '{path}' — the auths trailer hook is bypassed"
272 ),
273 Some(format!(
274 "Copy the prepare-commit-msg hook from ~/.auths/githooks/ into '{path}' (or chain it from your hook manager)."
275 )),
276 ),
277 };
278 Check {
279 name: "Repo hook override".to_string(),
280 passed,
281 detail,
282 suggestion,
283 category: CheckCategory::Advisory,
284 }
285}
286
287fn apply_fixes(checks: &[Check], out: Option<&Output>) -> Vec<FixApplied> {
288 let failed: Vec<CheckResult> = checks
289 .iter()
290 .filter(|c| !c.passed)
291 .map(|c| CheckResult {
292 name: c.name.clone(),
293 passed: c.passed,
294 message: Some(c.detail.clone()),
295 config_issues: Vec::new(),
296 category: c.category,
297 })
298 .collect();
299
300 let fixes = build_available_fixes();
301 let interactive = std::io::stdin().is_terminal();
302 let mut applied = Vec::new();
303
304 for fix in &fixes {
305 let applicable: Vec<&CheckResult> = failed.iter().filter(|c| fix.can_fix(c)).collect();
306 if applicable.is_empty() {
307 continue;
308 }
309
310 if !fix.is_safe() && !interactive {
311 if let Some(o) = out {
312 o.print_warn(&format!(
313 "Skipping unsafe fix '{}' (non-interactive mode)",
314 fix.name()
315 ));
316 }
317 continue;
318 }
319
320 if !fix.is_safe() && interactive {
321 let confirm = dialoguer::Confirm::new()
322 .with_prompt(format!(
323 "Apply fix '{}'? (may overwrite existing git config)",
324 fix.name()
325 ))
326 .default(true)
327 .interact()
328 .unwrap_or(false);
329 if !confirm {
330 continue;
331 }
332 }
333
334 match fix.apply() {
335 Ok(message) => {
336 if let Some(o) = out {
337 o.print_success(&format!("Fixed: {}", message));
338 }
339 applied.push(FixApplied {
340 name: fix.name().to_string(),
341 message,
342 });
343 }
344 Err(e) => {
345 if let Some(o) = out {
346 o.print_error(&format!("Fix '{}' failed: {}", fix.name(), e));
347 }
348 }
349 }
350 }
351
352 applied
353}
354
355fn build_available_fixes() -> Vec<Box<dyn DiagnosticFix>> {
356 let mut fixes: Vec<Box<dyn DiagnosticFix>> = Vec::new();
357
358 if let Ok(sign_path) = which::which("auths-sign") {
359 let key_alias = resolve_key_alias().unwrap_or_else(|| "main".to_string());
360 fixes.push(Box::new(GitSigningConfigFix::new(sign_path, key_alias)));
361 }
362
363 fixes
364}
365
366fn resolve_key_alias() -> Option<String> {
367 let keychain = keychain::get_platform_keychain().ok()?;
368 let aliases = keychain.list_aliases().ok()?;
369 aliases
370 .into_iter()
371 .find(|a| !a.to_string().contains("--next-"))
372 .map(|a| a.to_string())
373}
374
375fn format_check_detail(cr: &CheckResult) -> String {
376 if !cr.config_issues.is_empty() {
377 let parts: Vec<String> = cr
378 .config_issues
379 .iter()
380 .map(|issue| match issue {
381 ConfigIssue::Mismatch {
382 key,
383 expected,
384 actual,
385 } => {
386 format!("{key} (is '{actual}', expected '{expected}')")
387 }
388 ConfigIssue::Absent(key) => format!("{key} (not set)"),
389 })
390 .collect();
391 return format!("Missing or wrong: {}", parts.join(", "));
392 }
393 cr.message.clone().unwrap_or_default()
394}
395
396fn suggestion_for_check(name: &str) -> Option<String> {
397 match name {
398 "Git installed" => {
399 Some("Install Git for your platform (see: https://git-scm.com/downloads)".to_string())
400 }
401 "Git version" => Some(
402 "Upgrade Git to 2.34.0+ for SSH signing: https://git-scm.com/downloads".to_string(),
403 ),
404 "Git user identity" => Some(
405 "Run: git config --global user.name \"Your Name\" && git config --global user.email \"you@example.com\"".to_string(),
406 ),
407 "ssh-keygen installed" => {
408 let hint = if cfg!(target_os = "macos") {
409 "ssh-keygen is normally pre-installed on macOS. Check your PATH."
410 } else if cfg!(target_os = "windows") {
411 "Install OpenSSH via Settings > Apps > Optional features, or `winget install Microsoft.OpenSSH.Client`."
412 } else {
413 "Install OpenSSH: `sudo apt install openssh-client` (Debian/Ubuntu) or `sudo dnf install openssh-clients` (Fedora/RHEL)."
414 };
415 Some(hint.to_string())
416 }
417 "SSH version" => Some(
418 "Upgrade OpenSSH to 8.2+ for -Y find-principals support. Check with: ssh -V".to_string(),
419 ),
420 "Git signing config" => Some("Run: auths doctor --fix".to_string()),
421 "Auths directory" => Some("Run: auths init --profile developer".to_string()),
422 "Allowed signers file" => Some("Run: auths doctor --fix".to_string()),
423 "Registry connectivity" => {
424 Some("Check your internet connection or try again later.".to_string())
425 }
426 _ => None,
427 }
428}
429
430fn check_keychain_accessible() -> Check {
431 let (passed, detail, suggestion) = match keychain::get_platform_keychain() {
432 Ok(keychain) => (
433 true,
434 format!("{} (accessible)", keychain.backend_name()),
435 None,
436 ),
437 Err(e) => (
438 false,
439 format!("inaccessible: {e}"),
440 Some("Run: auths init --profile developer".to_string()),
441 ),
442 };
443 Check {
444 name: "System keychain".to_string(),
445 passed,
446 detail,
447 suggestion,
448 category: CheckCategory::Critical,
449 }
450}
451
452fn check_identity_home(repo_override: Option<PathBuf>) -> Check {
459 let overridden = repo_override.is_some();
460 let (passed, detail, suggestion) = match resolve_registry_path(repo_override) {
461 Ok(path) => {
462 let source = if overridden {
463 "from --repo/AUTHS_REPO"
464 } else {
465 "default ~/.auths"
466 };
467 (true, format!("{} ({source})", path.display()), None)
468 }
469 Err(e) => (
470 false,
471 format!("cannot resolve: {e}"),
472 Some("Set AUTHS_REPO, or ensure a home directory is available".to_string()),
473 ),
474 };
475 Check {
476 name: "Identity home".to_string(),
477 passed,
478 detail,
479 suggestion,
480 category: CheckCategory::Advisory,
481 }
482}
483
484fn check_auths_repo(repo_override: Option<PathBuf>) -> Check {
485 let (passed, detail, suggestion) = match resolve_registry_path(repo_override) {
486 Ok(path) => {
487 if !path.exists() {
488 (
489 false,
490 format!("{} (not found)", path.display()),
491 Some("Run: auths init --profile developer".to_string()),
492 )
493 } else {
494 match crate::factories::storage::open_git_repo(&path) {
495 Ok(_) => (
496 true,
497 format!("{} (valid git repository)", path.display()),
498 None,
499 ),
500 Err(_) => (
501 false,
502 format!("{} (exists but not a valid git repo)", path.display()),
503 Some("Run: auths init --profile developer".to_string()),
504 ),
505 }
506 }
507 }
508 Err(e) => (
509 false,
510 format!("Cannot resolve path: {e}"),
511 Some("Run: auths init --profile developer".to_string()),
512 ),
513 };
514 Check {
515 name: "Auths directory".to_string(),
516 passed,
517 detail,
518 suggestion,
519 category: CheckCategory::Critical,
520 }
521}
522
523fn check_identity_valid(now: DateTime<Utc>, repo_override: Option<PathBuf>) -> Check {
524 let (passed, detail, suggestion) = match keychain::get_platform_keychain() {
525 Ok(keychain) => match keychain.list_aliases() {
526 Ok(aliases) if aliases.is_empty() => (
527 false,
528 "No keys found in keychain".to_string(),
529 Some("Run: auths init --profile developer (or: auths id create)".to_string()),
530 ),
531 Ok(aliases) => {
532 let key_count = aliases.len();
533 let expiry_info = check_attestation_expiry(now, repo_override);
534 match expiry_info {
535 ExpiryStatus::AllExpired(msg) => (
536 false,
537 format!("{key_count} key(s) found, but {msg}"),
538 Some("Run: auths device extend".to_string()),
539 ),
540 ExpiryStatus::ExpiringSoon(msg) => {
541 (true, format!("{key_count} key(s) found ({msg})"), None)
542 }
543 ExpiryStatus::Ok | ExpiryStatus::NoAttestations => {
544 (true, format!("{key_count} key(s) found"), None)
545 }
546 }
547 }
548 Err(e) => (
549 false,
550 format!("Failed to list keys: {e}"),
551 Some("Run: auths doctor (check keychain is accessible first)".to_string()),
552 ),
553 },
554 Err(_) => (
555 false,
556 "Keychain not accessible".to_string(),
557 Some("Run: auths init --profile developer".to_string()),
558 ),
559 };
560 Check {
561 name: "Auths identity".to_string(),
562 passed,
563 detail,
564 suggestion,
565 category: CheckCategory::Critical,
566 }
567}
568
569enum ExpiryStatus {
570 Ok,
571 NoAttestations,
572 ExpiringSoon(String),
573 AllExpired(String),
574}
575
576fn check_attestation_expiry(now: DateTime<Utc>, repo_override: Option<PathBuf>) -> ExpiryStatus {
577 use auths_sdk::storage::RegistryAttestationStorage;
578
579 let repo_path = match resolve_registry_path(repo_override) {
580 Ok(p) if p.exists() => p,
581 _ => return ExpiryStatus::NoAttestations,
582 };
583
584 let storage = RegistryAttestationStorage::new(&repo_path);
585 let attestations = match storage
586 .load_all_enriched()
587 .map(|v| v.into_iter().map(|e| e.attestation).collect::<Vec<_>>())
588 {
589 Ok(a) => a,
590 Err(_) => return ExpiryStatus::NoAttestations,
591 };
592
593 if attestations.is_empty() {
594 return ExpiryStatus::NoAttestations;
595 }
596
597 let active: Vec<_> = attestations
598 .iter()
599 .filter(|a| a.revoked_at.is_none())
600 .collect();
601
602 if active.is_empty() {
603 return ExpiryStatus::AllExpired("all attestations revoked".to_string());
604 }
605
606 let with_expiry: Vec<_> = active.iter().filter(|a| a.expires_at.is_some()).collect();
607
608 if with_expiry.is_empty() {
609 return ExpiryStatus::Ok;
610 }
611
612 let all_expired = with_expiry
613 .iter()
614 .all(|a| a.expires_at.is_some_and(|exp| exp < now));
615
616 if all_expired {
617 return ExpiryStatus::AllExpired("all attestations expired".to_string());
618 }
619
620 let warn_threshold = now + chrono::Duration::days(7);
621 let expiring_soon = with_expiry
622 .iter()
623 .any(|a| a.expires_at.is_some_and(|exp| exp < warn_threshold));
624
625 if expiring_soon {
626 return ExpiryStatus::ExpiringSoon("some attestations expiring within 7 days".to_string());
627 }
628
629 ExpiryStatus::Ok
630}
631
632fn check_registry_connectivity() -> Check {
640 let Ok(base) = std::env::var("AUTHS_REGISTRY_URL") else {
641 return Check {
642 name: "Registry connectivity".to_string(),
643 passed: true,
644 detail: "not configured (optional — signing and verification need no registry)"
645 .to_string(),
646 suggestion: None,
647 category: CheckCategory::Advisory,
648 };
649 };
650
651 let url = format!("{base}/health");
652 let client = reqwest::blocking::Client::builder()
653 .timeout(std::time::Duration::from_secs(5))
654 .build();
655
656 let (passed, detail) = match client {
657 Ok(client) => match client.get(&url).send() {
658 Ok(resp) if resp.status().is_success() => (true, format!("{base} (reachable)")),
659 Ok(resp) => (false, format!("{base} (HTTP {})", resp.status())),
660 Err(e) => (false, format!("unreachable: {e}")),
661 },
662 Err(e) => (false, format!("HTTP client error: {e}")),
663 };
664
665 Check {
666 name: "Registry connectivity".to_string(),
667 passed,
668 detail,
669 suggestion: if passed {
670 None
671 } else {
672 suggestion_for_check("Registry connectivity")
673 },
674 category: CheckCategory::Advisory,
675 }
676}
677
678fn print_report(report: &DoctorReport) {
680 let out = Output::new();
681
682 out.print_heading(&format!("Auths Doctor (v{})", report.version));
683 out.println("--------------------------");
684 out.newline();
685
686 for check in &report.checks {
687 let (icon, name_styled) = if check.passed {
688 (out.success("✓"), out.bold(&check.name))
689 } else {
690 (out.error("✗"), out.error(&check.name))
691 };
692
693 out.println(&format!("[{icon}] {name_styled}: {}", check.detail));
694
695 if let Some(ref suggestion) = check.suggestion {
696 out.println(&format!(" -> {}", out.dim(suggestion)));
697 }
698 }
699
700 if !report.fixes_applied.is_empty() {
701 out.newline();
702 out.print_heading("Fixes applied:");
703 for fix in &report.fixes_applied {
704 out.println(&format!(" {} — {}", out.success(&fix.name), fix.message));
705 }
706 }
707
708 out.newline();
709
710 let passed_count = report.checks.iter().filter(|c| c.passed).count();
711 let failed_count = report.checks.len() - passed_count;
712
713 let summary = format!(
714 "Summary: {} passed, {} failed",
715 out.success(&passed_count.to_string()),
716 out.error(&failed_count.to_string())
717 );
718 out.println(&summary);
719 out.newline();
720
721 if report.all_pass {
722 out.print_success("All checks passed! Your system is ready.");
723 } else {
724 out.print_error("Some checks failed. Please review the suggestions above.");
725 }
726}
727
728impl crate::commands::executable::ExecutableCommand for DoctorCommand {
729 fn execute(&self, ctx: &crate::config::CliConfig) -> anyhow::Result<()> {
730 handle_doctor(self.clone(), ctx.repo_path.clone())
731 }
732}
733
734#[cfg(test)]
735mod tests {
736 #[test]
737 fn test_keychain_check_suggestion_is_exact_command() {
738 let suggestion = "Run: auths init --profile developer";
739 assert!(
740 suggestion.starts_with("Run:"),
741 "suggestion must start with 'Run:'"
742 );
743 }
744
745 #[test]
746 fn test_workflow_includes_version_and_user_checks() {
747 use super::*;
748 let adapter = PosixDiagnosticAdapter;
749 let workflow = DiagnosticsWorkflow::new(&adapter, &adapter);
750 let report = workflow.run().unwrap();
751
752 let check_names: Vec<&str> = report.checks.iter().map(|c| c.name.as_str()).collect();
753 assert!(
754 check_names.contains(&"Git signing config"),
755 "signing config check must exist"
756 );
757 assert!(
758 check_names.contains(&"Git version"),
759 "git version check must exist"
760 );
761 assert!(
762 check_names.contains(&"Git user identity"),
763 "git user identity check must exist"
764 );
765 }
766
767 #[test]
768 fn test_all_failed_checks_have_exact_runnable_suggestions() {
769 let suggestions: Vec<Option<String>> = vec![
770 Some("Run: auths init --profile developer".to_string()),
771 Some("Run: auths id init".to_string()),
772 Some("Run: git config --global gpg.format ssh".to_string()),
773 Some("Run: auths init --profile developer".to_string()),
774 ];
775 for text in suggestions.into_iter().flatten() {
776 assert!(text.starts_with("Run:"), "bad suggestion: {}", text);
777 }
778 }
779
780 #[test]
781 fn test_suggestion_for_all_new_checks() {
782 use super::suggestion_for_check;
783 assert!(suggestion_for_check("Git version").is_some());
784 assert!(suggestion_for_check("Git user identity").is_some());
785 assert!(suggestion_for_check("Auths directory").is_some());
786 assert!(suggestion_for_check("Registry connectivity").is_some());
787 }
788}