cli

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

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:
Msrc/main.rs | 8+++++++-
Msrc/operation_order.rs | 27++++++++++++++++++---------
Msrc/operation_registry.rs | 9++++++---
Msrc/output_contract.rs | 116++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------
Mtests/target_cli.rs | 105+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
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]