commit 0dd47689eb437531474926b4d8fa50f19c35c61a
parent 328ab327704429e3b488aa73f9b6b37f643d90a0
Author: triesap <tyson@radroots.org>
Date: Sat, 9 May 2026 15:37:27 +0000
cli: add operator config next actions
- extend next_actions with typed cli and operator configuration variants
- mirror radrootsd token and signer binding remediation in json and ndjson
- enable ndjson frames for config and health readiness commands
- update focused config health and output contract coverage
Diffstat:
5 files changed, 242 insertions(+), 23 deletions(-)
diff --git a/src/main.rs b/src/main.rs
@@ -629,7 +629,13 @@ fn human_actions(envelope: &OutputEnvelope) -> Vec<String> {
actions = envelope
.next_actions
.iter()
- .map(|action| action.command.clone())
+ .map(|action| {
+ action
+ .command
+ .clone()
+ .or_else(|| action.description.clone())
+ .unwrap_or_else(|| action.label.clone())
+ })
.collect();
}
actions.into_iter().fold(Vec::new(), |mut unique, action| {
diff --git a/src/operation_order.rs b/src/operation_order.rs
@@ -1479,8 +1479,14 @@ mod tests {
assert_eq!(detail["order_id"], "ord_missing");
assert_eq!(detail["actions"][0], "radroots order list");
assert_eq!(detail["actions"][1], "radroots basket create");
- assert_eq!(envelope.next_actions[0].command, "radroots order list");
- assert_eq!(envelope.next_actions[1].command, "radroots basket create");
+ assert_eq!(
+ envelope.next_actions[0].command.as_deref(),
+ Some("radroots order list")
+ );
+ assert_eq!(
+ envelope.next_actions[1].command.as_deref(),
+ Some("radroots basket create")
+ );
}
#[test]
@@ -1836,8 +1842,8 @@ mod tests {
OperationContext::default().envelope_context("req_order_status"),
);
assert_eq!(
- envelope.next_actions[0].command,
- "radroots --relay wss://relay.example.com order status get ord_pending"
+ envelope.next_actions[0].command.as_deref(),
+ Some("radroots --relay wss://relay.example.com order status get ord_pending")
);
}
@@ -1867,8 +1873,8 @@ mod tests {
"radroots --relay wss://relay.example.com order event list"
);
assert_eq!(
- envelope.next_actions[0].command,
- "radroots --relay wss://relay.example.com order event list"
+ envelope.next_actions[0].command.as_deref(),
+ Some("radroots --relay wss://relay.example.com order event list")
);
}
@@ -1901,7 +1907,10 @@ mod tests {
envelope.errors[0].detail.as_ref().unwrap()["actions"][0],
"radroots account create"
);
- assert_eq!(envelope.next_actions[0].command, "radroots account create");
+ assert_eq!(
+ envelope.next_actions[0].command.as_deref(),
+ Some("radroots account create")
+ );
}
#[test]
@@ -1935,8 +1944,8 @@ mod tests {
"ord_missing"
);
assert_eq!(
- envelope.next_actions[0].command,
- "radroots order status get ord_missing"
+ envelope.next_actions[0].command.as_deref(),
+ Some("radroots order status get ord_missing")
);
}
diff --git a/src/operation_registry.rs b/src/operation_registry.rs
@@ -124,7 +124,7 @@ pub const OPERATION_REGISTRY: &[OperationSpec] = &[
false,
None,
Low,
- false,
+ true,
false
),
operation!(
@@ -139,7 +139,7 @@ pub const OPERATION_REGISTRY: &[OperationSpec] = &[
false,
None,
Low,
- false,
+ true,
false
),
operation!(
@@ -154,7 +154,7 @@ pub const OPERATION_REGISTRY: &[OperationSpec] = &[
false,
None,
Low,
- false,
+ true,
false
),
operation!(
@@ -1435,6 +1435,9 @@ mod tests {
.map(|operation| operation.operation_id)
.collect::<BTreeSet<_>>();
let expected = [
+ "health.status.get",
+ "health.check.run",
+ "config.get",
"account.list",
"relay.list",
"sync.pull",
diff --git a/src/output_contract.rs b/src/output_contract.rs
@@ -153,10 +153,7 @@ fn next_actions_from_actions_value(actions_value: Option<&Value>) -> Vec<NextAct
.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)
- {
+ if !actions.contains(&action) {
actions.push(action);
}
actions
@@ -164,13 +161,38 @@ fn next_actions_from_actions_value(actions_value: Option<&Value>) -> Vec<NextAct
}
fn next_action_from_action_string(action: &str) -> Option<NextAction> {
+ let action = action.trim();
+ if action == "configure RADROOTS_RPC_BEARER_TOKEN" {
+ return Some(NextAction {
+ kind: NextActionKind::OperatorConfig,
+ label: "configure rpc bearer token".to_owned(),
+ command: None,
+ description: Some(action.to_owned()),
+ env_var: Some("RADROOTS_RPC_BEARER_TOKEN".to_owned()),
+ config_key: None,
+ });
+ }
+ if action == "configure signer.remote_nip46 signer_session_ref" {
+ return Some(NextAction {
+ kind: NextActionKind::OperatorConfig,
+ label: "configure signer session binding".to_owned(),
+ command: None,
+ description: Some(action.to_owned()),
+ env_var: None,
+ config_key: Some("signer.remote_nip46.signer_session_ref".to_owned()),
+ });
+ }
let command = action.trim().strip_prefix("run ").unwrap_or(action).trim();
if !command.starts_with("radroots ") {
return None;
}
Some(NextAction {
+ kind: NextActionKind::CliCommand,
label: next_action_label(command),
- command: command.to_owned(),
+ command: Some(command.to_owned()),
+ description: None,
+ env_var: None,
+ config_key: None,
})
}
@@ -273,8 +295,23 @@ impl CliExitCode {
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct NextAction {
+ pub kind: NextActionKind,
pub label: String,
- pub command: String,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub command: Option<String>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub description: Option<String>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub env_var: Option<String>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub config_key: Option<String>,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
+#[serde(rename_all = "snake_case")]
+pub enum NextActionKind {
+ CliCommand,
+ OperatorConfig,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
@@ -330,8 +367,8 @@ mod tests {
use serde_json::{Value, json};
use super::{
- CliExitCode, EnvelopeContext, NdjsonFrame, NdjsonFrameType, OUTPUT_SCHEMA_VERSION,
- OutputEnvelope, OutputError,
+ CliExitCode, EnvelopeContext, NdjsonFrame, NdjsonFrameType, NextActionKind,
+ OUTPUT_SCHEMA_VERSION, OutputEnvelope, OutputError,
};
#[test]
@@ -400,10 +437,69 @@ mod tests {
);
assert_eq!(envelope.next_actions.len(), 2);
+ assert_eq!(envelope.next_actions[0].kind, NextActionKind::CliCommand);
assert_eq!(envelope.next_actions[0].label, "order list");
- assert_eq!(envelope.next_actions[0].command, "radroots order list");
+ assert_eq!(
+ envelope.next_actions[0].command.as_deref(),
+ Some("radroots order list")
+ );
+ assert_eq!(envelope.next_actions[1].kind, NextActionKind::CliCommand);
assert_eq!(envelope.next_actions[1].label, "basket create");
- assert_eq!(envelope.next_actions[1].command, "radroots basket create");
+ assert_eq!(
+ envelope.next_actions[1].command.as_deref(),
+ Some("radroots basket create")
+ );
+ }
+
+ #[test]
+ fn failure_envelope_derives_operator_config_next_actions() {
+ let mut error = OutputError::new(
+ "operation_unavailable",
+ "publish mode needs operator configuration",
+ CliExitCode::RuntimeUnavailable,
+ );
+ error.detail = Some(json!({
+ "actions": [
+ "configure RADROOTS_RPC_BEARER_TOKEN",
+ "configure signer.remote_nip46 signer_session_ref",
+ "configure RADROOTS_RPC_BEARER_TOKEN"
+ ]
+ }));
+ let envelope = OutputEnvelope::failure(
+ "config.get",
+ error,
+ EnvelopeContext::new("req_config", false),
+ );
+ let value = serde_json::to_value(&envelope).expect("serialize envelope");
+
+ assert_eq!(envelope.next_actions.len(), 2);
+ assert_eq!(
+ envelope.next_actions[0].kind,
+ NextActionKind::OperatorConfig
+ );
+ assert_eq!(envelope.next_actions[0].label, "configure rpc bearer token");
+ assert_eq!(envelope.next_actions[0].command, None);
+ assert_eq!(
+ envelope.next_actions[0].env_var.as_deref(),
+ Some("RADROOTS_RPC_BEARER_TOKEN")
+ );
+ assert_eq!(
+ envelope.next_actions[1].kind,
+ NextActionKind::OperatorConfig
+ );
+ assert_eq!(
+ envelope.next_actions[1].label,
+ "configure signer session binding"
+ );
+ assert_eq!(envelope.next_actions[1].command, None);
+ assert_eq!(
+ envelope.next_actions[1].config_key.as_deref(),
+ Some("signer.remote_nip46.signer_session_ref")
+ );
+ assert_eq!(value["next_actions"][0]["kind"], "operator_config");
+ assert_eq!(value["next_actions"][0]["command"], Value::Null);
+ assert_eq!(value["next_actions"][1]["kind"], "operator_config");
+ assert_eq!(value["next_actions"][1]["command"], Value::Null);
}
#[test]
diff --git a/tests/target_cli.rs b/tests/target_cli.rs
@@ -407,6 +407,37 @@ fn assert_public_signer_session_binding_message(value: &Value) {
);
}
+fn assert_rpc_bearer_token_next_action(actions: &Value) {
+ let action = actions
+ .as_array()
+ .expect("next actions")
+ .iter()
+ .find(|action| action["env_var"] == "RADROOTS_RPC_BEARER_TOKEN")
+ .expect("rpc bearer token next action");
+
+ assert_eq!(action["kind"], "operator_config");
+ assert_eq!(action["label"], "configure rpc bearer token");
+ assert_eq!(action["command"], Value::Null);
+ assert_eq!(action["description"], "configure RADROOTS_RPC_BEARER_TOKEN");
+}
+
+fn assert_signer_session_next_action(actions: &Value) {
+ let action = actions
+ .as_array()
+ .expect("next actions")
+ .iter()
+ .find(|action| action["config_key"] == "signer.remote_nip46.signer_session_ref")
+ .expect("signer session next action");
+
+ assert_eq!(action["kind"], "operator_config");
+ assert_eq!(action["label"], "configure signer session binding");
+ assert_eq!(action["command"], Value::Null);
+ assert_eq!(
+ action["description"],
+ "configure signer.remote_nip46 signer_session_ref"
+ );
+}
+
#[test]
fn removed_global_flags_are_rejected_publicly() {
for args in [
@@ -467,6 +498,42 @@ fn config_get_exposes_resolved_publish_state() {
value["result"]["actions"][0],
"configure RADROOTS_RPC_BEARER_TOKEN"
);
+ assert_eq!(
+ value["result"]["actions"][1],
+ "configure signer.remote_nip46 signer_session_ref"
+ );
+ assert_rpc_bearer_token_next_action(&value["next_actions"]);
+ assert_signer_session_next_action(&value["next_actions"]);
+}
+
+#[test]
+fn config_get_radrootsd_missing_signer_binding_mirrors_operator_next_action() {
+ let sandbox = RadrootsCliSandbox::new();
+ sandbox.write_app_config("[publish]\nmode = \"radrootsd\"\n");
+
+ let mut command = sandbox.command();
+ command
+ .env("RADROOTS_RPC_BEARER_TOKEN", "bridge_test")
+ .args(["--format", "json", "config", "get"]);
+ let output = command.output().expect("run config get");
+ let value: Value = serde_json::from_slice(&output.stdout).expect("json output");
+
+ assert!(output.status.success());
+ assert_eq!(value["operation_id"], "config.get");
+ assert_eq!(value["result"]["publish"]["mode"], "radrootsd");
+ assert_eq!(value["result"]["publish"]["state"], "unconfigured");
+ assert_eq!(
+ value["result"]["actions"][0],
+ "configure signer.remote_nip46 signer_session_ref"
+ );
+ assert_eq!(
+ value["next_actions"]
+ .as_array()
+ .expect("next actions")
+ .len(),
+ 1
+ );
+ assert_signer_session_next_action(&value["next_actions"]);
}
#[test]
@@ -674,6 +741,17 @@ fn health_surfaces_publish_state_under_deferred_signer_mode() {
value["result"]["actions"][2],
"configure RADROOTS_RPC_BEARER_TOKEN"
);
+ assert_eq!(
+ value["result"]["actions"][3],
+ "configure signer.remote_nip46 signer_session_ref"
+ );
+ assert_eq!(value["next_actions"][0]["command"], "radroots store init");
+ assert_eq!(
+ value["next_actions"][1]["command"],
+ "radroots account create"
+ );
+ assert_rpc_bearer_token_next_action(&value["next_actions"]);
+ assert_signer_session_next_action(&value["next_actions"]);
assert_eq!(value["errors"].as_array().expect("errors").len(), 0);
}
@@ -731,6 +809,17 @@ fn health_check_exposes_publish_readiness() {
value["result"]["actions"][2],
"configure RADROOTS_RPC_BEARER_TOKEN"
);
+ assert_eq!(
+ value["result"]["actions"][3],
+ "configure signer.remote_nip46 signer_session_ref"
+ );
+ assert_eq!(value["next_actions"][0]["command"], "radroots store init");
+ assert_eq!(
+ value["next_actions"][1]["command"],
+ "radroots account create"
+ );
+ assert_rpc_bearer_token_next_action(&value["next_actions"]);
+ assert_signer_session_next_action(&value["next_actions"]);
assert_eq!(value["errors"].as_array().expect("errors").len(), 0);
}
@@ -2027,6 +2116,22 @@ fn next_actions_mirror_result_actions_for_json_and_ndjson() {
terminal["payload"]["next_actions"][0]["command"],
"radroots store init"
);
+
+ for args in [
+ &["--format", "ndjson", "config", "get"][..],
+ &["--format", "ndjson", "health", "status", "get"][..],
+ &["--format", "ndjson", "health", "check", "run"][..],
+ ] {
+ let daemon = RadrootsCliSandbox::new();
+ daemon.write_app_config("[publish]\nmode = \"radrootsd\"\n");
+ let output = daemon.command().args(args).output().expect("run ndjson");
+ let frames = ndjson_from_stdout(&output);
+ let terminal = frames.last().expect("terminal ndjson frame");
+
+ assert!(output.status.success(), "{args:?}");
+ assert_rpc_bearer_token_next_action(&terminal["payload"]["next_actions"]);
+ assert_signer_session_next_action(&terminal["payload"]["next_actions"]);
+ }
}
#[test]