feat: add doctor command for diagnosing installs and cleaning stale daemon state (#1254)
* feat: add `doctor` command for install diagnostics and cleanup Adds `agent-browser doctor`, a one-shot diagnostic that checks environment, Chrome install, daemon state, config, encryption key, providers, network reachability, and a live headless launch test. Auto-cleans stale `.sock` / `.pid` / `.version` / `.stream` sidecar files on every run. Destructive repairs (reinstall Chrome, purge old state, close version-mismatched daemons, generate missing encryption key) are gated behind `--fix`. Supports `--offline`, `--quick`, and `--json`. * fixes
This commit is contained in:
@@ -0,0 +1,289 @@
|
||||
//! Diagnose an agent-browser installation.
|
||||
//!
|
||||
//! Runs a battery of checks across environment, Chrome install, daemon
|
||||
//! state, config files, encryption, providers, network reachability, and
|
||||
//! a live headless browser launch test.
|
||||
//!
|
||||
//! Auto-cleans stale daemon socket/pid/version sidecar files. Destructive
|
||||
//! repairs (reinstalling Chrome, purging old state files, generating a
|
||||
//! missing encryption key) are gated behind `--fix`.
|
||||
|
||||
mod chrome;
|
||||
mod config;
|
||||
mod daemon;
|
||||
mod environment;
|
||||
mod fix;
|
||||
mod helpers;
|
||||
mod launch;
|
||||
mod network;
|
||||
mod providers;
|
||||
mod security;
|
||||
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use crate::color;
|
||||
|
||||
#[derive(Default, Clone, Copy)]
|
||||
pub struct DoctorOptions {
|
||||
pub offline: bool,
|
||||
pub quick: bool,
|
||||
pub fix: bool,
|
||||
pub json: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
#[repr(u8)]
|
||||
pub(crate) enum Status {
|
||||
Pass,
|
||||
Warn,
|
||||
Fail,
|
||||
Info,
|
||||
}
|
||||
|
||||
impl Status {
|
||||
fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Status::Pass => "pass",
|
||||
Status::Warn => "warn",
|
||||
Status::Fail => "fail",
|
||||
Status::Info => "info",
|
||||
}
|
||||
}
|
||||
|
||||
fn label(&self) -> String {
|
||||
match self {
|
||||
Status::Pass => color::green("pass"),
|
||||
Status::Warn => color::yellow("warn"),
|
||||
Status::Fail => color::red("fail"),
|
||||
Status::Info => color::dim("info"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct Check {
|
||||
pub id: String,
|
||||
pub category: &'static str,
|
||||
pub status: Status,
|
||||
pub message: String,
|
||||
pub fix: Option<String>,
|
||||
}
|
||||
|
||||
impl Check {
|
||||
fn new(
|
||||
id: impl Into<String>,
|
||||
category: &'static str,
|
||||
status: Status,
|
||||
message: impl Into<String>,
|
||||
) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
category,
|
||||
status,
|
||||
message: message.into(),
|
||||
fix: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn with_fix(mut self, fix: impl Into<String>) -> Self {
|
||||
self.fix = Some(fix.into());
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// Run the doctor command. Returns the process exit code.
|
||||
pub fn run_doctor(opts: DoctorOptions) -> i32 {
|
||||
let mut checks: Vec<Check> = Vec::new();
|
||||
let mut fixed: Vec<String> = Vec::new();
|
||||
|
||||
environment::check(&mut checks);
|
||||
chrome::check(&mut checks);
|
||||
daemon::check(&mut checks);
|
||||
config::check(&mut checks);
|
||||
security::check(&mut checks);
|
||||
providers::check(&mut checks);
|
||||
|
||||
if !opts.offline {
|
||||
network::check(&mut checks);
|
||||
}
|
||||
|
||||
if !opts.quick {
|
||||
launch::check(&mut checks);
|
||||
}
|
||||
|
||||
if opts.fix {
|
||||
fix::run(&mut checks, &mut fixed);
|
||||
}
|
||||
|
||||
let summary = summarize(&checks);
|
||||
let exit_code = if summary.fail > 0 { 1 } else { 0 };
|
||||
|
||||
if opts.json {
|
||||
print_json(&checks, &summary, &fixed, exit_code == 0);
|
||||
} else {
|
||||
print_text(&checks, &summary, &fixed, opts.fix);
|
||||
}
|
||||
|
||||
exit_code
|
||||
}
|
||||
|
||||
struct Summary {
|
||||
pass: usize,
|
||||
warn: usize,
|
||||
fail: usize,
|
||||
}
|
||||
|
||||
fn summarize(checks: &[Check]) -> Summary {
|
||||
let mut s = Summary {
|
||||
pass: 0,
|
||||
warn: 0,
|
||||
fail: 0,
|
||||
};
|
||||
for c in checks {
|
||||
match c.status {
|
||||
Status::Pass => s.pass += 1,
|
||||
Status::Warn => s.warn += 1,
|
||||
Status::Fail => s.fail += 1,
|
||||
Status::Info => {}
|
||||
}
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
fn print_text(checks: &[Check], summary: &Summary, fixed: &[String], fix_ran: bool) {
|
||||
println!("{}", color::bold("agent-browser doctor"));
|
||||
|
||||
let mut current_category = "";
|
||||
for c in checks {
|
||||
if c.category != current_category {
|
||||
current_category = c.category;
|
||||
println!();
|
||||
println!("{}", color::bold(current_category));
|
||||
}
|
||||
println!(" {} {}", c.status.label(), c.message);
|
||||
if let Some(fix) = &c.fix {
|
||||
println!(" {} {}", color::dim("fix:"), fix);
|
||||
}
|
||||
}
|
||||
|
||||
if !fixed.is_empty() {
|
||||
println!();
|
||||
println!("{}", color::bold("Fixed"));
|
||||
for line in fixed {
|
||||
println!(" {} {}", color::green("done"), line);
|
||||
}
|
||||
}
|
||||
|
||||
println!();
|
||||
let line = format!(
|
||||
"Summary: {} pass, {} warn, {} fail",
|
||||
summary.pass, summary.warn, summary.fail
|
||||
);
|
||||
if summary.fail > 0 {
|
||||
println!("{}", color::red(&line));
|
||||
} else if summary.warn > 0 {
|
||||
println!("{}", color::yellow(&line));
|
||||
} else {
|
||||
println!("{}", color::green(&line));
|
||||
}
|
||||
|
||||
if !fix_ran && checks.iter().any(|c| c.fix.is_some()) {
|
||||
println!();
|
||||
println!(
|
||||
"{} Run with {} to attempt repairs.",
|
||||
color::dim("tip:"),
|
||||
color::bold("--fix")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn print_json(checks: &[Check], summary: &Summary, fixed: &[String], success: bool) {
|
||||
let checks_json: Vec<Value> = checks
|
||||
.iter()
|
||||
.map(|c| {
|
||||
let mut obj = json!({
|
||||
"id": c.id,
|
||||
"category": c.category,
|
||||
"status": c.status.as_str(),
|
||||
"message": c.message,
|
||||
});
|
||||
if let Some(fix) = &c.fix {
|
||||
obj["fix"] = json!(fix);
|
||||
}
|
||||
obj
|
||||
})
|
||||
.collect();
|
||||
|
||||
let payload = json!({
|
||||
"success": success,
|
||||
"summary": {
|
||||
"pass": summary.pass,
|
||||
"warn": summary.warn,
|
||||
"fail": summary.fail,
|
||||
},
|
||||
"checks": checks_json,
|
||||
"fixed": fixed,
|
||||
});
|
||||
println!("{}", payload);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_summary_counts_each_status() {
|
||||
let checks = vec![
|
||||
Check::new("a", "Cat", Status::Pass, "ok"),
|
||||
Check::new("b", "Cat", Status::Pass, "ok"),
|
||||
Check::new("c", "Cat", Status::Warn, "meh"),
|
||||
Check::new("d", "Cat", Status::Fail, "no"),
|
||||
Check::new("e", "Cat", Status::Info, "fyi"),
|
||||
];
|
||||
let s = summarize(&checks);
|
||||
assert_eq!(s.pass, 2);
|
||||
assert_eq!(s.warn, 1);
|
||||
assert_eq!(s.fail, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_summary_zeroes_when_only_info() {
|
||||
let checks = vec![Check::new("a", "Cat", Status::Info, "ignored")];
|
||||
let s = summarize(&checks);
|
||||
assert_eq!(s.pass, 0);
|
||||
assert_eq!(s.warn, 0);
|
||||
assert_eq!(s.fail, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_status_label_does_not_panic() {
|
||||
for s in &[Status::Pass, Status::Warn, Status::Fail, Status::Info] {
|
||||
assert!(!s.label().is_empty());
|
||||
assert!(!s.as_str().is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_status_as_str_values() {
|
||||
assert_eq!(Status::Pass.as_str(), "pass");
|
||||
assert_eq!(Status::Warn.as_str(), "warn");
|
||||
assert_eq!(Status::Fail.as_str(), "fail");
|
||||
assert_eq!(Status::Info.as_str(), "info");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_check_new_and_with_fix() {
|
||||
let c = Check::new("id", "cat", Status::Warn, "msg").with_fix("do thing");
|
||||
assert_eq!(c.id, "id");
|
||||
assert_eq!(c.category, "cat");
|
||||
assert_eq!(c.status, Status::Warn);
|
||||
assert_eq!(c.message, "msg");
|
||||
assert_eq!(c.fix.as_deref(), Some("do thing"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_check_new_no_fix_by_default() {
|
||||
let c = Check::new("id", "cat", Status::Pass, "msg");
|
||||
assert!(c.fix.is_none());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user