cli

Command-line interface for Radroots
git clone https://radroots.dev/git/cli.git
Log | Files | Refs | README | LICENSE

commit 65d592ed49af6339186a9984adf7c2741fe12a53
parent 39d391e2bec5e3fad32b2d1b8e82a5753622cc91
Author: triesap <tyson@radroots.org>
Date:   Tue,  7 Apr 2026 04:29:34 +0000

land relay and net operator surfaces

Diffstat:
MCargo.lock | 2++
MCargo.toml | 2++
Msrc/cli.rs | 10++++++++++
Msrc/commands/doctor.rs | 29+++++++++++++++++++++++++++++
Msrc/commands/mod.rs | 6++++--
Asrc/commands/net.rs | 20++++++++++++++++++++
Asrc/commands/relay.rs | 19+++++++++++++++++++
Msrc/commands/runtime.rs | 8+++++++-
Msrc/domain/runtime.rs | 65+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/render/mod.rs | 146++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Msrc/runtime/config.rs | 308++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Msrc/runtime/mod.rs | 1+
Asrc/runtime/network.rs | 72++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mtests/doctor.rs | 26+++++++++++++++++++-------
Mtests/identity_commands.rs | 1+
Mtests/myc_status.rs | 1+
Atests/relay_net.rs | 137+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mtests/runtime_show.rs | 33+++++++++++++++++++++++++++++++++
Mtests/signer_status.rs | 1+
19 files changed, 875 insertions(+), 12 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -1202,6 +1202,8 @@ dependencies = [ "serde_json", "tempfile", "thiserror 2.0.18", + "toml", + "url", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml @@ -25,6 +25,8 @@ radroots-nostr-signer = { path = "../lib/crates/nostr-signer" } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" thiserror = "2.0" +toml = "0.8" +url = "2.5" [dev-dependencies] assert_cmd = "2.0" diff --git a/src/cli.rs b/src/cli.rs @@ -38,6 +38,8 @@ pub struct CliArgs { #[arg(long, global = true)] pub signer: Option<String>, #[arg(long, global = true)] + pub relay: Vec<String>, + #[arg(long, global = true)] pub myc_executable: Option<PathBuf>, #[command(subcommand)] pub command: Command, @@ -383,6 +385,10 @@ mod tests { "identity.local.json", "--signer", "myc", + "--relay", + "wss://relay.one", + "--relay", + "wss://relay.two", "--myc-executable", "bin/myc", "config", @@ -415,6 +421,10 @@ mod tests { ); assert_eq!(parsed.signer.as_deref(), Some("myc")); assert_eq!( + parsed.relay, + vec!["wss://relay.one".to_owned(), "wss://relay.two".to_owned()] + ); + assert_eq!( parsed .myc_executable .as_deref() diff --git a/src/commands/doctor.rs b/src/commands/doctor.rs @@ -46,6 +46,7 @@ pub fn report( let mut checks = Vec::new(); checks.push(config_check(config)); checks.push(account_check(config)?); + checks.push(relay_check(config)); let signer = resolve_signer_status(config); checks.push(signer_check(&signer)); @@ -202,6 +203,34 @@ fn signer_check(signer: &crate::domain::runtime::SignerStatusView) -> EvaluatedC } } +fn relay_check(config: &RuntimeConfig) -> EvaluatedCheck { + if config.relay.urls.is_empty() { + return EvaluatedCheck { + severity: DoctorSeverity::Warn, + view: DoctorCheckView { + name: "relays".to_owned(), + status: "warn".to_owned(), + detail: "no relays configured".to_owned(), + }, + action: Some("radroots relay ls"), + }; + } + + EvaluatedCheck { + severity: DoctorSeverity::Ok, + view: DoctorCheckView { + name: "relays".to_owned(), + status: "ok".to_owned(), + detail: format!( + "{} configured · policy {}", + config.relay.urls.len(), + config.relay.publish_policy.as_str() + ), + }, + action: None, + } +} + fn myc_check(myc: &crate::domain::runtime::MycStatusView) -> EvaluatedCheck { let (severity, detail, action) = match myc.state.as_str() { "ready" => ( diff --git a/src/commands/mod.rs b/src/commands/mod.rs @@ -1,6 +1,8 @@ pub mod doctor; pub mod identity; pub mod myc; +pub mod net; +pub mod relay; pub mod runtime; pub mod signer; @@ -62,7 +64,7 @@ pub fn dispatch( LocalCommand::Backup => unimplemented_command("local backup"), }, Command::Net(net) => match &net.command { - NetCommand::Status => unimplemented_command("net status"), + NetCommand::Status => net::status(config), }, Command::Order(order) => match &order.command { OrderCommand::New => unimplemented_command("order new"), @@ -74,7 +76,7 @@ pub fn dispatch( OrderCommand::History => unimplemented_command("order history"), }, Command::Relay(relay) => match &relay.command { - RelayCommand::Ls => unimplemented_command("relay ls"), + RelayCommand::Ls => Ok(relay::list(config)), }, Command::Rpc(rpc) => match &rpc.command { RpcCommand::Status => unimplemented_command("rpc status"), diff --git a/src/commands/net.rs b/src/commands/net.rs @@ -0,0 +1,20 @@ +use crate::domain::runtime::{CommandDisposition, CommandOutput, CommandView}; +use crate::runtime::RuntimeError; +use crate::runtime::config::RuntimeConfig; +use crate::runtime::network; + +pub fn status(config: &RuntimeConfig) -> Result<CommandOutput, RuntimeError> { + let view = network::net_status(config)?; + Ok(match view.disposition() { + CommandDisposition::Success => CommandOutput::success(CommandView::NetStatus(view)), + CommandDisposition::Unconfigured => { + CommandOutput::unconfigured(CommandView::NetStatus(view)) + } + CommandDisposition::ExternalUnavailable => { + CommandOutput::external_unavailable(CommandView::NetStatus(view)) + } + CommandDisposition::InternalError => { + CommandOutput::internal_error(CommandView::NetStatus(view)) + } + }) +} diff --git a/src/commands/relay.rs b/src/commands/relay.rs @@ -0,0 +1,19 @@ +use crate::domain::runtime::{CommandDisposition, CommandOutput, CommandView}; +use crate::runtime::config::RuntimeConfig; +use crate::runtime::network; + +pub fn list(config: &RuntimeConfig) -> CommandOutput { + let view = network::relay_list(config); + match view.disposition() { + CommandDisposition::Success => CommandOutput::success(CommandView::RelayList(view)), + CommandDisposition::Unconfigured => { + CommandOutput::unconfigured(CommandView::RelayList(view)) + } + CommandDisposition::ExternalUnavailable => { + CommandOutput::external_unavailable(CommandView::RelayList(view)) + } + CommandDisposition::InternalError => { + CommandOutput::internal_error(CommandView::RelayList(view)) + } + } +} diff --git a/src/commands/runtime.rs b/src/commands/runtime.rs @@ -1,6 +1,6 @@ use crate::domain::runtime::{ AccountRuntimeView, ConfigFilesRuntimeView, ConfigShowView, LoggingRuntimeView, MycRuntimeView, - OutputRuntimeView, PathsRuntimeView, SignerRuntimeView, + OutputRuntimeView, PathsRuntimeView, RelayRuntimeView, SignerRuntimeView, }; use crate::runtime::config::RuntimeConfig; use crate::runtime::logging::LoggingState; @@ -46,6 +46,12 @@ pub fn show(config: &RuntimeConfig, logging: &LoggingState) -> ConfigShowView { signer: SignerRuntimeView { mode: config.signer.backend.as_str().to_owned(), }, + relay: RelayRuntimeView { + count: config.relay.urls.len(), + urls: config.relay.urls.clone(), + publish_policy: config.relay.publish_policy.as_str().to_owned(), + source: config.relay.source.as_str().to_owned(), + }, myc: MycRuntimeView { executable: config.myc.executable.display().to_string(), }, diff --git a/src/domain/runtime.rs b/src/domain/runtime.rs @@ -75,6 +75,8 @@ pub enum CommandView { ConfigShow(ConfigShowView), Doctor(DoctorView), MycStatus(MycStatusView), + NetStatus(NetStatusView), + RelayList(RelayListView), SignerStatus(SignerStatusView), } @@ -87,6 +89,7 @@ pub struct ConfigShowView { pub logging: LoggingRuntimeView, pub account: AccountRuntimeView, pub signer: SignerRuntimeView, + pub relay: RelayRuntimeView, pub myc: MycRuntimeView, } @@ -135,6 +138,14 @@ pub struct SignerRuntimeView { } #[derive(Debug, Clone, Serialize)] +pub struct RelayRuntimeView { + pub count: usize, + pub urls: Vec<String>, + pub publish_policy: String, + pub source: String, +} + +#[derive(Debug, Clone, Serialize)] pub struct MycRuntimeView { pub executable: String, } @@ -246,6 +257,60 @@ pub struct AccountListView { } #[derive(Debug, Clone, Serialize)] +pub struct RelayListView { + pub state: String, + pub source: String, + pub publish_policy: String, + pub count: usize, + #[serde(skip_serializing_if = "Option::is_none")] + pub reason: Option<String>, + pub relays: Vec<RelayEntryView>, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub actions: Vec<String>, +} + +impl RelayListView { + pub fn disposition(&self) -> CommandDisposition { + match self.state.as_str() { + "unconfigured" => CommandDisposition::Unconfigured, + _ => CommandDisposition::Success, + } + } +} + +#[derive(Debug, Clone, Serialize)] +pub struct RelayEntryView { + pub url: String, + pub read: bool, + pub write: bool, +} + +#[derive(Debug, Clone, Serialize)] +pub struct NetStatusView { + pub state: String, + pub source: String, + pub session: String, + pub relay_count: usize, + pub publish_policy: String, + pub signer_mode: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub active_account_id: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub reason: Option<String>, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub actions: Vec<String>, +} + +impl NetStatusView { + pub fn disposition(&self) -> CommandDisposition { + match self.state.as_str() { + "unconfigured" => CommandDisposition::Unconfigured, + _ => CommandDisposition::Success, + } + } +} + +#[derive(Debug, Clone, Serialize)] pub struct SignerStatusView { pub mode: String, pub state: String, diff --git a/src/render/mod.rs b/src/render/mod.rs @@ -2,6 +2,7 @@ use std::io::{self, Write}; use crate::domain::runtime::{ AccountListView, AccountSummaryView, CommandOutput, CommandView, DoctorCheckView, DoctorView, + NetStatusView, RelayListView, }; use crate::runtime::RuntimeError; use crate::runtime::config::{OutputConfig, OutputFormat}; @@ -71,12 +72,18 @@ fn render_human_to(stdout: &mut dyn Write, output: &CommandOutput) -> Result<(), CommandView::MycStatus(view) => { render_myc_status(stdout, view, true)?; } + CommandView::NetStatus(view) => { + render_net_status(stdout, view)?; + } CommandView::ConfigShow(view) => { render_config_show(stdout, view)?; } CommandView::Doctor(view) => { render_doctor(stdout, view)?; } + CommandView::RelayList(view) => { + render_relay_list(stdout, view)?; + } CommandView::SignerStatus(view) => { write_context( stdout, @@ -140,6 +147,10 @@ fn render_json_to(stdout: &mut dyn Write, output: &CommandOutput) -> Result<(), serde_json::to_writer_pretty(&mut *stdout, view)?; writeln!(stdout)?; } + CommandView::NetStatus(view) => { + serde_json::to_writer_pretty(&mut *stdout, view)?; + writeln!(stdout)?; + } CommandView::ConfigShow(view) => { serde_json::to_writer_pretty(&mut *stdout, view)?; writeln!(stdout)?; @@ -148,6 +159,10 @@ fn render_json_to(stdout: &mut dyn Write, output: &CommandOutput) -> Result<(), serde_json::to_writer_pretty(&mut *stdout, view)?; writeln!(stdout)?; } + CommandView::RelayList(view) => { + serde_json::to_writer_pretty(&mut *stdout, view)?; + writeln!(stdout)?; + } CommandView::SignerStatus(view) => { serde_json::to_writer_pretty(&mut *stdout, view)?; writeln!(stdout)?; @@ -170,6 +185,13 @@ fn render_ndjson_to(stdout: &mut dyn Write, output: &CommandOutput) -> Result<() } Ok(()) } + CommandView::RelayList(view) => { + for relay in &view.relays { + serde_json::to_writer(&mut *stdout, relay)?; + writeln!(stdout)?; + } + Ok(()) + } _ => Err(RuntimeError::Config(format!( "`{}` does not support --ndjson", human_command_name(output.view()) @@ -274,6 +296,16 @@ fn render_config_show( } render_pairs(stdout, "account", account_rows.as_slice())?; render_pairs(stdout, "signer", &[("mode", view.signer.mode.as_str())])?; + let relay_count = view.relay.count.to_string(); + render_pairs( + stdout, + "relay", + &[ + ("count", relay_count.as_str()), + ("publish policy", view.relay.publish_policy.as_str()), + ("source", view.relay.source.as_str()), + ], + )?; render_pairs( stdout, "myc", @@ -302,6 +334,71 @@ fn render_doctor(stdout: &mut dyn Write, view: &DoctorView) -> Result<(), Runtim Ok(()) } +fn render_relay_list(stdout: &mut dyn Write, view: &RelayListView) -> Result<(), RuntimeError> { + write_context( + stdout, + match view.state.as_str() { + "configured" => "relays · configured", + _ => "relays · unconfigured", + }, + )?; + if view.relays.is_empty() { + if let Some(reason) = &view.reason { + writeln!(stdout, "{reason}")?; + writeln!(stdout)?; + } + } else { + let table = Table { + headers: &["relay", "read", "write"], + rows: view + .relays + .iter() + .map(|relay| { + vec![ + relay.url.clone(), + yes_no(relay.read).to_owned(), + yes_no(relay.write).to_owned(), + ] + }) + .collect(), + }; + render_table(stdout, &table)?; + writeln!(stdout)?; + } + writeln!(stdout, "publish policy: {}", view.publish_policy)?; + writeln!(stdout, "source: {}", view.source)?; + render_actions(stdout, &view.actions)?; + Ok(()) +} + +fn render_net_status(stdout: &mut dyn Write, view: &NetStatusView) -> Result<(), RuntimeError> { + write_context( + stdout, + match view.state.as_str() { + "configured" => "network · configured", + _ => "network · unconfigured", + }, + )?; + let relay_count = view.relay_count.to_string(); + let mut rows = vec![ + ("status", view.state.as_str()), + ("session", view.session.as_str()), + ("relays configured", relay_count.as_str()), + ("publish policy", view.publish_policy.as_str()), + ("signer mode", view.signer_mode.as_str()), + ]; + if let Some(account_id) = &view.active_account_id { + rows.push(("active account id", account_id.as_str())); + } + render_pairs(stdout, "network", rows.as_slice())?; + if let Some(reason) = &view.reason { + writeln!(stdout, "reason: {reason}")?; + } + writeln!(stdout, "source: {}", view.source)?; + render_actions(stdout, &view.actions)?; + Ok(()) +} + fn doctor_row(check: &DoctorCheckView) -> Vec<String> { vec![ check.name.clone(), @@ -508,6 +605,8 @@ fn human_command_name(view: &CommandView) -> &'static str { CommandView::ConfigShow(_) => "config show", CommandView::Doctor(_) => "doctor", CommandView::MycStatus(_) => "myc status", + CommandView::NetStatus(_) => "net status", + CommandView::RelayList(_) => "relay ls", CommandView::SignerStatus(_) => "signer status", } } @@ -518,10 +617,12 @@ mod tests { use crate::commands::runtime; use crate::domain::runtime::{ AccountListView, CommandOutput, CommandView, DoctorCheckView, DoctorView, MycStatusView, + RelayEntryView, RelayListView, }; use crate::runtime::config::{ AccountConfig, IdentityConfig, LoggingConfig, MycConfig, OutputConfig, OutputFormat, - PathsConfig, RuntimeConfig, SignerBackend, SignerConfig, Verbosity, + PathsConfig, RelayConfig, RelayConfigSource, RelayPublishPolicy, RuntimeConfig, + SignerBackend, SignerConfig, Verbosity, }; use crate::runtime::logging::LoggingState; @@ -556,6 +657,11 @@ mod tests { signer: SignerConfig { backend: SignerBackend::Local, }, + relay: RelayConfig { + urls: vec!["wss://relay.one".into(), "wss://relay.two".into()], + publish_policy: RelayPublishPolicy::Any, + source: RelayConfigSource::WorkspaceConfig, + }, myc: MycConfig { executable: "myc".into(), }, @@ -576,6 +682,8 @@ mod tests { .store_path .ends_with(".local/share/radroots/accounts/store.json") ); + assert_eq!(view.relay.count, 2); + assert_eq!(view.relay.publish_policy, "any"); } #[test] @@ -630,6 +738,11 @@ mod tests { signer: SignerConfig { backend: SignerBackend::Local, }, + relay: RelayConfig { + urls: Vec::new(), + publish_policy: RelayPublishPolicy::Any, + source: RelayConfigSource::Defaults, + }, myc: MycConfig { executable: "myc".into(), }, @@ -679,6 +792,37 @@ mod tests { } #[test] + fn relay_list_ndjson_emits_one_json_object_per_relay() { + let output = CommandOutput::success(CommandView::RelayList(RelayListView { + state: "configured".to_owned(), + source: "workspace config · local first".to_owned(), + publish_policy: "any".to_owned(), + count: 2, + reason: None, + relays: vec![ + RelayEntryView { + url: "wss://relay.one".to_owned(), + read: true, + write: true, + }, + RelayEntryView { + url: "wss://relay.two".to_owned(), + read: true, + write: true, + }, + ], + actions: Vec::new(), + })); + let mut buffer = Vec::new(); + render_ndjson_to(&mut buffer, &output).expect("render relay ndjson"); + let rendered = String::from_utf8(buffer).expect("utf8"); + let lines = rendered.lines().collect::<Vec<_>>(); + assert_eq!(lines.len(), 2); + assert!(lines[0].contains("\"url\":\"wss://relay.one\"")); + assert!(lines[1].contains("\"url\":\"wss://relay.two\"")); + } + + #[test] fn human_render_doctor_uses_check_table_and_actions() { let output = CommandOutput::unconfigured(CommandView::Doctor(DoctorView { ok: false, diff --git a/src/runtime/config.rs b/src/runtime/config.rs @@ -3,6 +3,9 @@ use std::fs; use std::path::Path; use std::path::PathBuf; +use serde::Deserialize; +use url::Url; + use crate::cli::CliArgs; use crate::runtime::RuntimeError; @@ -22,6 +25,7 @@ const ENV_LOG_STDOUT: &str = "RADROOTS_LOG_STDOUT"; const ENV_ACCOUNT: &str = "RADROOTS_ACCOUNT"; const ENV_IDENTITY_PATH: &str = "RADROOTS_IDENTITY_PATH"; const ENV_SIGNER: &str = "RADROOTS_SIGNER"; +const ENV_RELAYS: &str = "RADROOTS_RELAYS"; const ENV_MYC_EXECUTABLE: &str = "RADROOTS_MYC_EXECUTABLE"; const SUPPORTED_ENV_FILE_KEYS: &[&str] = &[ ENV_OUTPUT, @@ -34,6 +38,7 @@ const SUPPORTED_ENV_FILE_KEYS: &[&str] = &[ ENV_ACCOUNT, ENV_IDENTITY_PATH, ENV_SIGNER, + ENV_RELAYS, ENV_MYC_EXECUTABLE, ]; @@ -120,6 +125,47 @@ pub struct SignerConfig { pub backend: SignerBackend, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RelayPublishPolicy { + Any, +} + +impl RelayPublishPolicy { + pub fn as_str(self) -> &'static str { + match self { + Self::Any => "any", + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RelayConfigSource { + Flags, + Environment, + UserConfig, + WorkspaceConfig, + Defaults, +} + +impl RelayConfigSource { + pub fn as_str(self) -> &'static str { + match self { + Self::Flags => "cli flags · local first", + Self::Environment => "environment · local first", + Self::UserConfig => "user config · local first", + Self::WorkspaceConfig => "workspace config · local first", + Self::Defaults => "defaults · local first", + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RelayConfig { + pub urls: Vec<String>, + pub publish_policy: RelayPublishPolicy, + pub source: RelayConfigSource, +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct MycConfig { pub executable: PathBuf, @@ -133,6 +179,7 @@ pub struct RuntimeConfig { pub account: AccountConfig, pub identity: IdentityConfig, pub signer: SignerConfig, + pub relay: RelayConfig, pub myc: MycConfig, } @@ -146,6 +193,17 @@ pub struct PathsConfig { #[derive(Debug, Default)] struct EnvFileValues(BTreeMap<String, String>); +#[derive(Debug, Default, Deserialize)] +struct CliConfigFile { + relay: Option<RelayFileConfig>, +} + +#[derive(Debug, Default, Deserialize)] +struct RelayFileConfig { + urls: Option<Vec<String>>, + publish_policy: Option<String>, +} + pub trait Environment { fn var(&self, key: &str) -> Option<String>; fn current_dir(&self) -> Result<PathBuf, RuntimeError>; @@ -184,6 +242,8 @@ impl RuntimeConfig { env_file: &EnvFileValues, ) -> Result<Self, RuntimeError> { let paths = resolve_paths(env)?; + let workspace_config = load_cli_config_file(paths.workspace_config_path.as_path())?; + let user_config = load_cli_config_file(paths.user_config_path.as_path())?; Ok(Self { output: OutputConfig { format: resolve_output_format(args, env, env_file)?, @@ -236,6 +296,13 @@ impl RuntimeConfig { .transpose()? .unwrap_or(SignerBackend::Local), }, + relay: resolve_relay_config( + args, + env, + env_file, + user_config.as_ref(), + workspace_config.as_ref(), + )?, myc: MycConfig { executable: args .myc_executable @@ -262,6 +329,166 @@ fn resolve_paths(env: &dyn Environment) -> Result<PathsConfig, RuntimeError> { }) } +fn load_cli_config_file(path: &Path) -> Result<Option<CliConfigFile>, RuntimeError> { + if !path.exists() { + return Ok(None); + } + + let raw = fs::read_to_string(path).map_err(|err| { + RuntimeError::Config(format!( + "failed to read config file {}: {err}", + path.display() + )) + })?; + + if raw.trim().is_empty() { + return Ok(Some(CliConfigFile::default())); + } + + toml::from_str::<CliConfigFile>(&raw) + .map(Some) + .map_err(|err| { + RuntimeError::Config(format!( + "failed to parse config file {}: {err}", + path.display() + )) + }) +} + +fn resolve_relay_config( + args: &CliArgs, + env: &dyn Environment, + env_file: &EnvFileValues, + user_config: Option<&CliConfigFile>, + workspace_config: Option<&CliConfigFile>, +) -> Result<RelayConfig, RuntimeError> { + let publish_policy = resolve_relay_publish_policy(user_config, workspace_config)? + .unwrap_or(RelayPublishPolicy::Any); + + if !args.relay.is_empty() { + return Ok(RelayConfig { + urls: normalize_relay_urls(args.relay.clone(), "--relay")?, + publish_policy, + source: RelayConfigSource::Flags, + }); + } + + if let Some(value) = env_value(env, env_file, &[ENV_RELAYS]) { + return Ok(RelayConfig { + urls: parse_relay_env_value(value.as_str(), ENV_RELAYS)?, + publish_policy, + source: RelayConfigSource::Environment, + }); + } + + if let Some(relay) = user_config.and_then(|config| config.relay.as_ref()) { + if let Some(urls) = relay.urls.clone() { + return Ok(RelayConfig { + urls: normalize_relay_urls(urls, "user config [relay].urls")?, + publish_policy, + source: RelayConfigSource::UserConfig, + }); + } + } + + if let Some(relay) = workspace_config.and_then(|config| config.relay.as_ref()) { + if let Some(urls) = relay.urls.clone() { + return Ok(RelayConfig { + urls: normalize_relay_urls(urls, "workspace config [relay].urls")?, + publish_policy, + source: RelayConfigSource::WorkspaceConfig, + }); + } + } + + Ok(RelayConfig { + urls: Vec::new(), + publish_policy, + source: RelayConfigSource::Defaults, + }) +} + +fn resolve_relay_publish_policy( + user_config: Option<&CliConfigFile>, + workspace_config: Option<&CliConfigFile>, +) -> Result<Option<RelayPublishPolicy>, RuntimeError> { + if let Some(value) = user_config + .and_then(|config| config.relay.as_ref()) + .and_then(|relay| relay.publish_policy.as_deref()) + { + return parse_relay_publish_policy(value).map(Some); + } + + if let Some(value) = workspace_config + .and_then(|config| config.relay.as_ref()) + .and_then(|relay| relay.publish_policy.as_deref()) + { + return parse_relay_publish_policy(value).map(Some); + } + + Ok(None) +} + +fn parse_relay_publish_policy(value: &str) -> Result<RelayPublishPolicy, RuntimeError> { + match value.trim().to_ascii_lowercase().as_str() { + "any" => Ok(RelayPublishPolicy::Any), + other => Err(RuntimeError::Config(format!( + "[relay].publish_policy must be `any`, got `{other}`" + ))), + } +} + +fn parse_relay_env_value(value: &str, key: &str) -> Result<Vec<String>, RuntimeError> { + let entries = value + .split(',') + .map(str::trim) + .filter(|entry| !entry.is_empty()) + .map(ToOwned::to_owned) + .collect::<Vec<_>>(); + + if entries.is_empty() { + return Err(RuntimeError::Config(format!( + "{key} must contain at least one websocket relay url" + ))); + } + + normalize_relay_urls(entries, key) +} + +fn normalize_relay_urls(values: Vec<String>, source: &str) -> Result<Vec<String>, RuntimeError> { + let mut normalized = Vec::new(); + for value in values { + let relay = validate_relay_url(value.as_str(), source)?; + if !normalized.iter().any(|existing| existing == &relay) { + normalized.push(relay); + } + } + Ok(normalized) +} + +fn validate_relay_url(value: &str, source: &str) -> Result<String, RuntimeError> { + let trimmed = value.trim(); + if trimmed.is_empty() { + return Err(RuntimeError::Config(format!( + "{source} contains an empty relay url" + ))); + } + + let parsed = Url::parse(trimmed).map_err(|err| { + RuntimeError::Config(format!( + "{source} contains invalid relay url `{trimmed}`: {err}" + )) + })?; + + if !matches!(parsed.scheme(), "ws" | "wss") || parsed.host_str().is_none() { + return Err(RuntimeError::Config(format!( + "{source} must use websocket relay urls, got `{trimmed}`" + ))); + } + + Ok(trimmed.to_owned()) +} + fn resolve_env_file_path(args: &CliArgs, env: &dyn Environment) -> Option<PathBuf> { args.env_file .clone() @@ -452,12 +679,15 @@ fn parse_bool_env(key: &str, value: &str) -> Result<bool, RuntimeError> { mod tests { use super::{ AccountConfig, EnvFileValues, Environment, OutputConfig, OutputFormat, PathsConfig, - RuntimeConfig, SignerBackend, Verbosity, parse_env_file_values, + RelayConfigSource, RelayPublishPolicy, RuntimeConfig, SignerBackend, Verbosity, + parse_env_file_values, }; use crate::cli::CliArgs; use clap::Parser; use std::collections::BTreeMap; + use std::fs; use std::path::{Path, PathBuf}; + use tempfile::tempdir; struct MapEnvironment { values: BTreeMap<String, String>, @@ -504,6 +734,10 @@ mod tests { "custom-identity.json", "--signer", "local", + "--relay", + "wss://relay.one", + "--relay", + "wss://relay.two", "--myc-executable", "bin/myc-cli", "config", @@ -518,6 +752,7 @@ mod tests { "env-identity.json".to_owned(), ), ("RADROOTS_SIGNER".to_owned(), "myc".to_owned()), + ("RADROOTS_RELAYS".to_owned(), "wss://relay.env".to_owned()), ("RADROOTS_MYC_EXECUTABLE".to_owned(), "env-myc".to_owned()), ])); @@ -557,6 +792,12 @@ mod tests { } ); assert_eq!(resolved.signer.backend, SignerBackend::Local); + assert_eq!( + resolved.relay.urls, + vec!["wss://relay.one".to_owned(), "wss://relay.two".to_owned()] + ); + assert_eq!(resolved.relay.source, RelayConfigSource::Flags); + assert_eq!(resolved.relay.publish_policy, RelayPublishPolicy::Any); assert_eq!(resolved.myc.executable, PathBuf::from("bin/myc-cli")); } @@ -577,6 +818,10 @@ mod tests { "state/identity.json".to_owned(), ), ("RADROOTS_SIGNER".to_owned(), "myc".to_owned()), + ( + "RADROOTS_RELAYS".to_owned(), + "wss://relay.one,wss://relay.two".to_owned(), + ), ("RADROOTS_MYC_EXECUTABLE".to_owned(), "bin/myc".to_owned()), ])); @@ -600,6 +845,11 @@ mod tests { assert_eq!(resolved.account.selector.as_deref(), Some("acct_demo")); assert_eq!(resolved.identity.path, PathBuf::from("state/identity.json")); assert_eq!(resolved.signer.backend, SignerBackend::Myc); + assert_eq!( + resolved.relay.urls, + vec!["wss://relay.one".to_owned(), "wss://relay.two".to_owned()] + ); + assert_eq!(resolved.relay.source, RelayConfigSource::Environment); assert_eq!(resolved.myc.executable, PathBuf::from("bin/myc")); } @@ -672,6 +922,7 @@ RADROOTS_CLI_LOGGING_STDOUT=false RADROOTS_ACCOUNT=acct_env_file RADROOTS_IDENTITY_PATH=state/identity.json RADROOTS_SIGNER=myc +RADROOTS_RELAYS=wss://relay.env-file RADROOTS_MYC_EXECUTABLE=bin/myc "#, Path::new(".env.test"), @@ -690,6 +941,8 @@ RADROOTS_MYC_EXECUTABLE=bin/myc assert_eq!(resolved.account.selector.as_deref(), Some("acct_env_file")); assert_eq!(resolved.identity.path, PathBuf::from("state/identity.json")); assert_eq!(resolved.signer.backend, SignerBackend::Myc); + assert_eq!(resolved.relay.urls, vec!["wss://relay.env-file".to_owned()]); + assert_eq!(resolved.relay.source, RelayConfigSource::Environment); assert_eq!(resolved.myc.executable, PathBuf::from("bin/myc")); } @@ -717,6 +970,59 @@ RADROOTS_CLI_LOGGING_STDOUT=false } #[test] + fn user_relay_config_overrides_workspace_relay_config() { + let temp = tempdir().expect("tempdir"); + let workspace_root = temp.path().join("workspace"); + let user_home = temp.path().join("home"); + fs::create_dir_all(workspace_root.join(".radroots")).expect("workspace config dir"); + fs::create_dir_all(user_home.join(".config/radroots")).expect("user config dir"); + fs::write( + workspace_root.join(".radroots/config.toml"), + "[relay]\nurls = [\"wss://relay.workspace\"]\npublish_policy = \"any\"\n", + ) + .expect("write workspace config"); + fs::write( + user_home.join(".config/radroots/config.toml"), + "[relay]\nurls = [\"wss://relay.user\", \"wss://relay.workspace\"]\n", + ) + .expect("write user config"); + + let env = MapEnvironment { + values: BTreeMap::new(), + current_dir: workspace_root, + home_dir: user_home, + }; + let args = CliArgs::parse_from(["radroots", "config", "show"]); + + let resolved = RuntimeConfig::resolve_with_env_file(&args, &env, &EnvFileValues::default()) + .expect("resolve config"); + assert_eq!( + resolved.relay.urls, + vec![ + "wss://relay.user".to_owned(), + "wss://relay.workspace".to_owned() + ] + ); + assert_eq!(resolved.relay.source, RelayConfigSource::UserConfig); + assert_eq!(resolved.relay.publish_policy, RelayPublishPolicy::Any); + } + + #[test] + fn invalid_relay_url_fails() { + let args = CliArgs::parse_from([ + "radroots", + "--relay", + "https://not-a-websocket.example.com", + "relay", + "ls", + ]); + let env = MapEnvironment::new(BTreeMap::new()); + let error = RuntimeConfig::resolve_with_env_file(&args, &env, &EnvFileValues::default()) + .expect_err("invalid relay url"); + assert!(error.to_string().contains("websocket relay urls")); + } + + #[test] fn state_roots_are_resolved_from_home_and_workspace() { let args = CliArgs::parse_from(["radroots", "config", "show"]); let env = MapEnvironment::new(BTreeMap::new()); diff --git a/src/runtime/mod.rs b/src/runtime/mod.rs @@ -2,6 +2,7 @@ pub mod accounts; pub mod config; pub mod logging; pub mod myc; +pub mod network; pub mod signer; use std::process::ExitCode; diff --git a/src/runtime/network.rs b/src/runtime/network.rs @@ -0,0 +1,72 @@ +use crate::domain::runtime::{NetStatusView, RelayEntryView, RelayListView}; +use crate::runtime::RuntimeError; +use crate::runtime::accounts; +use crate::runtime::config::RuntimeConfig; + +pub fn relay_list(config: &RuntimeConfig) -> RelayListView { + let relays = config + .relay + .urls + .iter() + .cloned() + .map(|url| RelayEntryView { + url, + read: true, + write: true, + }) + .collect::<Vec<_>>(); + + let state = if relays.is_empty() { + "unconfigured" + } else { + "configured" + }; + + RelayListView { + state: state.to_owned(), + source: config.relay.source.as_str().to_owned(), + publish_policy: config.relay.publish_policy.as_str().to_owned(), + count: relays.len(), + reason: relays + .is_empty() + .then_some("no relays are configured for this operator session".to_owned()), + relays, + actions: relay_actions(config), + } +} + +pub fn net_status(config: &RuntimeConfig) -> Result<NetStatusView, RuntimeError> { + let active_account_id = + accounts::resolve_account(config)?.map(|account| account.record.account_id.to_string()); + let relay_count = config.relay.urls.len(); + let configured = relay_count > 0; + + Ok(NetStatusView { + state: if configured { + "configured".to_owned() + } else { + "unconfigured".to_owned() + }, + source: config.relay.source.as_str().to_owned(), + session: if configured { + "not_started".to_owned() + } else { + "not_configured".to_owned() + }, + relay_count, + publish_policy: config.relay.publish_policy.as_str().to_owned(), + signer_mode: config.signer.backend.as_str().to_owned(), + active_account_id, + reason: (!configured) + .then_some("no relays are configured for this operator session".to_owned()), + actions: relay_actions(config), + }) +} + +fn relay_actions(config: &RuntimeConfig) -> Vec<String> { + if config.relay.urls.is_empty() { + vec!["radroots relay ls --relay wss://relay.example.com".to_owned()] + } else { + Vec::new() + } +} diff --git a/tests/doctor.rs b/tests/doctor.rs @@ -22,6 +22,7 @@ fn doctor_command_in(workdir: &Path) -> Command { "RADROOTS_ACCOUNT", "RADROOTS_IDENTITY_PATH", "RADROOTS_SIGNER", + "RADROOTS_RELAYS", "RADROOTS_MYC_EXECUTABLE", ] { command.env_remove(key); @@ -46,10 +47,13 @@ fn doctor_reports_unconfigured_local_bootstrap_state() { assert_eq!(json["checks"][0]["status"], "ok"); assert_eq!(json["checks"][1]["name"], "account"); assert_eq!(json["checks"][1]["status"], "warn"); - assert_eq!(json["checks"][2]["name"], "signer"); + assert_eq!(json["checks"][2]["name"], "relays"); assert_eq!(json["checks"][2]["status"], "warn"); + assert_eq!(json["checks"][3]["name"], "signer"); + assert_eq!(json["checks"][3]["status"], "warn"); assert_eq!(json["source"], "local diagnostics"); assert_eq!(json["actions"][0], "radroots account new"); + assert_eq!(json["actions"][1], "radroots relay ls"); } #[test] @@ -62,7 +66,7 @@ fn doctor_reports_ready_local_bootstrap_state() { assert!(init.status.success()); let output = doctor_command_in(dir.path()) - .args(["--json", "doctor"]) + .args(["--json", "--relay", "wss://relay.one", "doctor"]) .output() .expect("run doctor"); @@ -73,15 +77,21 @@ fn doctor_reports_ready_local_bootstrap_state() { assert_eq!(json["state"], "ok"); assert_eq!(json["checks"][1]["name"], "account"); assert_eq!(json["checks"][1]["status"], "ok"); - assert_eq!(json["checks"][2]["name"], "signer"); + assert_eq!(json["checks"][2]["name"], "relays"); assert_eq!(json["checks"][2]["status"], "ok"); + assert_eq!(json["checks"][3]["name"], "signer"); + assert_eq!(json["checks"][3]["status"], "ok"); assert_eq!(json["actions"], Value::Null); } #[test] fn doctor_reports_external_failure_for_missing_myc() { let dir = tempdir().expect("tempdir"); - fs::write(dir.path().join(".env"), "RADROOTS_SIGNER=myc\n").expect("write env file"); + fs::write( + dir.path().join(".env"), + "RADROOTS_SIGNER=myc\nRADROOTS_RELAYS=wss://relay.one\n", + ) + .expect("write env file"); let output = doctor_command_in(dir.path()) .args(["--json", "--myc-executable", "missing-myc", "doctor"]) @@ -92,9 +102,11 @@ fn doctor_reports_external_failure_for_missing_myc() { let stdout = String::from_utf8(output.stdout).expect("utf8 stdout"); let json: Value = serde_json::from_str(stdout.as_str()).expect("json output"); assert_eq!(json["state"], "fail"); - assert_eq!(json["checks"][2]["name"], "signer"); - assert_eq!(json["checks"][2]["status"], "fail"); - assert_eq!(json["checks"][3]["name"], "myc"); + assert_eq!(json["checks"][2]["name"], "relays"); + assert_eq!(json["checks"][2]["status"], "ok"); + assert_eq!(json["checks"][3]["name"], "signer"); assert_eq!(json["checks"][3]["status"], "fail"); + assert_eq!(json["checks"][4]["name"], "myc"); + assert_eq!(json["checks"][4]["status"], "fail"); assert_eq!(json["source"], "local diagnostics + myc status command"); } diff --git a/tests/identity_commands.rs b/tests/identity_commands.rs @@ -21,6 +21,7 @@ fn cli_command_in(workdir: &Path) -> Command { "RADROOTS_ACCOUNT", "RADROOTS_IDENTITY_PATH", "RADROOTS_SIGNER", + "RADROOTS_RELAYS", "RADROOTS_MYC_EXECUTABLE", ] { command.env_remove(key); diff --git a/tests/myc_status.rs b/tests/myc_status.rs @@ -25,6 +25,7 @@ fn cli_command_in(workdir: &Path) -> Command { "RADROOTS_ACCOUNT", "RADROOTS_IDENTITY_PATH", "RADROOTS_SIGNER", + "RADROOTS_RELAYS", "RADROOTS_MYC_EXECUTABLE", ] { command.env_remove(key); diff --git a/tests/relay_net.rs b/tests/relay_net.rs @@ -0,0 +1,137 @@ +use std::fs; +use std::path::Path; +use std::process::Command; + +use assert_cmd::prelude::*; +use serde_json::Value; +use tempfile::tempdir; + +fn cli_command_in(workdir: &Path) -> Command { + let mut command = Command::cargo_bin("radroots").expect("binary"); + command.current_dir(workdir); + command.env("HOME", workdir.join("home")); + for key in [ + "RADROOTS_ENV_FILE", + "RADROOTS_OUTPUT", + "RADROOTS_CLI_LOGGING_FILTER", + "RADROOTS_CLI_LOGGING_OUTPUT_DIR", + "RADROOTS_CLI_LOGGING_STDOUT", + "RADROOTS_LOG_FILTER", + "RADROOTS_LOG_DIR", + "RADROOTS_LOG_STDOUT", + "RADROOTS_ACCOUNT", + "RADROOTS_IDENTITY_PATH", + "RADROOTS_SIGNER", + "RADROOTS_RELAYS", + "RADROOTS_MYC_EXECUTABLE", + ] { + command.env_remove(key); + } + command +} + +#[test] +fn relay_ls_json_reports_workspace_configured_relays() { + let dir = tempdir().expect("tempdir"); + let config_dir = dir.path().join(".radroots"); + fs::create_dir_all(&config_dir).expect("workspace config dir"); + fs::write( + config_dir.join("config.toml"), + "[relay]\nurls = [\"wss://relay.one\", \"wss://relay.two\"]\npublish_policy = \"any\"\n", + ) + .expect("write workspace config"); + + let output = cli_command_in(dir.path()) + .args(["--json", "relay", "ls"]) + .output() + .expect("run relay ls"); + + assert!(output.status.success()); + let json: Value = serde_json::from_slice(output.stdout.as_slice()).expect("relay json"); + assert_eq!(json["state"], "configured"); + assert_eq!(json["count"], 2); + assert_eq!(json["publish_policy"], "any"); + assert_eq!(json["source"], "workspace config · local first"); + assert_eq!(json["relays"][0]["url"], "wss://relay.one"); + assert_eq!(json["relays"][0]["read"], true); + assert_eq!(json["relays"][0]["write"], true); + assert_eq!(json["relays"][1]["url"], "wss://relay.two"); +} + +#[test] +fn relay_ls_ndjson_emits_one_object_per_relay() { + let dir = tempdir().expect("tempdir"); + let output = cli_command_in(dir.path()) + .args([ + "--ndjson", + "--relay", + "wss://relay.one", + "--relay", + "wss://relay.two", + "relay", + "ls", + ]) + .output() + .expect("run relay ls"); + + assert!(output.status.success()); + let stdout = String::from_utf8(output.stdout).expect("utf8 stdout"); + let lines = stdout.lines().collect::<Vec<_>>(); + assert_eq!(lines.len(), 2); + assert!(lines[0].contains("\"url\":\"wss://relay.one\"")); + assert!(lines[1].contains("\"url\":\"wss://relay.two\"")); +} + +#[test] +fn relay_ls_without_relays_exits_unconfigured() { + let dir = tempdir().expect("tempdir"); + let output = cli_command_in(dir.path()) + .args(["relay", "ls"]) + .output() + .expect("run relay ls"); + + assert_eq!(output.status.code(), Some(3)); + let stdout = String::from_utf8(output.stdout).expect("utf8 stdout"); + assert!(stdout.contains("relays · unconfigured")); + assert!(stdout.contains("no relays are configured")); +} + +#[test] +fn net_status_json_reports_effective_network_configuration() { + let dir = tempdir().expect("tempdir"); + let init = cli_command_in(dir.path()) + .args(["--json", "account", "new"]) + .output() + .expect("run account new"); + assert!(init.status.success()); + let init_json: Value = serde_json::from_slice(init.stdout.as_slice()).expect("account json"); + let account_id = init_json["account"]["id"] + .as_str() + .expect("account id") + .to_owned(); + + let output = cli_command_in(dir.path()) + .args([ + "--json", + "--signer", + "local", + "--relay", + "wss://relay.one", + "--relay", + "wss://relay.two", + "net", + "status", + ]) + .output() + .expect("run net status"); + + assert!(output.status.success()); + let json: Value = serde_json::from_slice(output.stdout.as_slice()).expect("net json"); + assert_eq!(json["state"], "configured"); + assert_eq!(json["session"], "not_started"); + assert_eq!(json["relay_count"], 2); + assert_eq!(json["publish_policy"], "any"); + assert_eq!(json["signer_mode"], "local"); + assert_eq!(json["active_account_id"], account_id); + assert_eq!(json["source"], "cli flags · local first"); +} diff --git a/tests/runtime_show.rs b/tests/runtime_show.rs @@ -22,6 +22,7 @@ fn runtime_show_command_in(workdir: &Path) -> Command { "RADROOTS_ACCOUNT", "RADROOTS_IDENTITY_PATH", "RADROOTS_SIGNER", + "RADROOTS_RELAYS", "RADROOTS_MYC_EXECUTABLE", ] { command.env_remove(key); @@ -93,6 +94,9 @@ fn config_show_json_reports_default_bootstrap_state() { ); assert_eq!(json["account"]["legacy_identity_path"], "identity.json"); assert_eq!(json["signer"]["mode"], "local"); + assert_eq!(json["relay"]["count"], 0); + assert_eq!(json["relay"]["publish_policy"], "any"); + assert_eq!(json["relay"]["source"], "defaults · local first"); assert_eq!(json["myc"]["executable"], "myc"); } @@ -107,6 +111,7 @@ fn config_show_json_reflects_environment_configuration() { .env("RADROOTS_ACCOUNT", "acct_demo") .env("RADROOTS_IDENTITY_PATH", "state/identity.json") .env("RADROOTS_SIGNER", "myc") + .env("RADROOTS_RELAYS", "wss://relay.one,wss://relay.two") .env("RADROOTS_MYC_EXECUTABLE", "bin/myc") .args(["config", "show"]) .output() @@ -124,6 +129,9 @@ fn config_show_json_reflects_environment_configuration() { "state/identity.json" ); assert_eq!(json["signer"]["mode"], "myc"); + assert_eq!(json["relay"]["count"], 2); + assert_eq!(json["relay"]["urls"][0], "wss://relay.one"); + assert_eq!(json["relay"]["source"], "environment · local first"); assert_eq!(json["myc"]["executable"], "bin/myc"); } @@ -183,6 +191,31 @@ fn config_show_json_reads_logging_from_default_env_file() { } #[test] +fn config_show_json_reads_workspace_relay_config() { + let dir = tempdir().expect("tempdir"); + let config_dir = dir.path().join(".radroots"); + fs::create_dir_all(&config_dir).expect("workspace config dir"); + fs::write( + config_dir.join("config.toml"), + "[relay]\nurls = [\"wss://relay.workspace\", \"wss://relay.backup\"]\npublish_policy = \"any\"\n", + ) + .expect("write workspace config"); + + let output = runtime_show_command_in(dir.path()) + .args(["--json", "config", "show"]) + .output() + .expect("run config show"); + + assert!(output.status.success()); + let json: Value = serde_json::from_slice(output.stdout.as_slice()).expect("json output"); + assert_eq!(json["config_files"]["workspace_present"], true); + assert_eq!(json["relay"]["count"], 2); + assert_eq!(json["relay"]["urls"][0], "wss://relay.workspace"); + assert_eq!(json["relay"]["urls"][1], "wss://relay.backup"); + assert_eq!(json["relay"]["source"], "workspace config · local first"); +} + +#[test] fn config_show_rejects_ndjson_for_singular_output() { let dir = tempdir().expect("tempdir"); let output = runtime_show_command_in(dir.path()) diff --git a/tests/signer_status.rs b/tests/signer_status.rs @@ -22,6 +22,7 @@ fn cli_command_in(workdir: &Path) -> Command { "RADROOTS_ACCOUNT", "RADROOTS_IDENTITY_PATH", "RADROOTS_SIGNER", + "RADROOTS_RELAYS", "RADROOTS_MYC_EXECUTABLE", ] { command.env_remove(key);