commit 28818d7077dbf920fe410e6030b458d474832b30
parent 91ee8322f6202d65dea09fcaf64e3e6aca1ea3e5
Author: triesap <tyson@radroots.org>
Date: Fri, 8 May 2026 05:39:22 +0000
output: expose mode-aware recovery
Diffstat:
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"])