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:
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);