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: network connectivity
182    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
535/// Print the report in human-readable format.
536fn 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}