cli

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

commit 982502a94010d80a5a8b119941d50813828ce607
parent 49e72b10f1f636fb2c757c3c92514c7d5feacedb
Author: triesap <tyson@radroots.org>
Date:   Sat,  9 May 2026 15:41:54 +0000

cli: add sync push mode recovery action

- attach nostr_relay recovery detail to radrootsd sync push mode failures
- expose the recovery action through json ndjson and human output
- keep recovery actions narrow to parser-valid sync push invocation
- cover mode-mismatch envelopes and human rendering in target cli tests

Diffstat:
Msrc/main.rs | 21+++++++++++++++++----
Mtests/target_cli.rs | 75+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 92 insertions(+), 4 deletions(-)

diff --git a/src/main.rs b/src/main.rs @@ -476,13 +476,18 @@ fn validate_publish_mode_contract( if matches!(config.publish.mode, PublishMode::Radrootsd) && requires_nostr_relay_publish_mode(spec.operation_id) { + let message = format!( + "`{}` cannot run with publish mode `radrootsd`; radrootsd publish transport is only implemented for farm and listing publish operations", + spec.cli_path + ); + let actions = nostr_relay_publish_mode_recovery_actions(spec.operation_id); return Err(OperationAdapterError::operation_unavailable_with_detail( spec.operation_id, - format!( - "`{}` cannot run with publish mode `radrootsd`; radrootsd publish transport is only implemented for farm and listing publish operations", - spec.cli_path - ), + message.clone(), json!({ + "state": "unavailable", + "reason": message, + "actions": actions, "publish": { "mode": config.publish.mode.as_str(), "source": config.publish.source.as_str(), @@ -500,6 +505,14 @@ fn validate_publish_mode_contract( Ok(()) } +fn nostr_relay_publish_mode_recovery_actions(operation_id: &str) -> Vec<String> { + if operation_id == "sync.push" { + vec!["radroots --publish-mode nostr_relay sync push".to_owned()] + } else { + Vec::new() + } +} + fn is_publish_mode_routed_operation(operation_id: &str) -> bool { matches!( operation_id, diff --git a/tests/target_cli.rs b/tests/target_cli.rs @@ -2863,6 +2863,81 @@ fn online_requires_relay_for_external_network_operations() { } #[test] +fn radrootsd_sync_push_failure_exposes_nostr_relay_recovery_action() { + let sandbox = RadrootsCliSandbox::new(); + let (json_output, value) = sandbox.json_output(&[ + "--format", + "json", + "--publish-mode", + "radrootsd", + "sync", + "push", + ]); + + assert_eq!(json_output.status.code(), Some(3)); + assert_eq!(value["operation_id"], "sync.push"); + assert_eq!(value["result"], Value::Null); + assert_eq!(value["errors"][0]["code"], "operation_unavailable"); + assert_eq!(value["errors"][0]["detail"]["state"], "unavailable"); + assert_eq!(value["errors"][0]["detail"]["publish"]["mode"], "radrootsd"); + assert_eq!( + value["errors"][0]["detail"]["publish"]["state"], + "unavailable" + ); + assert_eq!( + value["errors"][0]["detail"]["actions"][0], + "radroots --publish-mode nostr_relay sync push" + ); + assert_eq!(value["next_actions"][0]["kind"], "cli_command"); + assert_eq!( + value["next_actions"][0]["command"], + "radroots --publish-mode nostr_relay sync push" + ); + + let ndjson_output = sandbox + .command() + .args([ + "--format", + "ndjson", + "--publish-mode", + "radrootsd", + "sync", + "push", + ]) + .output() + .expect("run sync push ndjson"); + let frames = ndjson_from_stdout(&ndjson_output); + let terminal = frames.last().expect("terminal frame"); + + assert_eq!(ndjson_output.status.code(), Some(3)); + assert_eq!(terminal["operation_id"], "sync.push"); + assert_eq!(terminal["frame_type"], "error"); + assert_eq!( + terminal["payload"]["next_actions"][0]["command"], + "radroots --publish-mode nostr_relay sync push" + ); + + let human_output = sandbox + .command() + .args(["--publish-mode", "radrootsd", "sync", "push"]) + .output() + .expect("run sync push human"); + let stdout = String::from_utf8(human_output.stdout).expect("utf8 stdout"); + + assert_eq!(human_output.status.code(), Some(3)); + assert!(stdout.starts_with("sync.push: error\n")); + assert!(stdout.contains("error: operation_unavailable")); + assert!(stdout.contains("state: unavailable")); + assert!(stdout.contains("publish_mode: radrootsd")); + assert!(stdout.contains("publish_state: unavailable")); + assert!( + stdout.contains("reason: `radroots sync push` cannot run with publish mode `radrootsd`") + ); + assert!(stdout.contains("- radroots --publish-mode nostr_relay sync push")); + assert!(serde_json::from_str::<Value>(&stdout).is_err()); +} + +#[test] fn online_order_event_watch_returns_deferred_without_relay_preflight() { let sandbox = RadrootsCliSandbox::new(); let (output, value) = sandbox.json_output(&[