diff --git a/Cargo.lock b/Cargo.lock index 78560a0af..4a83111fe 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -172,8 +172,10 @@ dependencies = [ "arc-util", "arc-workflows", "assert_cmd", + "bollard", "chrono", "clap", + "daytona-sdk", "dirs", "dotenvy", "httpmock", diff --git a/apps/arc-web/app/lib/config.server.ts b/apps/arc-web/app/lib/config.server.ts index 4231c2a83..26a3204e5 100644 --- a/apps/arc-web/app/lib/config.server.ts +++ b/apps/arc-web/app/lib/config.server.ts @@ -19,8 +19,13 @@ interface GitConfig { client_id: string | null; } -export interface AppConfig { +interface WebConfig { + url: string; auth: AuthConfig; +} + +export interface AppConfig { + web: WebConfig; api: ApiConfig; git: GitConfig; } @@ -30,6 +35,11 @@ const AUTH_DEFAULTS: AuthConfig = { allowed_usernames: [], }; +const WEB_DEFAULTS: WebConfig = { + url: "http://localhost:5173", + auth: AUTH_DEFAULTS, +}; + const API_DEFAULTS: ApiConfig = { base_url: "http://localhost:3000", authentication_strategy: "jwt", @@ -53,12 +63,17 @@ function loadAppConfig(): AppConfig { // File doesn't exist or is unreadable — use defaults } - const rawAuth = (raw.auth ?? {}) as Partial; + const rawWeb = (raw.web ?? {}) as Record; + const rawWebAuth = (rawWeb.auth ?? {}) as Partial; const rawApi = (raw.api ?? {}) as Partial; const rawGit = (raw.git ?? {}) as Partial; return { - auth: { ...AUTH_DEFAULTS, ...rawAuth }, + web: { + ...WEB_DEFAULTS, + url: (rawWeb.url as string) ?? WEB_DEFAULTS.url, + auth: { ...AUTH_DEFAULTS, ...rawWebAuth }, + }, api: { ...API_DEFAULTS, ...rawApi }, git: { ...GIT_DEFAULTS, ...rawGit }, }; diff --git a/apps/arc-web/app/routes/auth-callback.tsx b/apps/arc-web/app/routes/auth-callback.tsx index a09395108..ac015a9f6 100644 --- a/apps/arc-web/app/routes/auth-callback.tsx +++ b/apps/arc-web/app/routes/auth-callback.tsx @@ -43,7 +43,7 @@ export async function loader({ request }: Route.LoaderArgs) { }>; const primaryEmail = emails.find((e) => e.primary && e.verified)?.email ?? ""; - const { allowed_usernames } = getAppConfig().auth; + const { allowed_usernames } = getAppConfig().web.auth; if (allowed_usernames.length > 0 && !allowed_usernames.includes(profile.login)) { throw redirect("/auth/login?error=unauthorized"); } diff --git a/crates/arc-agent/src/cli.rs b/crates/arc-agent/src/cli.rs index 9dd12c6e9..05ab41fc6 100644 --- a/crates/arc-agent/src/cli.rs +++ b/crates/arc-agent/src/cli.rs @@ -191,20 +191,6 @@ fn build_profile( } } -fn validate_api_key(provider: Provider) -> bool { - match provider { - Provider::Anthropic => std::env::var("ANTHROPIC_API_KEY").is_ok(), - Provider::OpenAi => std::env::var("OPENAI_API_KEY").is_ok(), - Provider::Gemini => { - std::env::var("GEMINI_API_KEY").is_ok() || std::env::var("GOOGLE_API_KEY").is_ok() - } - Provider::Kimi => std::env::var("KIMI_API_KEY").is_ok(), - Provider::Zai => std::env::var("ZAI_API_KEY").is_ok(), - Provider::Minimax => std::env::var("MINIMAX_API_KEY").is_ok(), - Provider::Inception => std::env::var("INCEPTION_API_KEY").is_ok(), - } -} - fn format_tool_args(args: &serde_json::Value, cwd: &str) -> String { let cwd_prefix = if cwd.ends_with('/') { cwd.to_string() @@ -359,7 +345,7 @@ pub async fn run_with_args(args: AgentArgs) -> anyhow::Result<()> { .map_err(|e: String| anyhow::anyhow!("{e}"))?; // Validate provider API key - if !validate_api_key(provider) { + if !provider.has_api_key() { anyhow::bail!("API key not set for provider '{provider}'"); } diff --git a/crates/arc-api/src/server_config.rs b/crates/arc-api/src/server_config.rs index 65374ae62..b2ecf8bcb 100644 --- a/crates/arc-api/src/server_config.rs +++ b/crates/arc-api/src/server_config.rs @@ -63,11 +63,32 @@ pub struct GitConfig { pub client_id: Option, } +#[derive(Debug, Clone, Deserialize, PartialEq)] +pub struct WebConfig { + #[serde(default = "default_web_url")] + pub url: String, + #[serde(default)] + pub auth: AuthConfig, +} + +fn default_web_url() -> String { + "http://localhost:5173".to_string() +} + +impl Default for WebConfig { + fn default() -> Self { + Self { + url: default_web_url(), + auth: AuthConfig::default(), + } + } +} + #[derive(Debug, Default, Deserialize)] pub struct ServerConfig { pub data_dir: Option, #[serde(default)] - pub auth: AuthConfig, + pub web: WebConfig, #[serde(default)] pub api: ApiConfig, #[serde(default)] @@ -141,7 +162,10 @@ mod tests { #[test] fn parse_full_config() { let toml = r#" -[auth] +[web] +url = "https://arc.example.com" + +[web.auth] provider = "github" allowed_usernames = ["brynary", "alice"] @@ -155,8 +179,9 @@ app_id = "12345" client_id = "Iv1.abc123" "#; let config: ServerConfig = toml::from_str(toml).unwrap(); - assert_eq!(config.auth.provider, AuthProvider::Github); - assert_eq!(config.auth.allowed_usernames, vec!["brynary", "alice"]); + assert_eq!(config.web.url, "https://arc.example.com"); + assert_eq!(config.web.auth.provider, AuthProvider::Github); + assert_eq!(config.web.auth.allowed_usernames, vec!["brynary", "alice"]); assert_eq!(config.api.base_url, "http://example.com:8080"); assert_eq!( config.api.authentication_strategy, @@ -168,11 +193,12 @@ client_id = "Iv1.abc123" } #[test] - fn parse_auth_defaults() { + fn parse_web_defaults() { let toml = ""; let config: ServerConfig = toml::from_str(toml).unwrap(); - assert_eq!(config.auth.provider, AuthProvider::Github); - assert!(config.auth.allowed_usernames.is_empty()); + assert_eq!(config.web.url, "http://localhost:5173"); + assert_eq!(config.web.auth.provider, AuthProvider::Github); + assert!(config.web.auth.allowed_usernames.is_empty()); } #[test] @@ -235,7 +261,7 @@ repo_url = "https://github.com/org/repo" #[test] fn parse_config_server_and_run_defaults_together() { let toml = r#" -[auth] +[web.auth] provider = "github" [git] @@ -246,7 +272,7 @@ app_id = "123" model = "gpt-4" "#; let config: ServerConfig = toml::from_str(toml).unwrap(); - assert_eq!(config.auth.provider, AuthProvider::Github); + assert_eq!(config.web.auth.provider, AuthProvider::Github); assert_eq!(config.git.app_id.as_deref(), Some("123")); let llm = config.run_defaults.llm.unwrap(); assert_eq!(llm.model.as_deref(), Some("gpt-4")); @@ -255,14 +281,14 @@ model = "gpt-4" #[test] fn parse_insecure_disabled_values() { let toml = r#" -[auth] +[web.auth] provider = "insecure_disabled" [api] authentication_strategy = "insecure_disabled" "#; let config: ServerConfig = toml::from_str(toml).unwrap(); - assert_eq!(config.auth.provider, AuthProvider::InsecureDisabled); + assert_eq!(config.web.auth.provider, AuthProvider::InsecureDisabled); assert_eq!( config.api.authentication_strategy, ApiAuthenticationStrategy::InsecureDisabled diff --git a/crates/arc-cli/Cargo.toml b/crates/arc-cli/Cargo.toml index da7cf40fe..e07d3b29c 100644 --- a/crates/arc-cli/Cargo.toml +++ b/crates/arc-cli/Cargo.toml @@ -15,7 +15,9 @@ arc-agent = { path = "../arc-agent" } arc-workflows = { path = "../arc-workflows" } arc-api = { path = "../arc-api" } arc-util = { path = "../arc-util" } +bollard.workspace = true clap.workspace = true +daytona-sdk.workspace = true anyhow.workspace = true dotenvy.workspace = true tokio.workspace = true diff --git a/crates/arc-cli/src/doctor.rs b/crates/arc-cli/src/doctor.rs new file mode 100644 index 000000000..aeaaaff7a --- /dev/null +++ b/crates/arc-cli/src/doctor.rs @@ -0,0 +1,915 @@ +use std::fmt::Write; +use std::path::PathBuf; + +use arc_api::server_config::{ApiAuthenticationStrategy, AuthProvider}; +use arc_llm::provider::Provider; +use arc_util::terminal::Styles; + +// --------------------------------------------------------------------------- +// Core types +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CheckStatus { + Pass, + Warning, + Error, +} + +#[derive(Debug, Clone)] +pub struct CheckDetail { + pub text: String, +} + +#[derive(Debug, Clone)] +pub struct CheckResult { + pub name: String, + pub status: CheckStatus, + pub summary: String, + pub details: Vec, + pub remediation: Option, +} + +pub struct DoctorReport { + pub checks: Vec, +} + +impl DoctorReport { + pub fn has_errors(&self) -> bool { + self.checks + .iter() + .any(|c| c.status == CheckStatus::Error) + } + + pub fn issue_count(&self) -> usize { + self.checks + .iter() + .filter(|c| matches!(c.status, CheckStatus::Warning | CheckStatus::Error)) + .count() + } + + pub fn render(&self, s: &Styles, verbose: bool) -> String { + let mut out = String::new(); + + writeln!(out, "{b}Arc Doctor{r}", b = s.bold, r = s.reset).unwrap(); + writeln!(out).unwrap(); + + for check in &self.checks { + let (icon, color) = match check.status { + CheckStatus::Pass => ("[✓]", s.green), + CheckStatus::Warning => ("[!]", s.yellow), + CheckStatus::Error => ("[✗]", s.red), + }; + + writeln!( + out, + " {color}{icon}{r} {b}{name}{r} ({summary})", + color = color, + r = s.reset, + b = s.bold, + name = check.name, + summary = check.summary, + ) + .unwrap(); + + if verbose { + for detail in &check.details { + writeln!(out, " • {}", detail.text).unwrap(); + } + } + } + + let issues = self.issue_count(); + writeln!(out).unwrap(); + + if issues == 0 { + writeln!(out, "All checks passed.").unwrap(); + } else { + writeln!( + out, + "Doctor found issues in {issues} {}.", + if issues == 1 { "category" } else { "categories" } + ) + .unwrap(); + + let errors: Vec<_> = self + .checks + .iter() + .filter(|c| c.status == CheckStatus::Error) + .collect(); + if !errors.is_empty() { + writeln!(out).unwrap(); + writeln!(out, "{b}Errors:{r}", b = s.bold, r = s.reset).unwrap(); + for check in &errors { + write!(out, " • {}", check.name).unwrap(); + if let Some(ref rem) = check.remediation { + write!(out, " — {rem}").unwrap(); + } + writeln!(out).unwrap(); + } + } + + let warnings: Vec<_> = self + .checks + .iter() + .filter(|c| c.status == CheckStatus::Warning) + .collect(); + if !warnings.is_empty() { + writeln!(out).unwrap(); + writeln!(out, "{b}Warnings:{r}", b = s.bold, r = s.reset).unwrap(); + for check in &warnings { + write!(out, " • {}", check.name).unwrap(); + if let Some(ref rem) = check.remediation { + write!(out, " — {rem}").unwrap(); + } + writeln!(out).unwrap(); + } + } + } + + out + } +} + +// --------------------------------------------------------------------------- +// Check functions (pure, testable) +// --------------------------------------------------------------------------- + +pub fn check_config(path: Option) -> CheckResult { + match path { + Some(p) => CheckResult { + name: "Configuration".to_string(), + status: CheckStatus::Pass, + summary: p.display().to_string(), + details: vec![CheckDetail { + text: format!("Loaded from {}", p.display()), + }], + remediation: None, + }, + None => CheckResult { + name: "Configuration".to_string(), + status: CheckStatus::Warning, + summary: "no config file found".to_string(), + details: vec![CheckDetail { + text: "Create ~/.arc/arc.toml to configure Arc".to_string(), + }], + remediation: Some("Create ~/.arc/arc.toml".to_string()), + }, + } +} + +pub fn check_llm_providers(statuses: &[(Provider, bool)]) -> CheckResult { + let configured: Vec<_> = statuses.iter().filter(|(_, set)| *set).collect(); + let total = statuses.len(); + let count = configured.len(); + + let details: Vec = statuses + .iter() + .map(|(provider, set)| { + let env_vars = provider.api_key_env_vars().join(" or "); + let status_text = if *set { "set" } else { "not set" }; + CheckDetail { + text: format!("{provider} ({env_vars}): {status_text}"), + } + }) + .collect(); + + if count == 0 { + CheckResult { + name: "LLM providers".to_string(), + status: CheckStatus::Error, + summary: format!("{count} of {total} configured"), + details, + remediation: Some("Set at least one provider API key".to_string()), + } + } else { + CheckResult { + name: "LLM providers".to_string(), + status: CheckStatus::Pass, + summary: format!("{count} of {total} configured"), + details, + remediation: None, + } + } +} + +pub fn check_brave_search(api_key_set: bool) -> CheckResult { + if api_key_set { + CheckResult { + name: "Brave Search".to_string(), + status: CheckStatus::Pass, + summary: "API key set".to_string(), + details: vec![CheckDetail { + text: "BRAVE_SEARCH_API_KEY is set".to_string(), + }], + remediation: None, + } + } else { + CheckResult { + name: "Brave Search".to_string(), + status: CheckStatus::Warning, + summary: "not configured".to_string(), + details: vec![CheckDetail { + text: "BRAVE_SEARCH_API_KEY is not set".to_string(), + }], + remediation: Some("Set BRAVE_SEARCH_API_KEY to enable web search".to_string()), + } + } +} + +pub struct SandboxStatus { + pub daytona: Option>, + pub docker: Option>, +} + +pub fn check_sandbox(status: &SandboxStatus) -> CheckResult { + let mut available = Vec::new(); + let mut details = Vec::new(); + let mut errors = Vec::new(); + + match &status.daytona { + Some(Ok(())) => { + available.push("Daytona"); + details.push(CheckDetail { + text: "Daytona (DAYTONA_API_KEY): available".to_string(), + }); + } + Some(Err(e)) => { + errors.push(format!("Daytona: {e}")); + details.push(CheckDetail { + text: format!("Daytona (DAYTONA_API_KEY): error — {e}"), + }); + } + None => { + details.push(CheckDetail { + text: "Daytona (DAYTONA_API_KEY): not configured".to_string(), + }); + } + } + + match &status.docker { + Some(Ok(())) => { + available.push("Docker"); + details.push(CheckDetail { + text: "Docker: available".to_string(), + }); + } + Some(Err(e)) => { + errors.push(format!("Docker: {e}")); + details.push(CheckDetail { + text: format!("Docker: error — {e}"), + }); + } + None => { + details.push(CheckDetail { + text: "Docker: not available".to_string(), + }); + } + } + + if !errors.is_empty() { + CheckResult { + name: "Sandbox".to_string(), + status: CheckStatus::Error, + summary: errors.join("; "), + details, + remediation: Some("Fix sandbox configuration errors".to_string()), + } + } else if available.is_empty() { + CheckResult { + name: "Sandbox".to_string(), + status: CheckStatus::Warning, + summary: "no sandbox available".to_string(), + details, + remediation: Some( + "Install Docker or set DAYTONA_API_KEY to enable sandboxed execution".to_string(), + ), + } + } else { + CheckResult { + name: "Sandbox".to_string(), + status: CheckStatus::Pass, + summary: format!("{} available", available.join(" + ")), + details, + remediation: None, + } + } +} + +pub struct GithubAppStatus { + pub app_id: bool, + pub client_id: bool, + pub client_secret: bool, + pub webhook_secret: bool, + pub private_key: bool, +} + +impl GithubAppStatus { + fn all_set(&self) -> bool { + self.app_id + && self.client_id + && self.client_secret + && self.webhook_secret + && self.private_key + } + + fn none_set(&self) -> bool { + !self.app_id + && !self.client_id + && !self.client_secret + && !self.webhook_secret + && !self.private_key + } +} + +pub fn check_github_app(status: &GithubAppStatus) -> CheckResult { + let fields = [ + ("git.app_id", status.app_id), + ("git.client_id", status.client_id), + ("GITHUB_APP_CLIENT_SECRET", status.client_secret), + ("GITHUB_APP_WEBHOOK_SECRET", status.webhook_secret), + ("GITHUB_APP_PRIVATE_KEY", status.private_key), + ]; + + let details: Vec = fields + .iter() + .map(|(name, set)| CheckDetail { + text: format!("{name}: {}", if *set { "set" } else { "not set" }), + }) + .collect(); + + if status.all_set() { + CheckResult { + name: "GitHub App".to_string(), + status: CheckStatus::Pass, + summary: "fully configured".to_string(), + details, + remediation: None, + } + } else if status.none_set() { + CheckResult { + name: "GitHub App".to_string(), + status: CheckStatus::Warning, + summary: "not configured".to_string(), + details, + remediation: Some( + "Configure GitHub App in arc.toml and set env vars to enable GitHub integration" + .to_string(), + ), + } + } else { + let missing: Vec<_> = fields + .iter() + .filter(|(_, set)| !set) + .map(|(name, _)| *name) + .collect(); + CheckResult { + name: "GitHub App".to_string(), + status: CheckStatus::Error, + summary: "partially configured".to_string(), + details, + remediation: Some(format!("Missing: {}", missing.join(", "))), + } + } +} + +pub struct ApiStatus { + pub base_url: String, + pub authentication_strategy: String, +} + +pub fn check_api(status: &ApiStatus) -> CheckResult { + CheckResult { + name: "Arc API".to_string(), + status: CheckStatus::Pass, + summary: status.base_url.clone(), + details: vec![ + CheckDetail { + text: format!("Base URL: {}", status.base_url), + }, + CheckDetail { + text: format!("Authentication: {}", status.authentication_strategy), + }, + ], + remediation: None, + } +} + +pub struct WebStatus { + pub url: String, + pub auth_provider: String, + pub allowed_usernames_count: usize, +} + +pub fn check_web(status: &WebStatus) -> CheckResult { + CheckResult { + name: "Arc Web".to_string(), + status: CheckStatus::Pass, + summary: status.url.clone(), + details: vec![ + CheckDetail { + text: format!("URL: {}", status.url), + }, + CheckDetail { + text: format!("Auth provider: {}", status.auth_provider), + }, + CheckDetail { + text: format!("Allowed usernames: {}", status.allowed_usernames_count), + }, + ], + remediation: None, + } +} + +// --------------------------------------------------------------------------- +// Orchestrator (does real I/O) +// --------------------------------------------------------------------------- + +async fn probe_daytona() -> Option> { + if std::env::var("DAYTONA_API_KEY").is_err() { + return None; + } + Some( + daytona_sdk::Client::new() + .await + .map(|_| ()) + .map_err(|e| e.to_string()), + ) +} + +async fn probe_docker() -> Option> { + let docker = bollard::Docker::connect_with_local_defaults() + .map_err(|e| e.to_string()) + .ok()?; + Some(docker.ping().await.map(|_| ()).map_err(|e| e.to_string())) +} + +pub async fn run_doctor(verbose: bool) -> i32 { + let styles = Styles::detect_stdout(); + + // Gather state + let config_path = dirs::home_dir().map(|h| h.join(".arc").join("arc.toml")); + let config_exists = config_path + .as_ref() + .is_some_and(|p| p.exists()); + + let llm_statuses: Vec<(Provider, bool)> = Provider::ALL + .iter() + .map(|p| (*p, p.has_api_key())) + .collect(); + + let brave_key_set = std::env::var("BRAVE_SEARCH_API_KEY").is_ok(); + + let server_config = arc_api::server_config::load_server_config() + .unwrap_or_default(); + + let api_status = ApiStatus { + base_url: server_config.api.base_url.clone(), + authentication_strategy: match server_config.api.authentication_strategy { + ApiAuthenticationStrategy::Jwt => "jwt".to_string(), + ApiAuthenticationStrategy::InsecureDisabled => "insecure_disabled".to_string(), + }, + }; + + let web_status = WebStatus { + url: server_config.web.url.clone(), + auth_provider: match server_config.web.auth.provider { + AuthProvider::Github => "github".to_string(), + AuthProvider::InsecureDisabled => "insecure_disabled".to_string(), + }, + allowed_usernames_count: server_config.web.auth.allowed_usernames.len(), + }; + + let github_status = GithubAppStatus { + app_id: server_config.git.app_id.is_some(), + client_id: server_config.git.client_id.is_some(), + client_secret: std::env::var("GITHUB_APP_CLIENT_SECRET").is_ok(), + webhook_secret: std::env::var("GITHUB_APP_WEBHOOK_SECRET").is_ok(), + private_key: std::env::var("GITHUB_APP_PRIVATE_KEY").is_ok(), + }; + + // Probe sandboxes concurrently + let (daytona_result, docker_result) = tokio::join!(probe_daytona(), probe_docker()); + let sandbox_status = SandboxStatus { + daytona: daytona_result, + docker: docker_result, + }; + + // Run pure checks + let report = DoctorReport { + checks: vec![ + check_config(if config_exists { config_path } else { None }), + check_api(&api_status), + check_web(&web_status), + check_llm_providers(&llm_statuses), + check_brave_search(brave_key_set), + check_sandbox(&sandbox_status), + check_github_app(&github_status), + ], + }; + + print!("{}", report.render(&styles, verbose)); + + if report.has_errors() { 1 } else { 0 } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + fn pass_check(name: &str) -> CheckResult { + CheckResult { + name: name.to_string(), + status: CheckStatus::Pass, + summary: "all good".to_string(), + details: vec![CheckDetail { + text: "everything is fine".to_string(), + }], + remediation: None, + } + } + + fn warning_check(name: &str) -> CheckResult { + CheckResult { + name: name.to_string(), + status: CheckStatus::Warning, + summary: "not configured".to_string(), + details: vec![CheckDetail { + text: "missing something".to_string(), + }], + remediation: Some("fix it".to_string()), + } + } + + fn error_check(name: &str) -> CheckResult { + CheckResult { + name: name.to_string(), + status: CheckStatus::Error, + summary: "broken".to_string(), + details: vec![CheckDetail { + text: "something is wrong".to_string(), + }], + remediation: Some("repair it".to_string()), + } + } + + // -- render: all-pass, no color -- + + #[test] + fn render_all_pass_no_color() { + let report = DoctorReport { + checks: vec![pass_check("Test")], + }; + let out = report.render(&Styles::new(false), false); + assert!(out.contains("[✓]")); + assert!(out.contains("All checks passed.")); + assert!(out.contains("Arc Doctor")); + } + + // -- render: warning footer -- + + #[test] + fn render_warning_footer() { + let report = DoctorReport { + checks: vec![warning_check("Optional")], + }; + let out = report.render(&Styles::new(false), false); + assert!(out.contains("[!]")); + assert!(out.contains("Doctor found issues in 1 category.")); + assert!(out.contains("Warnings:")); + assert!(out.contains("fix it")); + } + + // -- render: error footer -- + + #[test] + fn render_error_footer() { + let report = DoctorReport { + checks: vec![error_check("Broken")], + }; + let out = report.render(&Styles::new(false), false); + assert!(out.contains("[✗]")); + assert!(out.contains("Errors:")); + assert!(out.contains("repair it")); + } + + // -- render: verbose mode -- + + #[test] + fn render_verbose_shows_details() { + let report = DoctorReport { + checks: vec![pass_check("Verbose")], + }; + let out = report.render(&Styles::new(false), true); + assert!(out.contains("•")); + assert!(out.contains("everything is fine")); + } + + #[test] + fn render_default_hides_details() { + let report = DoctorReport { + checks: vec![pass_check("Verbose")], + }; + let out = report.render(&Styles::new(false), false); + assert!(!out.contains("everything is fine")); + } + + // -- render: color -- + + #[test] + fn render_color_pass_green() { + let report = DoctorReport { + checks: vec![pass_check("Color")], + }; + let out = report.render(&Styles::new(true), false); + assert!(out.contains("\x1b[32m")); // green + } + + #[test] + fn render_color_warning_yellow() { + let report = DoctorReport { + checks: vec![warning_check("Color")], + }; + let out = report.render(&Styles::new(true), false); + assert!(out.contains("\x1b[33m")); // yellow + } + + #[test] + fn render_color_error_red() { + let report = DoctorReport { + checks: vec![error_check("Color")], + }; + let out = report.render(&Styles::new(true), false); + assert!(out.contains("\x1b[31m")); // red + } + + // -- has_errors / issue_count -- + + #[test] + fn has_errors_false_for_warnings_only() { + let report = DoctorReport { + checks: vec![pass_check("OK"), warning_check("Warn")], + }; + assert!(!report.has_errors()); + } + + #[test] + fn has_errors_true_when_error_present() { + let report = DoctorReport { + checks: vec![pass_check("OK"), error_check("Broken")], + }; + assert!(report.has_errors()); + } + + #[test] + fn issue_count_counts_warnings_and_errors() { + let report = DoctorReport { + checks: vec![ + pass_check("OK"), + warning_check("Warn"), + error_check("Broken"), + ], + }; + assert_eq!(report.issue_count(), 2); + } + + // -- check_config -- + + #[test] + fn check_config_pass_with_path() { + let result = check_config(Some(PathBuf::from("/home/user/.arc/arc.toml"))); + assert_eq!(result.status, CheckStatus::Pass); + assert!(result.summary.contains(".arc/arc.toml")); + } + + #[test] + fn check_config_warning_without_path() { + let result = check_config(None); + assert_eq!(result.status, CheckStatus::Warning); + assert!(result.remediation.is_some()); + } + + // -- check_llm_providers -- + + #[test] + fn check_llm_all_configured() { + let statuses: Vec<(Provider, bool)> = + Provider::ALL.iter().map(|p| (*p, true)).collect(); + let result = check_llm_providers(&statuses); + assert_eq!(result.status, CheckStatus::Pass); + assert!(result.summary.contains("7 of 7")); + } + + #[test] + fn check_llm_some_configured() { + let mut statuses: Vec<(Provider, bool)> = + Provider::ALL.iter().map(|p| (*p, false)).collect(); + statuses[0].1 = true; // Anthropic + statuses[1].1 = true; // OpenAi + statuses[2].1 = true; // Gemini + statuses[3].1 = true; // Kimi + statuses[4].1 = true; // Zai + let result = check_llm_providers(&statuses); + assert_eq!(result.status, CheckStatus::Pass); + assert!(result.summary.contains("5 of 7")); + } + + #[test] + fn check_llm_none_configured() { + let statuses: Vec<(Provider, bool)> = + Provider::ALL.iter().map(|p| (*p, false)).collect(); + let result = check_llm_providers(&statuses); + assert_eq!(result.status, CheckStatus::Error); + assert!(result.summary.contains("0 of 7")); + } + + // -- check_brave_search -- + + #[test] + fn check_brave_configured() { + let result = check_brave_search(true); + assert_eq!(result.status, CheckStatus::Pass); + } + + #[test] + fn check_brave_not_configured() { + let result = check_brave_search(false); + assert_eq!(result.status, CheckStatus::Warning); + assert!(result.remediation.is_some()); + } + + // -- check_sandbox -- + + #[test] + fn check_sandbox_daytona_ok() { + let status = SandboxStatus { + daytona: Some(Ok(())), + docker: None, + }; + let result = check_sandbox(&status); + assert_eq!(result.status, CheckStatus::Pass); + assert!(result.summary.contains("Daytona available")); + } + + #[test] + fn check_sandbox_docker_ok() { + let status = SandboxStatus { + daytona: None, + docker: Some(Ok(())), + }; + let result = check_sandbox(&status); + assert_eq!(result.status, CheckStatus::Pass); + assert!(result.summary.contains("Docker available")); + } + + #[test] + fn check_sandbox_both_ok() { + let status = SandboxStatus { + daytona: Some(Ok(())), + docker: Some(Ok(())), + }; + let result = check_sandbox(&status); + assert_eq!(result.status, CheckStatus::Pass); + assert!(result.summary.contains("Daytona + Docker available")); + } + + #[test] + fn check_sandbox_both_unavailable() { + let status = SandboxStatus { + daytona: None, + docker: None, + }; + let result = check_sandbox(&status); + assert_eq!(result.status, CheckStatus::Warning); + } + + #[test] + fn check_sandbox_configured_but_broken() { + let status = SandboxStatus { + daytona: Some(Err("connection refused".to_string())), + docker: None, + }; + let result = check_sandbox(&status); + assert_eq!(result.status, CheckStatus::Error); + } + + // -- check_github_app -- + + #[test] + fn check_github_all_set() { + let status = GithubAppStatus { + app_id: true, + client_id: true, + client_secret: true, + webhook_secret: true, + private_key: true, + }; + let result = check_github_app(&status); + assert_eq!(result.status, CheckStatus::Pass); + } + + #[test] + fn check_github_none_set() { + let status = GithubAppStatus { + app_id: false, + client_id: false, + client_secret: false, + webhook_secret: false, + private_key: false, + }; + let result = check_github_app(&status); + assert_eq!(result.status, CheckStatus::Warning); + } + + #[test] + fn check_github_partial() { + let status = GithubAppStatus { + app_id: true, + client_id: true, + client_secret: false, + webhook_secret: false, + private_key: false, + }; + let result = check_github_app(&status); + assert_eq!(result.status, CheckStatus::Error); + let rem = result.remediation.unwrap(); + assert!(rem.contains("GITHUB_APP_CLIENT_SECRET")); + assert!(rem.contains("GITHUB_APP_WEBHOOK_SECRET")); + assert!(rem.contains("GITHUB_APP_PRIVATE_KEY")); + } + + // -- check_api -- + + #[test] + fn check_api_shows_base_url() { + let status = ApiStatus { + base_url: "http://localhost:3000".to_string(), + authentication_strategy: "jwt".to_string(), + }; + let result = check_api(&status); + assert_eq!(result.status, CheckStatus::Pass); + assert_eq!(result.summary, "http://localhost:3000"); + } + + #[test] + fn check_api_details_show_auth_strategy() { + let status = ApiStatus { + base_url: "https://api.example.com".to_string(), + authentication_strategy: "jwt".to_string(), + }; + let result = check_api(&status); + assert!(result.details.iter().any(|d| d.text.contains("jwt"))); + assert!(result + .details + .iter() + .any(|d| d.text.contains("https://api.example.com"))); + } + + // -- check_web -- + + #[test] + fn check_web_shows_url() { + let status = WebStatus { + url: "http://localhost:5173".to_string(), + auth_provider: "github".to_string(), + allowed_usernames_count: 0, + }; + let result = check_web(&status); + assert_eq!(result.status, CheckStatus::Pass); + assert_eq!(result.summary, "http://localhost:5173"); + } + + #[test] + fn check_web_details_show_auth() { + let status = WebStatus { + url: "https://arc.example.com".to_string(), + auth_provider: "github".to_string(), + allowed_usernames_count: 3, + }; + let result = check_web(&status); + assert!(result.details.iter().any(|d| d.text.contains("github"))); + assert!(result + .details + .iter() + .any(|d| d.text.contains("https://arc.example.com"))); + assert!(result + .details + .iter() + .any(|d| d.text.contains("Allowed usernames: 3"))); + } + + // -- render: multiple issues -- + + #[test] + fn render_multiple_issues_pluralizes() { + let report = DoctorReport { + checks: vec![warning_check("A"), error_check("B")], + }; + let out = report.render(&Styles::new(false), false); + assert!(out.contains("2 categories")); + } +} diff --git a/crates/arc-cli/src/main.rs b/crates/arc-cli/src/main.rs index 5b89c545c..d02c86d40 100644 --- a/crates/arc-cli/src/main.rs +++ b/crates/arc-cli/src/main.rs @@ -1,3 +1,4 @@ +mod doctor; mod logging; use anyhow::Result; @@ -42,6 +43,12 @@ enum Command { }, /// Start the HTTP API server Serve(arc_api::serve::ServeArgs), + /// Check environment and integration health + Doctor { + /// Show detailed information for each check + #[arg(short, long)] + verbose: bool, + }, } #[derive(Subcommand)] @@ -80,6 +87,7 @@ async fn main() -> Result<()> { Command::Validate(_) => "validate", Command::Models { .. } => "models", Command::Serve(_) => "serve", + Command::Doctor { .. } => "doctor", }; debug!(command = %command_name, "CLI command started"); @@ -114,6 +122,10 @@ async fn main() -> Result<()> { Box::leak(Box::new(arc_util::terminal::Styles::detect_stderr())); arc_api::serve::serve_command(args, styles).await?; } + Command::Doctor { verbose } => { + let exit_code = doctor::run_doctor(verbose).await; + std::process::exit(exit_code); + } } Ok(()) diff --git a/crates/arc-cli/tests/cli.rs b/crates/arc-cli/tests/cli.rs index a84795b51..968e707fc 100644 --- a/crates/arc-cli/tests/cli.rs +++ b/crates/arc-cli/tests/cli.rs @@ -498,6 +498,63 @@ fn dry_run_legacy_tool() { .success(); } +// == Doctor =================================================================== + +#[test] +fn doctor_runs_and_prints_header() { + arc() + .args(["--no-dotenv", "doctor"]) + .env_clear() + .assert() + .stdout(predicate::str::contains("Arc Doctor")); +} + +#[test] +fn doctor_verbose_runs_and_prints_header() { + arc() + .args(["--no-dotenv", "doctor", "-v"]) + .env_clear() + .assert() + .stdout(predicate::str::contains("Arc Doctor")); +} + +#[test] +fn doctor_no_color_when_no_color_set() { + arc() + .args(["--no-dotenv", "doctor"]) + .env_clear() + .env("NO_COLOR", "1") + .assert() + .stdout(predicate::str::contains("\x1b[").not()); +} + +#[test] +fn doctor_checks_llm_providers() { + arc() + .args(["--no-dotenv", "doctor"]) + .env_clear() + .assert() + .stdout(predicate::str::contains("LLM providers")); +} + +#[test] +fn doctor_checks_arc_api() { + arc() + .args(["--no-dotenv", "doctor"]) + .env_clear() + .assert() + .stdout(predicate::str::contains("Arc API")); +} + +#[test] +fn doctor_checks_arc_web() { + arc() + .args(["--no-dotenv", "doctor"]) + .env_clear() + .assert() + .stdout(predicate::str::contains("Arc Web")); +} + // == JSONL logging ============================================================ #[test] diff --git a/crates/arc-llm/src/provider.rs b/crates/arc-llm/src/provider.rs index 64d5b1a78..359ecaff5 100644 --- a/crates/arc-llm/src/provider.rs +++ b/crates/arc-llm/src/provider.rs @@ -35,6 +35,29 @@ impl Provider { Provider::Inception, ]; + /// Environment variable names that can provide the API key for this provider. + /// Gemini accepts either `GEMINI_API_KEY` or `GOOGLE_API_KEY`. + #[must_use] + pub fn api_key_env_vars(self) -> &'static [&'static str] { + match self { + Self::Anthropic => &["ANTHROPIC_API_KEY"], + Self::OpenAi => &["OPENAI_API_KEY"], + Self::Gemini => &["GEMINI_API_KEY", "GOOGLE_API_KEY"], + Self::Kimi => &["KIMI_API_KEY"], + Self::Zai => &["ZAI_API_KEY"], + Self::Minimax => &["MINIMAX_API_KEY"], + Self::Inception => &["INCEPTION_API_KEY"], + } + } + + /// Returns `true` if at least one of the provider's API key env vars is set. + #[must_use] + pub fn has_api_key(self) -> bool { + self.api_key_env_vars() + .iter() + .any(|var| std::env::var(var).is_ok()) + } + /// Stable lowercase string representation used in `Request.provider`, /// adapter names, and other serialization boundaries. #[must_use] @@ -212,4 +235,54 @@ mod tests { fn inception_as_str() { assert_eq!(Provider::Inception.as_str(), "inception"); } + + #[test] + fn api_key_env_vars_anthropic() { + assert_eq!( + Provider::Anthropic.api_key_env_vars(), + &["ANTHROPIC_API_KEY"] + ); + } + + #[test] + fn api_key_env_vars_openai() { + assert_eq!(Provider::OpenAi.api_key_env_vars(), &["OPENAI_API_KEY"]); + } + + #[test] + fn api_key_env_vars_gemini_has_two() { + let vars = Provider::Gemini.api_key_env_vars(); + assert_eq!(vars.len(), 2); + assert_eq!(vars, &["GEMINI_API_KEY", "GOOGLE_API_KEY"]); + } + + #[test] + fn api_key_env_vars_kimi() { + assert_eq!(Provider::Kimi.api_key_env_vars(), &["KIMI_API_KEY"]); + } + + #[test] + fn api_key_env_vars_zai() { + assert_eq!(Provider::Zai.api_key_env_vars(), &["ZAI_API_KEY"]); + } + + #[test] + fn api_key_env_vars_minimax() { + assert_eq!(Provider::Minimax.api_key_env_vars(), &["MINIMAX_API_KEY"]); + } + + #[test] + fn api_key_env_vars_inception() { + assert_eq!( + Provider::Inception.api_key_env_vars(), + &["INCEPTION_API_KEY"] + ); + } + + #[test] + fn every_provider_has_at_least_one_env_var() { + assert!(Provider::ALL + .iter() + .all(|p| !p.api_key_env_vars().is_empty())); + } }