diff --git a/src/commands/command_tree.rs b/src/commands/command_tree.rs index 404546ab..c45e9c91 100644 --- a/src/commands/command_tree.rs +++ b/src/commands/command_tree.rs @@ -208,6 +208,17 @@ const COMMANDS: &[CmdEntry] = &[ ("fish", "Generate fish completions"), ], }, + CmdEntry { + name: "config", + about: "Manage starforge configuration", + subs: &[ + ("show", "Show current global configuration"), + ("set", "Set a scalar configuration value"), + ("doctor", "Validate config and check connectivity"), + ("plugin-trust", "Manage trusted plugin source allowlist"), + ("set-encryption", "Set global wallet encryption parameters"), + ], + }, CmdEntry { name: "info", about: "Show starforge config and environment info", diff --git a/src/commands/config.rs b/src/commands/config.rs index 74ad824e..ebf42352 100644 --- a/src/commands/config.rs +++ b/src/commands/config.rs @@ -31,7 +31,7 @@ pub enum ConfigCommands { #[arg(long, default_value = "false")] reset: bool, }, - /// Validate configuration, wallet keys, connectivity, and CLI tooling + /// Validate configuration and check network connectivity Doctor, } @@ -64,7 +64,7 @@ pub fn handle(cmd: ConfigCommands) -> Result<()> { parallelism, reset, } => set_encryption(mem, iterations, parallelism, reset), - ConfigCommands::Doctor => crate::commands::doctor::handle(), + ConfigCommands::Doctor => crate::commands::doctor::run(), } } diff --git a/src/commands/doctor.rs b/src/commands/doctor.rs index 0d2ee123..518fcab4 100644 --- a/src/commands/doctor.rs +++ b/src/commands/doctor.rs @@ -1,279 +1,100 @@ use crate::commands::info; -use crate::utils::{config, horizon, print as p}; +use crate::utils::{config, horizon, print as p, soroban}; use anyhow::Result; -use colored::*; -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct CheckResult { - pub name: String, - pub passed: bool, - pub detail: String, -} - -pub fn handle() -> Result<()> { - let results = run_checks()?; - print_report(&results); - - let failures: Vec<_> = results.iter().filter(|r| !r.passed).collect(); - if failures.is_empty() { - p::success("All checks passed."); - Ok(()) - } else { - anyhow::bail!( - "{} check(s) failed. Run `starforge config doctor` after fixing the issues above.", - failures.len() - ) - } -} - -pub fn run_checks() -> Result> { - let mut results = Vec::new(); - - results.push(check_config_file()); - results.extend(check_config_schema()?); - results.extend(check_wallets()?); - results.push(check_stellar_cli()); - results.extend(check_connectivity()?); - - Ok(results) -} +pub fn run() -> Result<()> { + p::header("StarForge Config Doctor"); + p::separator(); -fn check_config_file() -> CheckResult { + let mut findings = Vec::new(); let path = config::config_path(); - if !path.exists() { - return CheckResult { - name: "Config file".to_string(), - passed: true, - detail: format!( - "No config file at {} (using built-in defaults)", - path.display() - ), - }; - } - - match std::fs::read_to_string(&path) { - Ok(contents) => match toml::from_str::(&contents) { - Ok(_) => CheckResult { - name: "Config file".to_string(), - passed: true, - detail: format!("Valid TOML at {}", path.display()), - }, - Err(e) => CheckResult { - name: "Config file".to_string(), - passed: false, - detail: format!("Invalid TOML: {}", e), - }, - }, - Err(e) => CheckResult { - name: "Config file".to_string(), - passed: false, - detail: format!("Cannot read {}: {}", path.display(), e), - }, - } -} - -fn check_config_schema() -> Result> { - let cfg = config::load()?; - match config::validate_config(&cfg) { - Ok(()) => Ok(vec![CheckResult { - name: "Config schema".to_string(), - passed: true, - detail: format!( - "version={}, network={}, {} wallet(s), {} network(s)", - cfg.version, - cfg.network, - cfg.wallets.len(), - cfg.networks.len() - ), - }]), - Err(e) => Ok(vec![CheckResult { - name: "Config schema".to_string(), - passed: false, - detail: e.to_string(), - }]), - } -} - -fn check_wallets() -> Result> { - let cfg = config::load()?; - let mut results = Vec::new(); - - if cfg.wallets.is_empty() { - results.push(CheckResult { - name: "Wallets".to_string(), - passed: true, - detail: "No wallets configured".to_string(), - }); - return Ok(results); - } - for wallet in &cfg.wallets { - let label = format!("Wallet '{}'", wallet.name); - if let Err(e) = config::validate_wallet_name(&wallet.name) { - results.push(CheckResult { - name: label, - passed: false, - detail: e.to_string(), - }); - continue; - } - if let Err(e) = config::validate_public_key(&wallet.public_key) { - results.push(CheckResult { - name: label, - passed: false, - detail: format!("invalid public key: {}", e), - }); - continue; - } - if let Some(ref secret) = wallet.secret_key { - if let Err(e) = config::validate_secret_key(secret) { - results.push(CheckResult { - name: label, - passed: false, - detail: format!("invalid secret key: {}", e), - }); - continue; - } - } - if let Err(e) = config::validate_network_exists(&cfg, &wallet.network) { - results.push(CheckResult { - name: label, - passed: false, - detail: format!("invalid network: {}", e), - }); - continue; + if !path.exists() { + findings.push(config::DoctorFinding::pass( + "schema", + "no config.toml found; using built-in defaults", + )); + } else { + match config::parse_config_file() { + Ok(_) => findings.push(config::DoctorFinding::pass( + "schema", + format!("config.toml parses at {}", path.display()), + )), + Err(e) => findings.push(config::DoctorFinding::fail("schema", e.to_string())), } - results.push(CheckResult { - name: label, - passed: true, - detail: format!("{} on {}", wallet.public_key, wallet.network), - }); } - Ok(results) -} - -fn check_stellar_cli() -> CheckResult { - match info::detect_stellar_cli() { - Some(path) => CheckResult { - name: "Stellar CLI".to_string(), - passed: true, - detail: format!("found at {}", path.display()), - }, - None => CheckResult { - name: "Stellar CLI".to_string(), - passed: false, - detail: "not found on PATH (install stellar-cli for full functionality)".to_string(), - }, - } -} - -fn check_connectivity() -> Result> { let cfg = config::load()?; - let mut results = Vec::new(); - - for (name, net_cfg) in &cfg.networks { - if !should_check_network(name, &cfg.network, &net_cfg.horizon_url) { - continue; - } + findings.extend(config::validate_config_integrity(&cfg)); - let horizon_label = format!("Horizon ({})", name); - if horizon::check_horizon_endpoint(&net_cfg.horizon_url) { - results.push(CheckResult { - name: horizon_label, - passed: true, - detail: net_cfg.horizon_url.clone(), - }); - } else { - results.push(CheckResult { - name: horizon_label, - passed: false, - detail: format!("{} is unreachable", net_cfg.horizon_url), - }); - } + let network = cfg.network.clone(); + if horizon::check_network(&network) { + findings.push(config::DoctorFinding::pass( + "horizon", + format!("Horizon reachable for '{network}'"), + )); + } else { + findings.push(config::DoctorFinding::fail( + "horizon", + format!("Horizon unreachable for '{network}'"), + )); + } - if let Some(ref soroban_url) = net_cfg.soroban_rpc_url { - let soroban_label = format!("Soroban RPC ({})", name); - if horizon::check_soroban_rpc(soroban_url) { - results.push(CheckResult { - name: soroban_label, - passed: true, - detail: soroban_url.clone(), - }); + match soroban::rpc_url(&network) { + Ok(url) => { + if soroban::check_soroban_rpc_url(&url) { + findings.push(config::DoctorFinding::pass( + "soroban", + format!("Soroban RPC reachable for '{network}'"), + )); } else { - results.push(CheckResult { - name: soroban_label, - passed: false, - detail: format!("{} is unreachable", soroban_url), - }); + findings.push(config::DoctorFinding::fail( + "soroban", + format!("Soroban RPC unreachable at {url}"), + )); } } + Err(e) => findings.push(config::DoctorFinding::fail("soroban", e.to_string())), } - Ok(results) -} - -fn should_check_network(name: &str, active_network: &str, horizon_url: &str) -> bool { - if name == active_network { - return true; - } - - let local = horizon_url.contains("localhost") || horizon_url.contains("127.0.0.1"); - if local { - return false; + if let Some(cli_path) = info::detect_stellar_cli() { + findings.push(config::DoctorFinding::pass( + "stellar", + format!("Stellar CLI found at {}", cli_path.display()), + )); + } else { + findings.push(config::DoctorFinding::fail( + "stellar", + "Stellar CLI not found on PATH", + )); } - matches!(name, "testnet" | "mainnet") -} - -fn print_report(results: &[CheckResult]) { - p::header("starforge Config Doctor"); - p::separator(); - println!(); - - for result in results { - let status = if result.passed { - "PASS".green().bold() - } else { - "FAIL".red().bold() + let passed = findings + .iter() + .filter(|f| f.status == config::DoctorStatus::Pass) + .count(); + let failed = findings + .iter() + .filter(|f| f.status == config::DoctorStatus::Fail) + .count(); + + for finding in &findings { + let marker = match finding.status { + config::DoctorStatus::Pass => "✓", + config::DoctorStatus::Fail => "✗", }; - println!(" {} {}", status, result.name.bright_white()); - println!(" {}", result.detail.dimmed()); + println!(" {} {:<13} {}", marker, finding.category, finding.message); } println!(); + p::kv("Passed", &passed.to_string()); + p::kv("Failed", &failed.to_string()); p::separator(); -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn stellar_cli_check_returns_structured_result() { - let result = check_stellar_cli(); - assert_eq!(result.name, "Stellar CLI"); - assert!(!result.detail.is_empty()); - } - #[test] - fn run_checks_returns_non_empty_results() { - let results = run_checks().expect("run_checks should succeed"); - assert!(!results.is_empty()); - assert!(results.iter().any(|r| r.name == "Config schema")); + if failed > 0 { + anyhow::bail!("{failed} config doctor check(s) failed"); } - #[test] - fn should_skip_localhost_networks_unless_active() { - assert!(!should_check_network( - "docker-testnet", - "testnet", - "http://localhost:8000" - )); - assert!(should_check_network( - "docker-testnet", - "docker-testnet", - "http://localhost:8000" - )); - } + p::success("All config doctor checks passed."); + Ok(()) } diff --git a/src/commands/plugin.rs b/src/commands/plugin.rs index 0a13c6b6..2ad3d16d 100644 --- a/src/commands/plugin.rs +++ b/src/commands/plugin.rs @@ -624,6 +624,61 @@ fn update(name: Option, yes: bool) -> Result<()> { Ok(()) } +fn discover_commands_from_library(path: &str) -> Result> { + let mut pm = PluginManager::new(); + unsafe { + pm.load_plugin_diagnosed(path) + .map_err(|e| anyhow::anyhow!("{}", e))?; + } + Ok(pm + .list_commands() + .into_iter() + .map(|c| RegisteredCommand { + name: c.name, + description: c.description, + }) + .collect()) +} + +fn commands(name: Option) -> Result<()> { + p::header("Plugin Commands"); + + let reg = registry::load_registry().unwrap_or_default(); + if reg.plugins.is_empty() { + p::info("No plugins installed."); + return Ok(()); + } + + let to_show: Vec<_> = match &name { + Some(n) => { + let found: Vec<_> = reg.plugins.iter().filter(|p| &p.name == n).collect(); + if found.is_empty() { + anyhow::bail!("Plugin '{}' is not installed.", n); + } + found + } + None => reg.plugins.iter().collect(), + }; + + for (idx, pl) in to_show.iter().enumerate() { + if to_show.len() > 1 { + if idx > 0 { + println!(); + } + p::kv_accent("Plugin", &pl.name); + } + if pl.commands.is_empty() { + p::info(" (no commands registered)"); + } else { + for cmd in &pl.commands { + p::info(&format!(" • {} — {}", cmd.name, cmd.description)); + } + } + } + + Ok(()) +} + fn verify(name: Option, deep: bool, runtime_check: bool) -> Result<()> { if deep || runtime_check { return run_audit(name, runtime_check); diff --git a/src/utils/config.rs b/src/utils/config.rs index a2bd77c5..e9d9a571 100644 --- a/src/utils/config.rs +++ b/src/utils/config.rs @@ -583,6 +583,192 @@ pub fn load() -> Result { Ok(config) } +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum DoctorStatus { + Pass, + Fail, +} + +#[derive(Debug, Clone)] +pub struct DoctorFinding { + pub category: &'static str, + pub status: DoctorStatus, + pub message: String, +} + +impl DoctorFinding { + pub fn pass(category: &'static str, message: impl Into) -> Self { + Self { + category, + status: DoctorStatus::Pass, + message: message.into(), + } + } + + pub fn fail(category: &'static str, message: impl Into) -> Self { + Self { + category, + status: DoctorStatus::Fail, + message: message.into(), + } + } +} + +/// Read and parse `config.toml` without migration or default-network injection. +pub fn parse_config_file() -> Result { + let path = config_path(); + if !path.exists() { + return Ok(Config::default()); + } + let contents = fs::read_to_string(&path) + .with_context(|| format!("Failed to read config at {}", path.display()))?; + toml::from_str(&contents).with_context(|| "Failed to parse config.toml") +} + +fn validate_service_url(url: &str, label: &str) -> Result<()> { + if url.trim().is_empty() { + anyhow::bail!("{label} cannot be empty"); + } + if !url.starts_with("http://") && !url.starts_with("https://") { + anyhow::bail!("{label} must use http or https"); + } + Ok(()) +} + +/// Run structural validation checks against a loaded configuration. +pub fn validate_config_integrity(cfg: &Config) -> Vec { + let mut findings = Vec::new(); + + if cfg.version == CURRENT_CONFIG_VERSION { + findings.push(DoctorFinding::pass( + "schema", + format!("config version is {}", cfg.version), + )); + } else { + findings.push(DoctorFinding::fail( + "schema", + format!( + "unsupported config version '{}' (expected {})", + cfg.version, CURRENT_CONFIG_VERSION + ), + )); + } + + match validate_network_exists(cfg, &cfg.network) { + Ok(()) => findings.push(DoctorFinding::pass( + "network", + format!("active network '{}' is configured", cfg.network), + )), + Err(e) => findings.push(DoctorFinding::fail("network", e.to_string())), + } + + if cfg.wallets.is_empty() { + findings.push(DoctorFinding::pass("wallet", "no wallets configured")); + } else { + let mut wallet_ok = true; + let mut wallet_errors = Vec::new(); + for wallet in &cfg.wallets { + let label = format!("wallet '{}'", wallet.name); + if let Err(e) = validate_wallet_name(&wallet.name) { + wallet_ok = false; + wallet_errors.push(format!("{label}: {e}")); + } + if let Err(e) = validate_public_key(&wallet.public_key) { + wallet_ok = false; + wallet_errors.push(format!("{label} public key: {e}")); + } + if let Some(ref secret) = wallet.secret_key { + if let Err(e) = validate_secret_key(secret) { + wallet_ok = false; + wallet_errors.push(format!("{label} secret key: {e}")); + } + } + if let Err(e) = validate_network_exists(cfg, &wallet.network) { + wallet_ok = false; + wallet_errors.push(format!("{label} network: {e}")); + } + } + if wallet_ok { + findings.push(DoctorFinding::pass( + "wallet", + format!("{} wallet(s) validated", cfg.wallets.len()), + )); + } else { + findings.push(DoctorFinding::fail("wallet", wallet_errors.join("; "))); + } + } + + let mut network_ok = true; + let mut network_errors = Vec::new(); + for (name, net) in &cfg.networks { + if let Err(e) = validate_service_url(&net.horizon_url, "horizon_url") { + network_ok = false; + network_errors.push(format!("network '{name}': {e}")); + } + if let Some(ref rpc) = net.soroban_rpc_url { + if let Err(e) = validate_service_url(rpc, "soroban_rpc_url") { + network_ok = false; + network_errors.push(format!("network '{name}' soroban RPC: {e}")); + } + } + } + if network_ok { + findings.push(DoctorFinding::pass( + "network", + format!("{} network(s) have valid endpoint URLs", cfg.networks.len()), + )); + } else { + findings.push(DoctorFinding::fail("network", network_errors.join("; "))); + } + + let mut trust_ok = true; + let mut trust_errors = Vec::new(); + for source in &cfg.plugin_trust.trusted_sources { + if let Err(e) = validate_plugin_trust_source(source) { + trust_ok = false; + trust_errors.push(format!("'{source}': {e}")); + } + } + if trust_ok { + findings.push(DoctorFinding::pass( + "plugin_trust", + format!( + "{} trusted plugin source(s) validated", + cfg.plugin_trust.trusted_sources.len() + ), + )); + } else { + findings.push(DoctorFinding::fail("plugin_trust", trust_errors.join("; "))); + } + + if let Some(ref kdf) = cfg.wallet_encryption { + let mut enc_ok = true; + let mut enc_errors = Vec::new(); + for (field, value) in [ + ("mem", kdf.mem), + ("iterations", kdf.iterations), + ("parallelism", kdf.parallelism), + ] { + if let Some(v) = value { + if v == 0 { + enc_ok = false; + enc_errors.push(format!("{field} must be > 0")); + } + } + } + if enc_ok { + findings.push(DoctorFinding::pass( + "encryption", + "wallet encryption parameters are valid", + )); + } else { + findings.push(DoctorFinding::fail("encryption", enc_errors.join("; "))); + } + } + + findings +} + #[cfg(test)] mod tests { use super::*; @@ -786,6 +972,39 @@ telemetry_enabled = true ); } } + + #[test] + fn validate_config_integrity_passes_default_config() { + let cfg = Config::default(); + let findings = validate_config_integrity(&cfg); + assert!( + findings.iter().all(|f| f.status == DoctorStatus::Pass), + "expected all pass, got: {:?}", + findings + ); + } + + #[test] + fn validate_config_integrity_catches_bad_wallet_key() { + let mut cfg = Config::default(); + cfg.wallets.push(WalletEntry { + name: "bad".to_string(), + public_key: "not-a-key".to_string(), + secret_key: None, + network: "testnet".to_string(), + created_at: String::new(), + funded: false, + rotation_history: Vec::new(), + }); + let findings = validate_config_integrity(&cfg); + assert!( + findings + .iter() + .any(|f| f.category == "wallet" && f.status == DoctorStatus::Fail), + "expected wallet failure, got: {:?}", + findings + ); + } } /// Returns the network passphrase for transaction signing. diff --git a/src/utils/soroban.rs b/src/utils/soroban.rs index 033e324e..7de6b557 100644 --- a/src/utils/soroban.rs +++ b/src/utils/soroban.rs @@ -296,6 +296,39 @@ pub fn rpc_url(network: &str) -> Result { get_rpc_url(network) } +/// Returns true when the Soroban RPC endpoint for `network` responds to `getHealth`. +pub fn check_soroban_rpc(network: &str) -> bool { + match get_rpc_url(network) { + Ok(url) => check_soroban_rpc_url(&url), + Err(_) => false, + } +} + +/// Returns true when a Soroban RPC URL responds to a `getHealth` JSON-RPC request. +pub fn check_soroban_rpc_url(url: &str) -> bool { + let request = SorobanRpcRequest { + jsonrpc: "2.0".to_string(), + id: 1, + method: "getHealth".to_string(), + params: serde_json::json!({}), + }; + + ureq::post(url) + .set("Content-Type", "application/json") + .send_json(&request) + .ok() + .and_then(|response| { + if response.status() != 200 { + return None; + } + response + .into_json::>() + .ok() + }) + .map(|parsed| parsed.result.is_some()) + .unwrap_or(false) +} + fn rpc_request_with_url(rpc_url: &str, request: SorobanRpcRequest) -> Result where T: DeserializeOwned, @@ -873,4 +906,27 @@ mod tests { let err = encode_arguments(&["maybe".to_string()], &["bool".to_string()]).unwrap_err(); assert!(!err.to_string().is_empty()); } + + #[test] + fn check_soroban_rpc_url_reports_healthy_endpoint() { + let mut server = mockito::Server::new(); + let mock = server + .mock("POST", "/") + .with_status(200) + .with_header("content-type", "application/json") + .with_body(r#"{"jsonrpc":"2.0","id":1,"result":{"status":"healthy"}}"#) + .create(); + + assert!(check_soroban_rpc_url(&server.url())); + mock.assert(); + } + + #[test] + fn check_soroban_rpc_url_rejects_error_response() { + let mut server = mockito::Server::new(); + let mock = server.mock("POST", "/").with_status(500).create(); + + assert!(!check_soroban_rpc_url(&server.url())); + mock.assert(); + } } diff --git a/tests/cli_smoke.rs b/tests/cli_smoke.rs index 9946fa78..d68c1120 100644 --- a/tests/cli_smoke.rs +++ b/tests/cli_smoke.rs @@ -394,3 +394,75 @@ fn telemetry_respects_env_override() { assert!(stdout.contains("Environment Override")); assert!(stdout.contains("false")); } + +fn write_config(home: &std::path::Path, contents: &str) { + let dir = home.join(".starforge"); + std::fs::create_dir_all(&dir).expect("create config dir"); + std::fs::write(dir.join("config.toml"), contents).expect("write config"); +} + +#[test] +fn config_doctor_smoke_in_isolated_home() { + let home = isolated_home(); + let output = starforge(home.path()) + .args(["config", "doctor"]) + .output() + .expect("spawn config doctor"); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("StarForge Config Doctor")); + assert!(stdout.contains("schema")); + assert!(stdout.contains("Passed")); + assert!( + stdout.contains("no config.toml found") || stdout.contains("config version is"), + "expected default schema finding, got: {stdout}" + ); +} + +#[test] +fn config_doctor_fails_on_invalid_wallet_key() { + let home = isolated_home(); + write_config( + home.path(), + r#" +version = "1" +network = "testnet" + +[[wallets]] +name = "bad" +public_key = "not-a-key" +network = "testnet" +created_at = "" +funded = false +"#, + ); + + let output = starforge(home.path()) + .args(["config", "doctor"]) + .output() + .expect("spawn config doctor"); + assert!( + !output.status.success(), + "expected non-zero exit for invalid wallet public key" + ); + let combined = format!( + "{}{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + assert!( + combined.contains("wallet") || combined.contains("public key"), + "expected wallet validation failure, got: {combined}" + ); +} + +#[test] +fn config_help_lists_doctor_subcommand() { + let home = isolated_home(); + let output = starforge(home.path()) + .args(["config", "--help"]) + .output() + .expect("spawn config help"); + assert_success(&output, "starforge config --help"); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("doctor")); +}