Skip to main content

auths_cli/commands/
doctor.rs

1//! Comprehensive health check command for Auths.
2
3use 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/// Health check command.
18#[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    /// Auto-fix issues where possible
38    #[clap(long)]
39    pub fix: bool,
40}
41
42/// A single health check.
43#[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/// Overall doctor report.
58#[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
67/// Handle the doctor command.
68pub 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    // Compute exit code based on check categories
92    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
124/// Compute exit code based on check categories.
125///
126/// Returns:
127/// * 0 — all checks pass
128/// * 1 — at least one Critical check fails (Auths is non-functional)
129/// * 2 — all Critical checks pass, at least one Advisory check fails
130fn 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/// Run all prerequisite checks.
151#[allow(clippy::disallowed_methods)] // CLI boundary: Utc::now() injected here
152fn 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    // Domain checks are all Critical
177    checks.push(check_keychain_accessible());
178    checks.push(check_auths_repo());
179    checks.push(check_identity_valid(now));
180
181    // Advisory: commit-trailer hook wiring (plain `git commit` verifiability)
182    checks.push(check_commit_hook_installed());
183    checks.push(check_repo_hooks_path_override());
184
185    // Advisory: network connectivity
186    checks.push(check_registry_connectivity());
187
188    checks
189}
190
191/// Advisory: the prepare-commit-msg hook is installed, current, and wired via
192/// the global `core.hooksPath` — what makes a plain `git commit` verifiable.
193fn 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            // No identity / pre-hook install — not a failure, just not set up yet.
197            (
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
235/// Advisory: a repo-local `core.hooksPath` (husky-style hook managers) bypasses
236/// the global auths hook — commits in this repo won't carry trailers unless the
237/// trailer hook is added to that directory.
238fn 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
618/// Print the report in human-readable format.
619fn 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}