cli

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

commit 5a8a172feb2f2fecf95cb2bb9e195b5d9a282d79
parent 77a641133b5545c59afad193775275376fd825aa
Author: triesap <tyson@radroots.org>
Date:   Thu,  7 May 2026 03:37:50 +0000

cli: make publish readiness signed-write aware

Diffstat:
Msrc/operation_core.rs | 102+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------
Mtests/target_cli.rs | 166+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
2 files changed, 240 insertions(+), 28 deletions(-)

diff --git a/src/operation_core.rs b/src/operation_core.rs @@ -23,13 +23,14 @@ use crate::operation_adapter::{ }; use crate::runtime::RuntimeError; use crate::runtime::accounts::{ - account_resolution_view, account_summary_view, attach_identity_secret, clear_default_account, - create_or_migrate_default_account, import_public_identity, preview_account_removal, - preview_identity_secret_attachment, preview_public_identity_import, remove_account, - resolve_account_resolution, resolve_account_selector, secret_backend_status, select_account, - snapshot, unresolved_account_reason, + AccountResolution, AccountRuntimeFailure, account_resolution_view, account_summary_view, + attach_identity_secret, clear_default_account, create_or_migrate_default_account, + import_public_identity, preview_account_removal, preview_identity_secret_attachment, + preview_public_identity_import, remove_account, resolve_account_resolution, + resolve_account_selector, secret_backend_status, select_account, snapshot, + unresolved_account_reason, }; -use crate::runtime::config::{PublishMode, RuntimeConfig}; +use crate::runtime::config::{PublishMode, RuntimeConfig, SignerBackend}; use crate::runtime::logging::LoggingState; use crate::runtime_args::LocalExportFormatArg; @@ -100,7 +101,7 @@ 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); + let publish = publish_runtime_view(self.config, true, &account); json_operation_result::<HealthStatusGetResult>(json!({ "state": if store.state == "ready" { "ready" } else { "needs_attention" }, "store": store, @@ -128,7 +129,7 @@ impl OperationService<HealthCheckRunRequest> for CoreOperationService<'_> { } else { Some(map_runtime(unresolved_account_reason(self.config))?) }; - let publish = publish_runtime_view(self.config, false); + let publish = publish_runtime_view(self.config, true, &account); json_operation_result::<HealthCheckRunResult>(json!({ "state": if store.state == "ready" && account.resolved_account.is_some() { "ready" } else { "needs_attention" }, "checks": { @@ -163,6 +164,7 @@ impl OperationService<ConfigGetRequest> for CoreOperationService<'_> { _request: OperationRequest<ConfigGetRequest>, ) -> Result<OperationResult<Self::Result>, OperationAdapterError> { let write_plane = crate::runtime::provider::resolve_write_plane_provider(self.config); + let account = map_runtime(resolve_account_resolution(self.config))?; json_operation_result::<ConfigGetResult>(json!({ "output": { "format": self.config.output.format.as_str(), @@ -190,7 +192,7 @@ impl OperationService<ConfigGetRequest> for CoreOperationService<'_> { "signer": { "mode": self.config.signer.backend.as_str(), }, - "publish": publish_runtime_view(self.config, false), + "publish": publish_runtime_view(self.config, true, &account), "relay": { "count": self.config.relay.urls.len(), "urls": self.config.relay.urls, @@ -684,7 +686,11 @@ fn selected_config(config: &RuntimeConfig, selector: String) -> RuntimeConfig { config } -fn publish_runtime_view(config: &RuntimeConfig, signed_write_required: bool) -> PublishRuntimeView { +fn publish_runtime_view( + config: &RuntimeConfig, + signed_write_required: bool, + account: &AccountResolution, +) -> PublishRuntimeView { let relay_ready = !config.relay.urls.is_empty(); let source = config.publish.source.as_str().to_owned(); let relay = PublishRelayRuntimeView { @@ -695,30 +701,20 @@ fn publish_runtime_view(config: &RuntimeConfig, signed_write_required: bool) -> 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() - }); + let (state, executable, reason) = + nostr_relay_publish_readiness(config, relay_ready, signed_write_required, account); 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, + state: state.to_owned(), + executable, 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() - }, + state: state.to_owned(), source: config.relay.source.as_str().to_owned(), reason, }, @@ -748,6 +744,62 @@ fn publish_runtime_view(config: &RuntimeConfig, signed_write_required: bool) -> } } +fn nostr_relay_publish_readiness( + config: &RuntimeConfig, + relay_ready: bool, + signed_write_required: bool, + account: &AccountResolution, +) -> (&'static str, bool, Option<String>) { + if !relay_ready { + return ( + "unconfigured", + false, + Some( + "nostr_relay publish mode requires at least one configured relay for writes" + .to_owned(), + ), + ); + } + + if !signed_write_required { + return ("ready", true, None); + } + + if matches!(config.signer.backend, SignerBackend::Myc) { + return ( + "unavailable", + false, + Some( + "nostr_relay publish mode requires signer mode `local` for signed writes; signer mode `myc` is deferred" + .to_owned(), + ), + ); + } + + let Some(resolved_account) = account.resolved_account.as_ref() else { + return ( + "unconfigured", + false, + Some( + "nostr_relay publish mode requires a selected or default write-capable local account for signed writes" + .to_owned(), + ), + ); + }; + + if !resolved_account.write_capable { + return ( + "unconfigured", + false, + Some( + AccountRuntimeFailure::watch_only(&resolved_account.record.account_id).to_string(), + ), + ); + } + + ("ready", true, None) +} + fn required_string<P>( request: &OperationRequest<P>, key: &str, diff --git a/tests/target_cli.rs b/tests/target_cli.rs @@ -6,9 +6,10 @@ use std::path::Path; 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, toml_string, + RadrootsCliSandbox, assert_contains, 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, toml_string, write_public_identity_profile, }; @@ -105,6 +106,118 @@ fn config_get_exposes_resolved_publish_state() { } #[test] +fn config_get_distinguishes_relay_ready_from_missing_signed_write_account() { + let sandbox = RadrootsCliSandbox::new(); + + let value = sandbox.json_success(&[ + "--format", + "json", + "--relay", + "ws://127.0.0.1:19001", + "config", + "get", + ]); + + assert_eq!(value["operation_id"], "config.get"); + assert_eq!(value["result"]["publish"]["mode"], "nostr_relay"); + assert_eq!(value["result"]["publish"]["relay"]["ready"], true); + assert_eq!(value["result"]["publish"]["signed_write_required"], true); + assert_eq!(value["result"]["publish"]["state"], "unconfigured"); + assert_eq!(value["result"]["publish"]["executable"], false); + assert_contains( + &value["result"]["publish"]["reason"], + "write-capable local account", + ); + assert_eq!( + value["result"]["publish"]["provider"]["state"], + "unconfigured" + ); +} + +#[test] +fn config_get_marks_relay_publish_ready_with_secret_backed_local_account() { + let sandbox = RadrootsCliSandbox::new(); + sandbox.json_success(&["--format", "json", "account", "create"]); + + let value = sandbox.json_success(&[ + "--format", + "json", + "--relay", + "ws://127.0.0.1:19002", + "config", + "get", + ]); + + assert_eq!(value["result"]["publish"]["mode"], "nostr_relay"); + assert_eq!(value["result"]["publish"]["relay"]["ready"], true); + assert_eq!(value["result"]["publish"]["signed_write_required"], true); + assert_eq!(value["result"]["publish"]["state"], "ready"); + assert_eq!(value["result"]["publish"]["executable"], true); + assert_eq!(value["result"]["publish"]["reason"], Value::Null); + assert_eq!(value["result"]["publish"]["provider"]["state"], "ready"); +} + +#[test] +fn config_get_marks_relay_publish_unavailable_with_deferred_signer_mode() { + let sandbox = RadrootsCliSandbox::new(); + sandbox.json_success(&["--format", "json", "account", "create"]); + sandbox.write_app_config("[signer]\nmode = \"myc\"\n"); + + let value = sandbox.json_success(&[ + "--format", + "json", + "--relay", + "ws://127.0.0.1:19003", + "config", + "get", + ]); + + assert_eq!(value["result"]["publish"]["mode"], "nostr_relay"); + assert_eq!(value["result"]["publish"]["relay"]["ready"], true); + assert_eq!(value["result"]["publish"]["signed_write_required"], true); + assert_eq!(value["result"]["publish"]["state"], "unavailable"); + assert_eq!(value["result"]["publish"]["executable"], false); + assert_contains(&value["result"]["publish"]["reason"], "signer mode `local`"); + assert_eq!( + value["result"]["publish"]["provider"]["state"], + "unavailable" + ); +} + +#[test] +fn config_get_marks_relay_publish_unconfigured_with_watch_only_account() { + let sandbox = RadrootsCliSandbox::new(); + let public_identity = identity_public(41); + let public_identity_file = + write_public_identity_profile(&sandbox, "publish-readiness-watch-only", &public_identity); + sandbox.json_success(&[ + "--format", + "json", + "--approval-token", + "approve", + "account", + "import", + "--default", + public_identity_file.to_string_lossy().as_ref(), + ]); + + let value = sandbox.json_success(&[ + "--format", + "json", + "--relay", + "ws://127.0.0.1:19004", + "config", + "get", + ]); + + assert_eq!(value["result"]["publish"]["relay"]["ready"], true); + assert_eq!(value["result"]["publish"]["signed_write_required"], true); + assert_eq!(value["result"]["publish"]["state"], "unconfigured"); + assert_eq!(value["result"]["publish"]["executable"], false); + assert_contains(&value["result"]["publish"]["reason"], "watch_only"); +} + +#[test] fn health_surfaces_publish_state_under_deferred_signer_mode() { let sandbox = RadrootsCliSandbox::new(); let missing_myc = sandbox.root().join("bin/missing-myc"); @@ -126,6 +239,31 @@ fn health_surfaces_publish_state_under_deferred_signer_mode() { } #[test] +fn health_status_distinguishes_relay_ready_from_missing_signed_write_account() { + let sandbox = RadrootsCliSandbox::new(); + + let value = sandbox.json_success(&[ + "--format", + "json", + "--relay", + "ws://127.0.0.1:19005", + "health", + "status", + "get", + ]); + + assert_eq!(value["operation_id"], "health.status.get"); + assert_eq!(value["result"]["publish"]["relay"]["ready"], true); + assert_eq!(value["result"]["publish"]["signed_write_required"], true); + assert_eq!(value["result"]["publish"]["state"], "unconfigured"); + assert_eq!(value["result"]["publish"]["executable"], false); + assert_contains( + &value["result"]["publish"]["reason"], + "write-capable local account", + ); +} + +#[test] fn health_check_exposes_publish_readiness() { let sandbox = RadrootsCliSandbox::new(); sandbox.write_app_config("[publish]\nmode = \"radrootsd\"\n"); @@ -140,6 +278,28 @@ fn health_check_exposes_publish_readiness() { } #[test] +fn health_check_marks_relay_publish_ready_with_secret_backed_local_account() { + let sandbox = RadrootsCliSandbox::new(); + sandbox.json_success(&["--format", "json", "account", "create"]); + + let value = sandbox.json_success(&[ + "--format", + "json", + "--relay", + "ws://127.0.0.1:19006", + "health", + "check", + "run", + ]); + + assert_eq!(value["operation_id"], "health.check.run"); + assert_eq!(value["result"]["checks"]["publish"]["mode"], "nostr_relay"); + assert_eq!(value["result"]["checks"]["publish"]["state"], "ready"); + assert_eq!(value["result"]["checks"]["publish"]["executable"], true); + assert_eq!(value["errors"].as_array().expect("errors").len(), 0); +} + +#[test] fn radrootsd_publish_mode_fails_closed_for_direct_relay_publish_paths() { let sandbox = RadrootsCliSandbox::new(); let missing_listing = sandbox.root().join("missing-listing.toml");