cli

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

commit 237e62d1e14374ec0d300b9c2495401509139917
parent 649e1b21038ce4a069996dc33b3981cd0afe3147
Author: triesap <tyson@radroots.org>
Date:   Thu,  7 May 2026 02:53:03 +0000

cli: surface publish runtime state

- add resolved publish state to config output
- expose publish readiness through health commands
- report write plane posture from runtime config
- cover publish observability under deferred signer mode

Diffstat:
Msrc/domain/runtime.rs | 31+++++++++++++++++++++++++++++++
Msrc/operation_core.rs | 108+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Msrc/runtime/config.rs | 19+++++++++++++++++++
Msrc/runtime/mod.rs | 1-
Msrc/runtime/provider.rs | 11++++++-----
Mtests/target_cli.rs | 60+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
6 files changed, 221 insertions(+), 9 deletions(-)

diff --git a/src/domain/runtime.rs b/src/domain/runtime.rs @@ -48,6 +48,7 @@ pub struct ConfigShowView { pub logging: LoggingRuntimeView, pub account: AccountRuntimeView, pub signer: SignerRuntimeView, + pub publish: PublishRuntimeView, pub relay: RelayRuntimeView, pub local: LocalRuntimeView, pub myc: MycRuntimeView, @@ -298,6 +299,36 @@ pub struct RelayRuntimeView { } #[derive(Debug, Clone, Serialize)] +pub struct PublishRuntimeView { + pub mode: String, + pub source: String, + pub transport_family: String, + pub state: String, + pub executable: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub reason: Option<String>, + pub signed_write_required: bool, + pub relay: PublishRelayRuntimeView, + pub provider: PublishProviderRuntimeView, +} + +#[derive(Debug, Clone, Serialize)] +pub struct PublishRelayRuntimeView { + pub ready: bool, + pub count: usize, + pub source: String, +} + +#[derive(Debug, Clone, Serialize)] +pub struct PublishProviderRuntimeView { + pub provider_runtime_id: String, + pub state: String, + pub source: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub reason: Option<String>, +} + +#[derive(Debug, Clone, Serialize)] pub struct LocalRuntimeView { pub root: String, pub replica_db_path: String, diff --git a/src/operation_core.rs b/src/operation_core.rs @@ -3,7 +3,10 @@ use std::path::PathBuf; use serde::Serialize; use serde_json::{Value, json}; -use crate::domain::runtime::{CommandDisposition, LocalBackupView}; +use crate::domain::runtime::{ + CommandDisposition, LocalBackupView, PublishProviderRuntimeView, PublishRelayRuntimeView, + PublishRuntimeView, +}; use crate::operation_adapter::{ AccountAttachSecretRequest, AccountAttachSecretResult, AccountCreateRequest, AccountCreateResult, AccountGetRequest, AccountGetResult, AccountImportRequest, @@ -26,7 +29,7 @@ use crate::runtime::accounts::{ resolve_account_resolution, resolve_account_selector, secret_backend_status, select_account, snapshot, unresolved_account_reason, }; -use crate::runtime::config::RuntimeConfig; +use crate::runtime::config::{PublishMode, RuntimeConfig}; use crate::runtime::logging::LoggingState; use crate::runtime_args::LocalExportFormatArg; @@ -97,10 +100,12 @@ impl OperationService<HealthStatusGetRequest> for CoreOperationService<'_> { ) -> Result<OperationResult<Self::Result>, OperationAdapterError> { let store = map_runtime(crate::runtime::local::status(self.config))?; let account = map_runtime(resolve_account_resolution(self.config))?; + let publish = publish_runtime_view(self.config, false); json_operation_result::<HealthStatusGetResult>(json!({ "state": if store.state == "ready" { "ready" } else { "needs_attention" }, "store": store, "account_resolution": account_resolution_view(&account), + "publish": publish, "logging": { "initialized": self.logging.initialized, "current_file": self.logging.current_file.as_ref().map(|path| path.display().to_string()), @@ -123,6 +128,7 @@ impl OperationService<HealthCheckRunRequest> for CoreOperationService<'_> { } else { Some(map_runtime(unresolved_account_reason(self.config))?) }; + let publish = publish_runtime_view(self.config, false); json_operation_result::<HealthCheckRunResult>(json!({ "state": if store.state == "ready" && account.resolved_account.is_some() { "ready" } else { "needs_attention" }, "checks": { @@ -138,6 +144,12 @@ impl OperationService<HealthCheckRunRequest> for CoreOperationService<'_> { "state": if account.resolved_account.is_some() { "ready" } else { "unconfigured" }, "reason": account_reason, }, + "publish": { + "state": publish.state, + "mode": publish.mode, + "executable": publish.executable, + "reason": publish.reason, + }, }, })) } @@ -150,6 +162,7 @@ impl OperationService<ConfigGetRequest> for CoreOperationService<'_> { &self, _request: OperationRequest<ConfigGetRequest>, ) -> Result<OperationResult<Self::Result>, OperationAdapterError> { + let write_plane = crate::runtime::provider::resolve_write_plane_provider(self.config); json_operation_result::<ConfigGetResult>(json!({ "output": { "format": self.config.output.format.as_str(), @@ -174,11 +187,38 @@ impl OperationService<ConfigGetRequest> for CoreOperationService<'_> { "store_path": self.config.account.store_path.display().to_string(), "secrets_dir": self.config.account.secrets_dir.display().to_string(), }, + "signer": { + "mode": self.config.signer.backend.as_str(), + }, + "publish": publish_runtime_view(self.config, false), "relay": { "count": self.config.relay.urls.len(), "urls": self.config.relay.urls, "source": self.config.relay.source.as_str(), }, + "myc": { + "executable": self.config.myc.executable.display().to_string(), + "status_timeout_ms": self.config.myc.status_timeout_ms, + }, + "hyf": { + "enabled": self.config.hyf.enabled, + "executable": self.config.hyf.executable.display().to_string(), + }, + "rpc": { + "url": self.config.rpc.url, + "bridge_auth_configured": self.config.rpc.bridge_bearer_token.is_some(), + }, + "write_plane": { + "provider_runtime_id": write_plane.provider_runtime_id, + "binding_model": write_plane.binding_model, + "state": write_plane.state, + "provenance": write_plane.provenance, + "source": write_plane.source, + "target_kind": write_plane.target_kind, + "target": write_plane.target, + "detail": write_plane.detail, + "bridge_auth_configured": self.config.rpc.bridge_bearer_token.is_some(), + }, "local": { "root": self.config.local.root.display().to_string(), "replica_db_path": self.config.local.replica_db_path.display().to_string(), @@ -644,6 +684,70 @@ fn selected_config(config: &RuntimeConfig, selector: String) -> RuntimeConfig { config } +fn publish_runtime_view(config: &RuntimeConfig, signed_write_required: bool) -> PublishRuntimeView { + let relay_ready = !config.relay.urls.is_empty(); + let source = config.publish.source.as_str().to_owned(); + let relay = PublishRelayRuntimeView { + ready: relay_ready, + count: config.relay.urls.len(), + source: config.relay.source.as_str().to_owned(), + }; + + match config.publish.mode { + PublishMode::NostrRelay => { + let reason = (!relay_ready).then(|| { + "nostr_relay publish mode requires at least one configured relay for writes" + .to_owned() + }); + PublishRuntimeView { + mode: config.publish.mode.as_str().to_owned(), + source, + transport_family: config.publish.mode.transport_family().to_owned(), + state: if relay_ready { + "ready".to_owned() + } else { + "unconfigured".to_owned() + }, + executable: relay_ready, + reason: reason.clone(), + signed_write_required, + relay, + provider: PublishProviderRuntimeView { + provider_runtime_id: "nostr_relay".to_owned(), + state: if relay_ready { + "ready".to_owned() + } else { + "unconfigured".to_owned() + }, + source: config.relay.source.as_str().to_owned(), + reason, + }, + } + } + PublishMode::Radrootsd => PublishRuntimeView { + mode: config.publish.mode.as_str().to_owned(), + source, + transport_family: config.publish.mode.transport_family().to_owned(), + state: "unavailable".to_owned(), + executable: false, + reason: Some( + "radrootsd publish mode is configured but the radrootsd publish transport is not implemented" + .to_owned(), + ), + signed_write_required, + relay, + provider: PublishProviderRuntimeView { + provider_runtime_id: "radrootsd".to_owned(), + state: "unavailable".to_owned(), + source: "publish mode · local first".to_owned(), + reason: Some( + "radrootsd publish transport is reserved for a future implementation".to_owned(), + ), + }, + }, + } +} + fn required_string<P>( request: &OperationRequest<P>, key: &str, diff --git a/src/runtime/config.rs b/src/runtime/config.rs @@ -195,6 +195,13 @@ impl PublishMode { Self::Radrootsd => "radrootsd", } } + + pub fn transport_family(self) -> &'static str { + match self { + Self::NostrRelay => "nostr_relay", + Self::Radrootsd => "radrootsd", + } + } } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -206,6 +213,18 @@ pub enum PublishModeSource { Defaults, } +impl PublishModeSource { + 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 PublishConfig { pub mode: PublishMode, diff --git a/src/runtime/mod.rs b/src/runtime/mod.rs @@ -11,7 +11,6 @@ pub mod logging; pub mod network; pub mod order; pub mod paths; -#[cfg(test)] pub mod provider; pub mod signer; pub mod sync; diff --git a/src/runtime/provider.rs b/src/runtime/provider.rs @@ -10,10 +10,11 @@ use crate::runtime::hyf; const WRITE_PLANE_UNAVAILABLE_DETAIL: &str = "legacy write-plane provider is unavailable; use seller publish commands with configured direct relays"; -#[cfg(test)] #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ProviderProvenance { + #[cfg(test)] ExplicitBinding, + #[cfg(test)] ManagedDefault, #[cfg(test)] DirectConfig, @@ -22,11 +23,12 @@ pub enum ProviderProvenance { Unavailable, } -#[cfg(test)] impl ProviderProvenance { pub fn as_str(self) -> &'static str { match self { + #[cfg(test)] Self::ExplicitBinding => "explicit_binding", + #[cfg(test)] Self::ManagedDefault => "managed_default", #[cfg(test)] Self::DirectConfig => "direct_config", @@ -50,7 +52,6 @@ pub struct ResolvedProviderView { pub target: Option<String>, } -#[cfg(test)] #[derive(Debug, Clone, PartialEq, Eq)] pub struct WritePlaneProviderView { pub provider_runtime_id: String, @@ -64,6 +65,7 @@ pub struct WritePlaneProviderView { pub bridge_auth_configured: bool, } +#[cfg(test)] #[derive(Debug, Clone, PartialEq, Eq)] pub struct ResolvedWritePlaneTarget { pub url: String, @@ -86,12 +88,12 @@ pub struct HyfProviderView { pub deterministic_available: Option<bool>, } -#[cfg(test)] pub fn resolve_write_plane_provider(config: &RuntimeConfig) -> WritePlaneProviderView { let _ = config; unavailable_write_plane_view() } +#[cfg(test)] pub fn resolve_actor_write_plane_target( config: &RuntimeConfig, ) -> Result<ResolvedWritePlaneTarget, String> { @@ -153,7 +155,6 @@ pub fn resolve_capability_providers(config: &RuntimeConfig) -> Vec<ResolvedProvi }] } -#[cfg(test)] fn unavailable_write_plane_view() -> WritePlaneProviderView { WritePlaneProviderView { provider_runtime_id: "nostr_relay".to_owned(), diff --git a/tests/target_cli.rs b/tests/target_cli.rs @@ -8,7 +8,7 @@ use serde_json::Value; use support::{ RadrootsCliSandbox, assert_no_daemon_runtime_reference, assert_no_removed_command_reference, create_listing_draft, identity_public, make_listing_publishable, ndjson_from_stdout, radroots, - remove_orderable_listing, replace_latest_listing_event_id, seed_orderable_listing, + remove_orderable_listing, replace_latest_listing_event_id, seed_orderable_listing, toml_string, write_public_identity_profile, }; @@ -82,6 +82,64 @@ fn removed_global_flags_are_rejected_publicly() { } #[test] +fn config_get_exposes_resolved_publish_state() { + let sandbox = RadrootsCliSandbox::new(); + sandbox.write_app_config("[publish]\nmode = \"radrootsd\"\n"); + + let value = sandbox.json_success(&["--format", "json", "config", "get"]); + + assert_eq!(value["operation_id"], "config.get"); + assert_eq!(value["result"]["publish"]["mode"], "radrootsd"); + assert_eq!( + value["result"]["publish"]["source"], + "user config · local first" + ); + assert_eq!(value["result"]["publish"]["transport_family"], "radrootsd"); + assert_eq!(value["result"]["publish"]["state"], "unavailable"); + assert_eq!(value["result"]["publish"]["executable"], false); + assert_eq!( + value["result"]["publish"]["provider"]["provider_runtime_id"], + "radrootsd" + ); + assert_eq!(value["result"]["write_plane"]["state"], "unavailable"); +} + +#[test] +fn health_surfaces_publish_state_under_deferred_signer_mode() { + let sandbox = RadrootsCliSandbox::new(); + let missing_myc = sandbox.root().join("bin/missing-myc"); + sandbox.write_app_config(&format!( + "[publish]\nmode = \"radrootsd\"\n\n[signer]\nmode = \"myc\"\n\n[myc]\nexecutable = \"{}\"\n", + toml_string(missing_myc.display().to_string().as_str()) + )); + + let value = sandbox.json_success(&["--format", "json", "health", "status", "get"]); + + assert_eq!(value["operation_id"], "health.status.get"); + assert_eq!(value["result"]["publish"]["mode"], "radrootsd"); + assert_eq!(value["result"]["publish"]["executable"], false); + assert_eq!( + value["result"]["publish"]["provider"]["state"], + "unavailable" + ); + assert_eq!(value["errors"].as_array().expect("errors").len(), 0); +} + +#[test] +fn health_check_exposes_publish_readiness() { + let sandbox = RadrootsCliSandbox::new(); + sandbox.write_app_config("[publish]\nmode = \"radrootsd\"\n"); + + let value = sandbox.json_success(&["--format", "json", "health", "check", "run"]); + + assert_eq!(value["operation_id"], "health.check.run"); + assert_eq!(value["result"]["checks"]["publish"]["mode"], "radrootsd"); + assert_eq!(value["result"]["checks"]["publish"]["state"], "unavailable"); + assert_eq!(value["result"]["checks"]["publish"]["executable"], false); + assert_eq!(value["errors"].as_array().expect("errors").len(), 0); +} + +#[test] fn removed_order_submit_watch_flag_is_rejected_publicly() { let output = radroots() .args(["order", "submit", "--watch"])