use anyhow::Result;
use clap::Args;
use serde::Serialize;
use serde_json::json;
use crate::api::DeveloperClient;
use crate::config::CliConfig;
use crate::output;
#[derive(Args)]
pub struct DoctorArgs {
#[arg(long)]
pub offline: bool,
#[arg(long)]
pub json: bool,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct Check {
name: String,
status: Status,
detail: String,
}
#[derive(Serialize, PartialEq, Clone, Copy)]
#[serde(rename_all = "lowercase")]
enum Status {
Ok,
Warn,
Fail,
}
struct VersionSpec {
bin: &'static str,
args: &'static [&'static str],
min_major: Option<u32>,
missing_status: Status,
hint: &'static str,
}
const VERSION_SPECS: &[VersionSpec] = &[
VersionSpec {
bin: "node",
args: &["--version"],
min_major: Some(18),
missing_status: Status::Fail,
hint: "Node.js >= 18 is required for React Native and Next.js projects",
},
VersionSpec {
bin: "npm",
args: &["--version"],
min_major: None,
missing_status: Status::Fail,
hint: "npm is required to install SDK packages",
},
VersionSpec {
bin: "git",
args: &["--version"],
min_major: None,
missing_status: Status::Warn,
hint: "git is recommended for project history",
},
VersionSpec {
bin: "watchman",
args: &["--version"],
min_major: None,
missing_status: Status::Warn,
hint: "watchman speeds up React Native development (optional)",
},
VersionSpec {
bin: "pod",
args: &["--version"],
min_major: None,
missing_status: Status::Warn,
hint: "CocoaPods is needed for iOS builds (macOS only)",
},
VersionSpec {
bin: "java",
args: &["-version"],
min_major: None,
missing_status: Status::Warn,
hint: "a JDK is needed for Android builds",
},
];
const NOT_LOGGED_IN: &str = "not logged in; run `offline login` (or `offline login --email` \
for `setup`, `orgs`, `apps` and `keys`)";
pub async fn run(args: DoctorArgs) -> Result<()> {
let mut checks = parallel_version_checks();
checks.push(
match std::env::var("ANDROID_HOME").or_else(|_| std::env::var("ANDROID_SDK_ROOT")) {
Ok(path) if !path.is_empty() => Check {
name: "android-sdk".into(),
status: Status::Ok,
detail: path,
},
_ => Check {
name: "android-sdk".into(),
status: Status::Warn,
detail: "ANDROID_HOME not set — needed for Android builds".into(),
},
},
);
checks.push(match offline_registry::Registry::load_default() {
Ok(r) => Check {
name: "registry".into(),
status: Status::Ok,
detail: format!(
"{} packages, {} workflows, {} skills, {} templates",
r.packages().count(),
r.workflows().count(),
r.skills().count(),
r.templates().count()
),
},
Err(e) => Check {
name: "registry".into(),
status: Status::Fail,
detail: format!("registry failed to load: {e}"),
},
});
let config = CliConfig::load().unwrap_or_default();
if let Some(key) = config.effective_api_key() {
if args.offline {
checks.push(Check {
name: "auth".into(),
status: Status::Ok,
detail: format!(
"API key present ({})",
config.email.as_deref().unwrap_or("identity unverified")
),
});
} else {
checks.push(match DeveloperClient::new(config.effective_api_base()) {
Ok(client) => match client.cli_me(&key).await {
Ok(me) => {
let app = me.app_id.as_deref().unwrap_or("(unknown app)");
let telemetry = me
.product_settings
.as_ref()
.map(|s| s.mesh_sdk.mesh_telemetry);
let detail = match telemetry {
Some(true) => {
format!("valid key for app {app}; mesh telemetry enabled")
}
Some(false) => {
format!("valid key for app {app}; mesh telemetry disabled")
}
None => format!("valid key for app {app}"),
};
Check {
name: "auth".into(),
status: Status::Ok,
detail,
}
}
Err(e) => Check {
name: "auth".into(),
status: Status::Warn,
detail: format!("API key present but invalid: {e:#}"),
},
},
Err(e) => Check {
name: "auth".into(),
status: Status::Warn,
detail: format!("could not verify API key: {e:#}"),
},
});
}
} else if config.effective_access_token().is_some() {
checks.push(Check {
name: "auth".into(),
status: Status::Ok,
detail: format!(
"developer JWT present ({}) — run `offline setup` for an API key",
config.email.as_deref().unwrap_or("email unknown")
),
});
} else {
checks.push(Check {
name: "auth".into(),
status: Status::Warn,
detail: NOT_LOGGED_IN.into(),
});
}
if !args.offline {
let base = config.effective_api_base();
checks.push(match DeveloperClient::new(&base) {
Ok(client) => match client.health().await {
Ok(()) => Check {
name: "api-gateway".into(),
status: Status::Ok,
detail: base,
},
Err(e) => Check {
name: "api-gateway".into(),
status: Status::Warn,
detail: format!("{base}: {e} (offline development still works)"),
},
},
Err(e) => Check {
name: "api-gateway".into(),
status: Status::Warn,
detail: format!("{base}: {e} (offline development still works)"),
},
});
}
let failed = checks.iter().any(|c| c.status == Status::Fail);
if args.json {
output::json(&json!({
"ok": !failed,
"checks": checks,
}))?;
} else {
output::heading("offline doctor");
for c in &checks {
let badge = match c.status {
Status::Ok => console::style("ok ").green(),
Status::Warn => console::style("warn").yellow(),
Status::Fail => console::style("FAIL").red().bold(),
};
println!(" [{badge}] {:<12} {}", c.name, c.detail);
}
println!();
if failed {
output::warn("some required checks failed — fix the FAIL items above");
} else {
output::success("environment looks good");
}
}
if failed {
std::process::exit(1);
}
Ok(())
}
fn parallel_version_checks() -> Vec<Check> {
std::thread::scope(|scope| {
VERSION_SPECS
.iter()
.map(|spec| {
scope.spawn(|| {
version_check(
spec.bin,
spec.args,
spec.min_major,
spec.missing_status,
spec.hint,
)
})
})
.map(|handle| handle.join().expect("doctor version check thread"))
.collect()
})
}
fn version_check(
bin: &str,
args: &[&str],
min_major: Option<u32>,
missing_status: Status,
hint: &str,
) -> Check {
let result = std::process::Command::new(bin).args(args).output();
match result {
Ok(out) if out.status.success() => {
let stdout = String::from_utf8_lossy(&out.stdout);
let stderr = String::from_utf8_lossy(&out.stderr);
let raw = if stdout.trim().is_empty() {
stderr
} else {
stdout
};
let version = raw.lines().next().unwrap_or("").trim().to_string();
if let Some(min) = min_major {
let major = version
.trim_start_matches(|c: char| !c.is_ascii_digit())
.split('.')
.next()
.and_then(|m| m.parse::<u32>().ok());
if let Some(major) = major {
if major < min {
return Check {
name: bin.into(),
status: Status::Fail,
detail: format!("{version} — need >= {min}"),
};
}
}
}
Check {
name: bin.into(),
status: Status::Ok,
detail: version,
}
}
_ => Check {
name: bin.into(),
status: missing_status,
detail: format!("not found — {hint}"),
},
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn not_logged_in_hint_leads_with_browser_login() {
assert!(NOT_LOGGED_IN.starts_with("not logged in; run `offline login` ("));
assert!(NOT_LOGGED_IN.contains("`offline login --email` for `setup`"));
}
}