cli

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

commit 28818d7077dbf920fe410e6030b458d474832b30
parent 91ee8322f6202d65dea09fcaf64e3e6aca1ea3e5
Author: triesap <tyson@radroots.org>
Date:   Fri,  8 May 2026 05:39:22 +0000

output: expose mode-aware recovery

Diffstat:
Msrc/main.rs | 72+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Msrc/operation_adapter.rs | 70+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Msrc/operation_core.rs | 113++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------
Msrc/runtime/provider.rs | 111+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------
Msrc/target_cli.rs | 27+++++++++++++++++++++++++--
Mtests/target_cli.rs | 158++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
6 files changed, 509 insertions(+), 42 deletions(-)

diff --git a/src/main.rs b/src/main.rs @@ -22,7 +22,7 @@ use std::sync::atomic::{AtomicU64, Ordering}; use std::time::{SystemTime, UNIX_EPOCH}; use clap::Parser; -use serde_json::json; +use serde_json::{Value, json}; use crate::deferred_payment::{deferred_payment_message, is_deferred_payment_operation}; use crate::operation_adapter::{ @@ -572,9 +572,79 @@ fn render_human_envelope( writeln!(handle, "error: {}", error.code)?; writeln!(handle, "message: {}", error.message)?; } + if let Some(mode) = human_publish_mode(&envelope.result) { + writeln!(handle, "publish_mode: {mode}")?; + } + if let Some(state) = human_publish_state(&envelope.result) { + writeln!(handle, "publish_state: {state}")?; + } + if let Some(reason) = human_reason(&envelope.result) { + writeln!(handle, "reason: {reason}")?; + } + let actions = human_actions(envelope); + if !actions.is_empty() { + writeln!(handle, "next:")?; + for action in actions { + writeln!(handle, "- {action}")?; + } + } Ok(()) } +fn human_publish_mode(result: &Value) -> Option<&str> { + human_string_path(result, &["publish", "mode"]) + .or_else(|| human_string_path(result, &["checks", "publish", "mode"])) + .or_else(|| human_string_path(result, &["publish_mode"])) +} + +fn human_publish_state(result: &Value) -> Option<&str> { + human_string_path(result, &["publish", "state"]) + .or_else(|| human_string_path(result, &["checks", "publish", "state"])) + .or_else(|| human_string_path(result, &["publish_state"])) +} + +fn human_reason(result: &Value) -> Option<&str> { + human_string_path(result, &["reason"]) + .or_else(|| human_string_path(result, &["publish", "reason"])) + .or_else(|| human_string_path(result, &["checks", "publish", "reason"])) + .or_else(|| human_string_path(result, &["store", "reason"])) + .or_else(|| human_string_path(result, &["checks", "store", "reason"])) + .or_else(|| human_string_path(result, &["checks", "account", "reason"])) +} + +fn human_actions(envelope: &OutputEnvelope) -> Vec<String> { + let mut actions = envelope + .result + .get("actions") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(Value::as_str) + .map(str::to_owned) + .collect::<Vec<_>>(); + if actions.is_empty() { + actions = envelope + .next_actions + .iter() + .map(|action| action.command.clone()) + .collect(); + } + actions.into_iter().fold(Vec::new(), |mut unique, action| { + if !unique.contains(&action) { + unique.push(action); + } + unique + }) +} + +fn human_string_path<'a>(value: &'a Value, path: &[&str]) -> Option<&'a str> { + let mut current = value; + for segment in path { + current = current.get(*segment)?; + } + current.as_str().filter(|value| !value.trim().is_empty()) +} + fn human_envelope_status(envelope: &OutputEnvelope) -> &str { if !envelope.errors.is_empty() { return "error"; diff --git a/src/operation_adapter.rs b/src/operation_adapter.rs @@ -237,6 +237,11 @@ impl<P: OperationResultPayload> OperationResult<P> { ) -> Result<OutputEnvelope, OperationAdapterError> { let result = serde_json::to_value(&self.payload) .map_err(|error| OperationAdapterError::Serialization(error.to_string()))?; + let next_actions = if self.next_actions.is_empty() { + next_actions_from_result(&result) + } else { + self.next_actions.clone() + }; Ok(OutputEnvelope { schema_version: OUTPUT_SCHEMA_VERSION, operation_id: self.operation_id().to_owned(), @@ -249,11 +254,74 @@ impl<P: OperationResultPayload> OperationResult<P> { result, warnings: self.warnings.clone(), errors: Vec::new(), - next_actions: self.next_actions.clone(), + next_actions, }) } } +fn next_actions_from_result(result: &Value) -> Vec<NextAction> { + result + .get("actions") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(Value::as_str) + .filter_map(next_action_from_action_string) + .fold(Vec::<NextAction>::new(), |mut actions, action| { + if !actions + .iter() + .any(|existing| existing.command == action.command) + { + actions.push(action); + } + actions + }) +} + +fn next_action_from_action_string(action: &str) -> Option<NextAction> { + let command = action.trim().strip_prefix("run ").unwrap_or(action).trim(); + if !command.starts_with("radroots ") { + return None; + } + Some(NextAction { + label: next_action_label(command), + command: command.to_owned(), + }) +} + +fn next_action_label(command: &str) -> String { + let parts = command.split_whitespace().collect::<Vec<_>>(); + let mut index = usize::from(parts.first().is_some_and(|part| *part == "radroots")); + let mut labels = Vec::new(); + while index < parts.len() { + let part = parts[index]; + if part.starts_with("--") { + index += 1; + if matches!( + part, + "--format" + | "--account-id" + | "--relay" + | "--publish-mode" + | "--idempotency-key" + | "--correlation-id" + | "--approval-token" + ) && index < parts.len() + { + index += 1; + } + continue; + } + labels.push(part); + index += 1; + } + if labels.is_empty() { + "radroots".to_owned() + } else { + labels.join(" ") + } +} + pub trait OperationService<P: OperationRequestPayload> { type Result: OperationResultPayload; diff --git a/src/operation_core.rs b/src/operation_core.rs @@ -105,6 +105,7 @@ impl OperationService<HealthStatusGetRequest> for CoreOperationService<'_> { let account = map_runtime(resolve_account_resolution(self.config))?; let publish = publish_runtime_view(self.config, true, &account); let state = health_status_state(&store.state, &publish); + let actions = health_actions(self.config, store.state.as_str(), &account, &publish); json_operation_result::<HealthStatusGetResult>(json!({ "state": state, "store": store, @@ -114,6 +115,7 @@ impl OperationService<HealthStatusGetRequest> for CoreOperationService<'_> { "initialized": self.logging.initialized, "current_file": self.logging.current_file.as_ref().map(|path| path.display().to_string()), }, + "actions": actions, })) } } @@ -134,6 +136,7 @@ impl OperationService<HealthCheckRunRequest> for CoreOperationService<'_> { }; let publish = publish_runtime_view(self.config, true, &account); let state = health_check_state(&store.state, account.resolved_account.is_some(), &publish); + let actions = health_actions(self.config, store.state.as_str(), &account, &publish); json_operation_result::<HealthCheckRunResult>(json!({ "state": state, "checks": { @@ -156,6 +159,7 @@ impl OperationService<HealthCheckRunRequest> for CoreOperationService<'_> { "reason": publish.reason, }, }, + "actions": actions, })) } } @@ -167,9 +171,13 @@ 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); let account = map_runtime(resolve_account_resolution(self.config))?; - json_operation_result::<ConfigGetResult>(json!({ + let publish = publish_runtime_view(self.config, true, &account); + let write_plane = + crate::runtime::provider::resolve_write_plane_provider(self.config, &publish); + let bridge_auth_configured = write_plane.bridge_auth_configured; + let actions = config_actions(self.config, &account, &publish); + let mut result = json!({ "output": { "format": self.config.output.format.as_str(), "verbosity": self.config.output.verbosity.as_str(), @@ -196,7 +204,7 @@ impl OperationService<ConfigGetRequest> for CoreOperationService<'_> { "signer": { "mode": self.config.signer.backend.as_str(), }, - "publish": publish_runtime_view(self.config, true, &account), + "publish": publish, "relay": { "count": self.config.relay.urls.len(), "urls": self.config.relay.urls, @@ -210,10 +218,6 @@ impl OperationService<ConfigGetRequest> for CoreOperationService<'_> { "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, @@ -223,7 +227,6 @@ impl OperationService<ConfigGetRequest> for CoreOperationService<'_> { "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(), @@ -231,7 +234,16 @@ impl OperationService<ConfigGetRequest> for CoreOperationService<'_> { "backups_dir": self.config.local.backups_dir.display().to_string(), "exports_dir": self.config.local.exports_dir.display().to_string(), }, - })) + "actions": actions, + }); + if matches!(self.config.publish.mode, PublishMode::Radrootsd) { + result["rpc"] = json!({ + "url": self.config.rpc.url, + "bridge_auth_configured": self.config.rpc.bridge_bearer_token.is_some(), + }); + result["write_plane"]["bridge_auth_configured"] = json!(bridge_auth_configured); + } + json_operation_result::<ConfigGetResult>(result) } } @@ -867,6 +879,89 @@ fn publish_runtime_ready(publish: &PublishRuntimeView) -> bool { !publish.signed_write_required || publish.executable } +fn health_actions( + config: &RuntimeConfig, + store_state: &str, + account: &AccountResolution, + publish: &PublishRuntimeView, +) -> Vec<String> { + let mut actions = Vec::new(); + if store_state != "ready" { + push_unique(&mut actions, "radroots store init"); + } + if let Some(resolved) = account.resolved_account.as_ref() { + if !resolved.write_capable { + push_unique(&mut actions, "radroots account attach-secret"); + } + } else { + push_unique(&mut actions, "radroots account create"); + } + for action in publish_recovery_actions(config, account, publish) { + push_unique(&mut actions, action); + } + actions +} + +fn config_actions( + config: &RuntimeConfig, + account: &AccountResolution, + publish: &PublishRuntimeView, +) -> Vec<String> { + publish_recovery_actions(config, account, publish) +} + +fn publish_recovery_actions( + config: &RuntimeConfig, + account: &AccountResolution, + publish: &PublishRuntimeView, +) -> Vec<String> { + if publish.state == "ready" { + return Vec::new(); + } + + let mut actions = Vec::new(); + match config.publish.mode { + PublishMode::NostrRelay => { + if config.relay.urls.is_empty() { + push_unique( + &mut actions, + "radroots --relay wss://relay.example.com sync pull", + ); + } + if publish.signed_write_required { + if matches!(config.signer.backend, SignerBackend::Myc) { + push_unique(&mut actions, "radroots signer status get"); + } else if let Some(resolved) = account.resolved_account.as_ref() { + if !resolved.write_capable { + push_unique(&mut actions, "radroots account attach-secret"); + } + } else { + push_unique(&mut actions, "radroots account create"); + } + } + } + PublishMode::Radrootsd => { + if config.rpc.bridge_bearer_token.is_none() { + push_unique(&mut actions, "configure RADROOTS_RPC_BEARER_TOKEN"); + } + if !radrootsd_signer_session_binding_configured(config) { + push_unique( + &mut actions, + "configure signer.remote_nip46 signer_session_ref", + ); + } + } + } + actions +} + +fn push_unique(actions: &mut Vec<String>, action: impl Into<String>) { + let action = action.into(); + if !actions.contains(&action) { + actions.push(action); + } +} + fn required_string<P>( request: &OperationRequest<P>, key: &str, diff --git a/src/runtime/provider.rs b/src/runtime/provider.rs @@ -1,14 +1,15 @@ -#[cfg(not(test))] -use crate::runtime::config::RuntimeConfig; +use crate::domain::runtime::PublishRuntimeView; #[cfg(test)] use crate::runtime::config::{ CapabilityBindingInspection, CapabilityBindingInspectionState, INFERENCE_HYF_STDIO_CAPABILITY, - RuntimeConfig, }; +use crate::runtime::config::{PublishMode, RuntimeConfig}; #[cfg(test)] 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)] +const WRITE_PLANE_TARGET_DETAIL: &str = + "write-plane targets are resolved by mode-specific publish commands"; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ProviderProvenance { @@ -20,6 +21,8 @@ pub enum ProviderProvenance { DirectConfig, #[cfg(test)] Disabled, + PublishMode, + #[cfg(test)] Unavailable, } @@ -34,6 +37,8 @@ impl ProviderProvenance { Self::DirectConfig => "direct_config", #[cfg(test)] Self::Disabled => "disabled", + Self::PublishMode => "publish_mode", + #[cfg(test)] Self::Unavailable => "unavailable", } } @@ -88,9 +93,36 @@ pub struct HyfProviderView { pub deterministic_available: Option<bool>, } -pub fn resolve_write_plane_provider(config: &RuntimeConfig) -> WritePlaneProviderView { - let _ = config; - unavailable_write_plane_view() +pub fn resolve_write_plane_provider( + config: &RuntimeConfig, + publish: &PublishRuntimeView, +) -> WritePlaneProviderView { + let (provider_runtime_id, binding_model, detail, bridge_auth_configured) = + match config.publish.mode { + PublishMode::NostrRelay => ( + "nostr_relay", + "direct_relay_publish", + "direct relay publish is selected; readiness is reported under publish", + false, + ), + PublishMode::Radrootsd => ( + "radrootsd", + "radrootsd_bridge_publish", + "radrootsd bridge publish is selected; readiness is reported under publish", + config.rpc.bridge_bearer_token.is_some(), + ), + }; + WritePlaneProviderView { + provider_runtime_id: provider_runtime_id.to_owned(), + binding_model: binding_model.to_owned(), + state: publish.state.clone(), + provenance: ProviderProvenance::PublishMode.as_str().to_owned(), + source: publish.source.clone(), + target_kind: None, + target: None, + detail: publish.reason.clone().unwrap_or_else(|| detail.to_owned()), + bridge_auth_configured, + } } #[cfg(test)] @@ -98,7 +130,7 @@ pub fn resolve_actor_write_plane_target( config: &RuntimeConfig, ) -> Result<ResolvedWritePlaneTarget, String> { let _ = config; - Err(WRITE_PLANE_UNAVAILABLE_DETAIL.to_owned()) + Err(WRITE_PLANE_TARGET_DETAIL.to_owned()) } #[cfg(test)] @@ -155,20 +187,6 @@ pub fn resolve_capability_providers(config: &RuntimeConfig) -> Vec<ResolvedProvi }] } -fn unavailable_write_plane_view() -> WritePlaneProviderView { - WritePlaneProviderView { - provider_runtime_id: "nostr_relay".to_owned(), - binding_model: "direct_relay_publish".to_owned(), - state: "unavailable".to_owned(), - provenance: ProviderProvenance::Unavailable.as_str().to_owned(), - source: "legacy write-plane provider is not active".to_owned(), - target_kind: None, - target: None, - detail: WRITE_PLANE_UNAVAILABLE_DETAIL.to_owned(), - bridge_auth_configured: false, - } -} - #[cfg(test)] fn inspect_binding(config: &RuntimeConfig, capability_id: &str) -> CapabilityBindingInspection { config @@ -246,6 +264,9 @@ mod tests { ProviderProvenance, resolve_actor_write_plane_target, resolve_capability_providers, resolve_hyf_provider, resolve_write_plane_provider, }; + use crate::domain::runtime::{ + PublishProviderRuntimeView, PublishRelayRuntimeView, PublishRuntimeView, + }; use crate::runtime::config::{ AccountConfig, AccountSecretContractConfig, CapabilityBindingConfig, CapabilityBindingSource, CapabilityBindingTargetKind, HyfConfig, IdentityConfig, @@ -349,15 +370,49 @@ mod tests { } } + fn publish_view( + config: &RuntimeConfig, + state: &str, + reason: Option<&str>, + ) -> PublishRuntimeView { + PublishRuntimeView { + mode: config.publish.mode.as_str().to_owned(), + source: config.publish.source.as_str().to_owned(), + transport_family: config.publish.mode.transport_family().to_owned(), + state: state.to_owned(), + executable: state == "ready", + reason: reason.map(str::to_owned), + signed_write_required: true, + relay: PublishRelayRuntimeView { + ready: !config.relay.urls.is_empty(), + count: config.relay.urls.len(), + source: config.relay.source.as_str().to_owned(), + }, + provider: PublishProviderRuntimeView { + provider_runtime_id: config.publish.mode.as_str().to_owned(), + state: state.to_owned(), + source: config.publish.source.as_str().to_owned(), + reason: reason.map(str::to_owned), + }, + } + } + #[test] - fn write_plane_provider_is_not_active_for_direct_relay_publish() { - let view = resolve_write_plane_provider(&sample_config(Vec::new(), false)); + fn write_plane_provider_tracks_direct_relay_publish() { + let config = sample_config(Vec::new(), false); + let publish = publish_view( + &config, + "unconfigured", + Some("nostr_relay publish mode requires a configured relay"), + ); + let view = resolve_write_plane_provider(&config, &publish); assert_eq!(view.provider_runtime_id, "nostr_relay"); assert_eq!(view.binding_model, "direct_relay_publish"); - assert_eq!(view.state, "unavailable"); - assert_eq!(view.provenance, ProviderProvenance::Unavailable.as_str()); + assert_eq!(view.state, "unconfigured"); + assert_eq!(view.provenance, ProviderProvenance::PublishMode.as_str()); assert!(view.target.is_none()); - assert!(view.detail.contains("seller publish commands")); + assert!(view.detail.contains("configured relay")); + assert!(!view.bridge_auth_configured); } #[test] @@ -366,7 +421,7 @@ mod tests { .expect_err("write plane target"); assert_eq!( error, - "legacy write-plane provider is unavailable; use seller publish commands with configured direct relays" + "write-plane targets are resolved by mode-specific publish commands" ); } diff --git a/src/target_cli.rs b/src/target_cli.rs @@ -28,7 +28,12 @@ impl TargetPublishMode { } #[derive(Debug, Parser, Clone)] -#[command(name = "radroots", disable_help_subcommand = true)] +#[command( + name = "radroots", + about = "Operate Radroots local-first trade workflows.", + long_about = "Operate Radroots local-first trade workflows.\n\nPublish modes:\n nostr_relay uses direct relay publish with local signer custody.\n radrootsd uses daemon-backed publish for supported farm and listing publish flows.\n\nRelay mode never silently falls back to radrootsd.", + disable_help_subcommand = true +)] pub struct TargetCliArgs { #[arg(long = "format", global = true, value_enum, default_value = "human")] pub format: TargetOutputFormat, @@ -36,7 +41,12 @@ pub struct TargetCliArgs { pub account_id: Option<String>, #[arg(long = "relay", global = true)] pub relay: Vec<String>, - #[arg(long = "publish-mode", global = true, value_enum)] + #[arg( + long = "publish-mode", + global = true, + value_enum, + help = "Select nostr_relay direct relay publish or radrootsd daemon-backed publish" + )] pub publish_mode: Option<TargetPublishMode>, #[arg(long = "offline", global = true, action = ArgAction::SetTrue, conflicts_with = "online")] pub offline: bool, @@ -66,18 +76,31 @@ pub struct TargetCliArgs { #[derive(Debug, Clone, Subcommand)] pub enum TargetCommand { + #[command(about = "Inspect and initialize workspace state.")] Workspace(WorkspaceArgs), + #[command(about = "Inspect local readiness and mode-specific recovery steps.")] Health(HealthArgs), + #[command(about = "Show effective configuration and publish-plane readiness.")] Config(ConfigArgs), + #[command(about = "Manage local signer accounts and custody.")] Account(AccountArgs), + #[command(about = "Inspect signer readiness for local relay writes.")] Signer(SignerArgs), + #[command(about = "List configured relay targets for direct relay mode.")] Relay(RelayArgs), + #[command(about = "Initialize and inspect the local replica store.")] Store(StoreArgs), + #[command(about = "Read from relay events into the local replica.")] Sync(SyncArgs), + #[command(about = "Create, inspect, and publish farm profile data.")] Farm(FarmArgs), + #[command(about = "Create, inspect, and publish listing data.")] Listing(ListingArgs), + #[command(about = "Refresh and query market data from the local replica.")] Market(MarketArgs), + #[command(about = "Prepare baskets and quotes before order coordination.")] Basket(BasketArgs), + #[command(about = "Coordinate order lifecycle events without payments.")] Order(OrderArgs), } diff --git a/tests/target_cli.rs b/tests/target_cli.rs @@ -246,6 +246,23 @@ fn root_help_exposes_only_target_namespaces() { } } +#[test] +fn root_help_explains_publish_modes() { + let output = radroots().arg("--help").output().expect("run root help"); + + assert!(output.status.success()); + let stdout = String::from_utf8(output.stdout).expect("utf8 stdout"); + + assert!(stdout.contains("nostr_relay uses direct relay publish")); + assert!(stdout.contains("radrootsd uses daemon-backed publish")); + assert!(stdout.contains("Relay mode never silently falls back")); + assert!(stdout.contains("Inspect local readiness and mode-specific recovery steps")); + assert!( + stdout + .contains("Select nostr_relay direct relay publish or radrootsd daemon-backed publish") + ); +} + fn help_lists(stdout: &str, command: &str) -> bool { stdout.lines().any(|line| { let line = line.trim_start(); @@ -307,7 +324,24 @@ fn config_get_exposes_resolved_publish_state() { value["result"]["publish"]["provider"]["provider_runtime_id"], "radrootsd" ); - assert_eq!(value["result"]["write_plane"]["state"], "unavailable"); + assert_eq!( + value["result"]["write_plane"]["provider_runtime_id"], + "radrootsd" + ); + assert_eq!( + value["result"]["write_plane"]["binding_model"], + "radrootsd_bridge_publish" + ); + assert_eq!(value["result"]["write_plane"]["state"], "unconfigured"); + assert_eq!( + value["result"]["write_plane"]["bridge_auth_configured"], + false + ); + assert_eq!(value["result"]["rpc"]["bridge_auth_configured"], false); + assert_eq!( + value["result"]["actions"][0], + "configure RADROOTS_RPC_BEARER_TOKEN" + ); } #[test] @@ -374,6 +408,36 @@ fn config_get_distinguishes_relay_ready_from_missing_signed_write_account() { value["result"]["publish"]["provider"]["state"], "unconfigured" ); + assert_eq!( + value["result"]["write_plane"]["provider_runtime_id"], + "nostr_relay" + ); + assert_eq!( + value["result"]["write_plane"]["binding_model"], + "direct_relay_publish" + ); + assert_eq!(value["result"]["write_plane"]["state"], "unconfigured"); + assert_eq!(value["result"]["rpc"], Value::Null); + assert_contains( + &value["result"]["write_plane"]["detail"], + "write-capable local account", + ); + assert_eq!(value["result"]["actions"][0], "radroots account create"); + assert_eq!( + value["next_actions"][0]["command"], + "radroots account create" + ); + assert_no_daemon_runtime_reference( + &value, + &[ + "--format", + "json", + "--relay", + "ws://127.0.0.1:19001", + "config", + "get", + ], + ); } #[test] @@ -479,6 +543,12 @@ fn health_surfaces_publish_state_under_deferred_signer_mode() { "unconfigured" ); assert_contains(&value["result"]["publish"]["reason"], "bridge bearer token"); + assert_eq!(value["result"]["actions"][0], "radroots store init"); + assert_eq!(value["result"]["actions"][1], "radroots account create"); + assert_eq!( + value["result"]["actions"][2], + "configure RADROOTS_RPC_BEARER_TOKEN" + ); assert_eq!(value["errors"].as_array().expect("errors").len(), 0); } @@ -506,6 +576,13 @@ fn health_status_distinguishes_relay_ready_from_missing_signed_write_account() { &value["result"]["publish"]["reason"], "write-capable local account", ); + assert_eq!(value["result"]["actions"][0], "radroots store init"); + assert_eq!(value["result"]["actions"][1], "radroots account create"); + assert_eq!(value["next_actions"][0]["command"], "radroots store init"); + assert_eq!( + value["next_actions"][1]["command"], + "radroots account create" + ); } #[test] @@ -523,6 +600,12 @@ fn health_check_exposes_publish_readiness() { "unconfigured" ); assert_eq!(value["result"]["checks"]["publish"]["executable"], false); + assert_eq!(value["result"]["actions"][0], "radroots store init"); + assert_eq!(value["result"]["actions"][1], "radroots account create"); + assert_eq!( + value["result"]["actions"][2], + "configure RADROOTS_RPC_BEARER_TOKEN" + ); assert_eq!(value["errors"].as_array().expect("errors").len(), 0); } @@ -547,6 +630,13 @@ fn health_check_marks_relay_publish_ready_with_secret_backed_local_account() { 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["result"]["actions"] + .as_array() + .expect("actions") + .len(), + 0 + ); assert_eq!(value["errors"].as_array().expect("errors").len(), 0); } @@ -1784,6 +1874,31 @@ fn target_command_outputs_standard_json_envelope() { } #[test] +fn next_actions_mirror_result_actions_for_json_and_ndjson() { + let sandbox = RadrootsCliSandbox::new(); + + let value = sandbox.json_success(&["--format", "json", "market", "refresh"]); + + assert_eq!(value["result"]["actions"][0], "radroots store init"); + assert_eq!(value["next_actions"][0]["label"], "store init"); + assert_eq!(value["next_actions"][0]["command"], "radroots store init"); + + let output = sandbox + .command() + .args(["--format", "ndjson", "market", "refresh"]) + .output() + .expect("run market refresh ndjson"); + let frames = ndjson_from_stdout(&output); + let terminal = frames.last().expect("terminal ndjson frame"); + + assert!(output.status.success()); + assert_eq!( + terminal["payload"]["next_actions"][0]["command"], + "radroots store init" + ); +} + +#[test] fn default_human_output_is_concise_and_not_json() { let output = radroots() .args(["workspace", "get"]) @@ -1799,6 +1914,47 @@ fn default_human_output_is_concise_and_not_json() { } #[test] +fn human_health_status_surfaces_publish_reason_and_actions() { + let sandbox = RadrootsCliSandbox::new(); + + let output = sandbox + .command() + .args(["--relay", "ws://127.0.0.1:19007", "health", "status", "get"]) + .output() + .expect("run human health status"); + + assert!(output.status.success()); + let stdout = String::from_utf8(output.stdout).expect("utf8 stdout"); + + assert!(stdout.starts_with("health.status.get: needs_attention\n")); + assert!(stdout.contains("publish_mode: nostr_relay")); + assert!(stdout.contains("publish_state: unconfigured")); + assert!(stdout.contains("reason: nostr_relay publish mode requires a selected or default write-capable local account")); + assert!(stdout.contains("- radroots store init")); + assert!(stdout.contains("- radroots account create")); + assert!(serde_json::from_str::<Value>(&stdout).is_err()); +} + +#[test] +fn human_market_refresh_missing_store_shows_action() { + let sandbox = RadrootsCliSandbox::new(); + + let output = sandbox + .command() + .args(["market", "refresh"]) + .output() + .expect("run human market refresh"); + + assert!(output.status.success()); + let stdout = String::from_utf8(output.stdout).expect("utf8 stdout"); + + assert!(stdout.starts_with("market.refresh: unconfigured\n")); + assert!(stdout.contains("reason: local replica database is not initialized")); + assert!(stdout.contains("- radroots store init")); + assert!(serde_json::from_str::<Value>(&stdout).is_err()); +} + +#[test] fn human_failure_output_preserves_error_code_and_message() { let output = radroots() .args(["--format", "human", "order", "submit"])