cli

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

commit 0570a2c155ab3488a66077fa1fbbf1113a606c4c
parent 982502a94010d80a5a8b119941d50813828ce607
Author: triesap <tyson@radroots.org>
Date:   Sat,  9 May 2026 17:10:15 +0000

cli: add farm seller recovery actions

Diffstat:
Msrc/operation_adapter.rs | 58++++++++++++++++++++++++++++++++++++++++++++++++++--------
Msrc/runtime/accounts.rs | 70++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------
Msrc/runtime/farm.rs | 75++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
Msrc/runtime/listing.rs | 20++++++++++++++++----
Mtests/signer_runtime_modes.rs | 193+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
5 files changed, 387 insertions(+), 29 deletions(-)

diff --git a/src/operation_adapter.rs b/src/operation_adapter.rs @@ -803,20 +803,62 @@ fn account_runtime_failure( operation_id: &str, failure: &AccountRuntimeFailure, ) -> OperationAdapterError { - let message = failure.to_string(); + let message = failure.message().to_owned(); match failure { - AccountRuntimeFailure::Unresolved(_) => OperationAdapterError::AccountUnresolved { - operation_id: operation_id.to_owned(), + AccountRuntimeFailure::Unresolved(_) => account_failure_output( + operation_id, + "account_unresolved", message, - }, - AccountRuntimeFailure::WatchOnly(_) => OperationAdapterError::AccountWatchOnly { - operation_id: operation_id.to_owned(), + CliExitCode::AuthorizationFailed, + failure.detail_json(), + || OperationAdapterError::AccountUnresolved { + operation_id: operation_id.to_owned(), + message: failure.message().to_owned(), + }, + ), + AccountRuntimeFailure::WatchOnly(_) => account_failure_output( + operation_id, + "account_watch_only", message, - }, - AccountRuntimeFailure::Mismatch(_) => OperationAdapterError::AccountMismatch { + CliExitCode::SignerUnavailable, + failure.detail_json(), + || OperationAdapterError::AccountWatchOnly { + operation_id: operation_id.to_owned(), + message: failure.message().to_owned(), + }, + ), + AccountRuntimeFailure::Mismatch(_) => account_failure_output( + operation_id, + "account_mismatch", + message, + CliExitCode::AuthorizationFailed, + failure.detail_json(), + || OperationAdapterError::AccountMismatch { + operation_id: operation_id.to_owned(), + message: failure.message().to_owned(), + }, + ), + } +} + +fn account_failure_output( + operation_id: &str, + code: &str, + message: String, + exit_code: CliExitCode, + detail_json: Option<&str>, + fallback: impl FnOnce() -> OperationAdapterError, +) -> OperationAdapterError { + match detail_json { + Some(detail_json) => OperationAdapterError::DetailedFailure { operation_id: operation_id.to_owned(), + code: code.to_owned(), + class: "account".to_owned(), message, + exit_code, + detail_json: detail_json.to_owned(), }, + None => fallback(), } } diff --git a/src/runtime/accounts.rs b/src/runtime/accounts.rs @@ -24,35 +24,81 @@ pub const SHARED_ACCOUNT_STORE_SOURCE: &str = "shared account store ยท local fir #[derive(Debug, Clone, PartialEq, Eq)] pub enum AccountRuntimeFailure { - Unresolved(String), - WatchOnly(String), - Mismatch(String), + Unresolved(AccountRuntimeFailureIssue), + WatchOnly(AccountRuntimeFailureIssue), + Mismatch(AccountRuntimeFailureIssue), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AccountRuntimeFailureIssue { + message: String, + detail_json: Option<String>, +} + +impl AccountRuntimeFailureIssue { + fn new(message: impl Into<String>) -> Self { + Self { + message: message.into(), + detail_json: None, + } + } + + fn with_detail(message: impl Into<String>, detail: serde_json::Value) -> Self { + Self { + message: message.into(), + detail_json: Some(detail.to_string()), + } + } + + pub fn message(&self) -> &str { + self.message.as_str() + } } impl AccountRuntimeFailure { pub fn unresolved(message: impl Into<String>) -> Self { - Self::Unresolved(message.into()) + Self::Unresolved(AccountRuntimeFailureIssue::new(message)) + } + + pub fn unresolved_with_detail(message: impl Into<String>, detail: serde_json::Value) -> Self { + Self::Unresolved(AccountRuntimeFailureIssue::with_detail(message, detail)) } pub fn watch_only(account_id: &radroots_identity::RadrootsIdentityId) -> Self { - Self::WatchOnly(format!( + Self::WatchOnly(AccountRuntimeFailureIssue::new(format!( "resolved account `{account_id}` is watch_only and cannot sign because it is not secret-backed" - )) + ))) } pub fn mismatch(message: impl Into<String>) -> Self { - Self::Mismatch(message.into()) + Self::Mismatch(AccountRuntimeFailureIssue::new(message)) } -} -impl fmt::Display for AccountRuntimeFailure { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + pub fn mismatch_with_detail(message: impl Into<String>, detail: serde_json::Value) -> Self { + Self::Mismatch(AccountRuntimeFailureIssue::with_detail(message, detail)) + } + + pub fn message(&self) -> &str { match self { - Self::Unresolved(message) | Self::WatchOnly(message) | Self::Mismatch(message) => { - formatter.write_str(message) + Self::Unresolved(issue) | Self::WatchOnly(issue) | Self::Mismatch(issue) => { + issue.message.as_str() } } } + + pub fn detail_json(&self) -> Option<&str> { + match self { + Self::Unresolved(issue) | Self::WatchOnly(issue) | Self::Mismatch(issue) => { + issue.detail_json.as_deref() + } + } + } +} + +impl fmt::Display for AccountRuntimeFailure { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(self.message()) + } } impl std::error::Error for AccountRuntimeFailure {} diff --git a/src/runtime/farm.rs b/src/runtime/farm.rs @@ -15,6 +15,7 @@ use radroots_sdk::{ SdkRadrootsdPublishReceipt, SdkRadrootsdSignerSessionRef, SdkTransportMode, SdkTransportReceipt, SignerConfig as SdkSignerConfig, }; +use serde_json::json; use crate::domain::runtime::{ FarmConfigDocumentView, FarmConfigSummaryView, FarmGetView, FarmListingDefaultsView, @@ -148,7 +149,8 @@ fn rebind_inner( let from_seller_pubkey = from_account .as_ref() .map(|account| account.record.public_identity.public_key_hex.clone()); - let target_account = accounts::resolve_account_selector(config, args.selector.as_str())?; + let target_account = accounts::resolve_account_selector(config, args.selector.as_str()) + .map_err(|error| farm_rebind_selector_error(args.selector.as_str(), error))?; let to_seller_pubkey = target_account.record.public_identity.public_key_hex.clone(); let seller_pubkey_changed = from_seller_pubkey .as_deref() @@ -206,6 +208,23 @@ fn rebind_inner( }) } +fn farm_rebind_selector_error(selector: &str, error: RuntimeError) -> RuntimeError { + match error { + RuntimeError::Account(accounts::AccountRuntimeFailure::Unresolved(issue)) => { + accounts::AccountRuntimeFailure::unresolved_with_detail( + issue.message().to_owned(), + json!({ + "seller_actor_source": FARM_SELLER_ACTOR_SOURCE, + "selector": selector, + "actions": account_recovery_actions(), + }), + ) + .into() + } + other => other, + } +} + pub fn set(config: &RuntimeConfig, args: &FarmUpdateArgs) -> Result<FarmSetView, RuntimeError> { let scope = scope_from_arg(args.scope); let resolved_scope = farm_config::resolve_scope(&config.paths, scope)?; @@ -230,6 +249,13 @@ pub fn set(config: &RuntimeConfig, args: &FarmUpdateArgs) -> Result<FarmSetView, let account_pubkey = configured_account .as_ref() .map(|account| account.record.public_identity.public_key_hex.as_str()); + let reason = if configured_account.is_none() { + Some(missing_farm_bound_seller_reason( + resolved.document.selection.account.as_str(), + )) + } else { + None + }; Ok(FarmSetView { state: "updated".to_owned(), @@ -242,7 +268,7 @@ pub fn set(config: &RuntimeConfig, args: &FarmUpdateArgs) -> Result<FarmSetView, &resolved.document, account_pubkey, )), - reason: None, + reason, actions: farm_update_actions(config, &resolved.document, configured_account.as_ref()), }) } @@ -273,6 +299,14 @@ pub fn set_preflight( let account_pubkey = configured_account .as_ref() .map(|account| account.record.public_identity.public_key_hex.as_str()); + let reason = if configured_account.is_none() { + Some(format!( + "dry run requested; farm draft was not written; {}", + missing_farm_bound_seller_reason(resolved.document.selection.account.as_str()) + )) + } else { + Some("dry run requested; farm draft was not written".to_owned()) + }; Ok(FarmSetView { state: "dry_run".to_owned(), @@ -285,7 +319,7 @@ pub fn set_preflight( &resolved.document, account_pubkey, )), - reason: Some("dry run requested; farm draft was not written".to_owned()), + reason, actions: farm_update_actions(config, &resolved.document, configured_account.as_ref()), }) } @@ -1734,10 +1768,19 @@ fn init_document( if let Some(document) = existing_document && document.selection.account != account.record.account_id.to_string() { - return Err(accounts::AccountRuntimeFailure::mismatch(format!( + let message = format!( "account mismatch: farm config is bound to seller account `{}`; use `radroots farm rebind {}` to change the farm-bound seller account", document.selection.account, account.record.account_id - )) + ); + return Err(accounts::AccountRuntimeFailure::mismatch_with_detail( + message, + json!({ + "seller_actor_source": FARM_SELLER_ACTOR_SOURCE, + "farm_bound_seller_account_id": document.selection.account, + "attempted_seller_account_id": account.record.account_id.to_string(), + "actions": [format!("radroots farm rebind {}", account.record.account_id)], + }), + ) .into()); } let farm_d_tag = match args.farm_d_tag.as_deref() { @@ -1876,6 +1919,10 @@ fn farm_setup_actions( account: Option<&AccountRecordView>, ) -> Vec<String> { let mut actions = vec!["radroots farm readiness check".to_owned()]; + if account.is_none() { + actions.extend(farm_bound_seller_recovery_actions()); + return actions; + } if farm_config::missing_fields(document).is_empty() && account .map(|account| farm_publish_readiness(config, account).executable) @@ -1886,6 +1933,24 @@ fn farm_setup_actions( actions } +fn missing_farm_bound_seller_reason(account_id: &str) -> String { + format!("farm-bound seller account `{account_id}` is not present in the local account store") +} + +fn farm_bound_seller_recovery_actions() -> Vec<String> { + vec![ + "radroots account import <path>".to_owned(), + "radroots farm rebind <selector>".to_owned(), + ] +} + +fn account_recovery_actions() -> Vec<String> { + vec![ + "radroots account import <path>".to_owned(), + "radroots account create".to_owned(), + ] +} + fn missing_blocks_listing_defaults(missing: &[FarmMissingField]) -> bool { missing.iter().any(|field| { matches!( diff --git a/src/runtime/listing.rs b/src/runtime/listing.rs @@ -33,6 +33,7 @@ use radroots_sql_core::SqliteExecutor; use radroots_trade::listing::publish::validate_listing_for_seller; use radroots_trade::listing::validation::validate_listing_event; use serde::{Deserialize, Serialize}; +use serde_json::json; use crate::domain::runtime::{ FindPriceView, FindQuantityView, FindResultProvenanceView, ListingGetView, ListingListView, @@ -2246,10 +2247,21 @@ fn authoring_defaults(config: &RuntimeConfig) -> Result<ListingAuthoringDefaults return Ok(defaults); }; let Some(account) = configured_account(config, &resolved.document.selection.account)? else { - return Err(RuntimeError::Config(format!( - "farm config account `{}` is not present in the local account store", - resolved.document.selection.account - ))); + let account_id = resolved.document.selection.account.clone(); + return Err(accounts::AccountRuntimeFailure::unresolved_with_detail( + format!( + "farm-bound seller account `{account_id}` is not present in the local account store" + ), + json!({ + "seller_actor_source": "farm_config", + "farm_bound_seller_account_id": account_id, + "actions": [ + "radroots account import <path>", + "radroots farm rebind <selector>", + ], + }), + ) + .into()); }; defaults.farm_config_present = true; diff --git a/tests/signer_runtime_modes.rs b/tests/signer_runtime_modes.rs @@ -1474,6 +1474,30 @@ fn farm_rebind_is_explicit_and_publish_defaults_ignore_ambient_selection() { "preserved" ); + let same_seller_live = sandbox.json_success(&[ + "--format", + "json", + "--approval-token", + "approve", + "farm", + "rebind", + first_account_id, + ]); + assert_eq!(same_seller_live["operation_id"], "farm.rebind"); + assert_eq!( + same_seller_live["result"]["publication_state_action"], + "preserved" + ); + let same_seller_get = sandbox.json_success(&["--format", "json", "farm", "get"]); + assert_eq!( + same_seller_get["result"]["document"]["publication"]["profile_state"], + "published" + ); + assert_eq!( + same_seller_get["result"]["document"]["publication"]["farm_state"], + "published" + ); + let second = sandbox.json_success(&["--format", "json", "account", "create"]); let second_account_id = second["result"]["account"]["id"] .as_str() @@ -1506,6 +1530,44 @@ fn farm_rebind_is_explicit_and_publish_defaults_ignore_ambient_selection() { assert_eq!(retarget["operation_id"], "farm.create"); assert_eq!(retarget["errors"][0]["code"], "account_mismatch"); assert_contains(&retarget["errors"][0]["message"], "farm-bound seller"); + assert_eq!( + retarget["errors"][0]["detail"]["seller_actor_source"], + "farm_config" + ); + assert_eq!( + retarget["errors"][0]["detail"]["farm_bound_seller_account_id"], + first_account_id + ); + assert_eq!( + retarget["errors"][0]["detail"]["attempted_seller_account_id"], + second_account_id + ); + assert_next_action_present( + &retarget, + format!("radroots farm rebind {second_account_id}").as_str(), + ); + + let (missing_rebind_output, missing_rebind) = sandbox.json_output(&[ + "--format", + "json", + "--dry-run", + "farm", + "rebind", + "acct_missing", + ]); + assert!(!missing_rebind_output.status.success()); + assert_eq!(missing_rebind["operation_id"], "farm.rebind"); + assert_eq!(missing_rebind["errors"][0]["code"], "account_unresolved"); + assert_eq!( + missing_rebind["errors"][0]["detail"]["seller_actor_source"], + "farm_config" + ); + assert_eq!( + missing_rebind["errors"][0]["detail"]["selector"], + "acct_missing" + ); + assert_next_action_present(&missing_rebind, "radroots account import <path>"); + assert_next_action_present(&missing_rebind, "radroots account create"); let publish_dry_run = sandbox.json_success(&[ "--format", @@ -1641,6 +1703,118 @@ fn farm_rebind_is_explicit_and_publish_defaults_ignore_ambient_selection() { } #[test] +fn missing_farm_bound_seller_blocks_listing_create_and_guides_setup_repair() { + let sandbox = RadrootsCliSandbox::new(); + let first = sandbox.json_success(&["--format", "json", "account", "create"]); + let first_account_id = first["result"]["account"]["id"] + .as_str() + .expect("first account id"); + sandbox.json_success(&[ + "--format", + "json", + "farm", + "create", + "--name", + "Missing Seller Farm", + "--location", + "farmstand", + "--country", + "US", + "--delivery-method", + "pickup", + ]); + let second = sandbox.json_success(&["--format", "json", "account", "create"]); + let second_account_id = second["result"]["account"]["id"] + .as_str() + .expect("second account id"); + sandbox.json_success(&[ + "--format", + "json", + "account", + "selection", + "update", + second_account_id, + ]); + sandbox.json_success(&[ + "--format", + "json", + "--approval-token", + "approve", + "account", + "remove", + first_account_id, + ]); + + let updated = sandbox.json_success(&[ + "--format", + "json", + "farm", + "profile", + "update", + "--field", + "name", + "--value", + "Missing Seller Farm Updated", + ]); + assert_eq!(updated["operation_id"], "farm.profile.update"); + assert_contains(&updated["result"]["reason"], "farm-bound seller account"); + assert_action_present(&updated, "radroots account import <path>"); + assert_action_present(&updated, "radroots farm rebind <selector>"); + + let listing_path = sandbox.root().join("missing-seller-listing.toml"); + let (listing_output, listing) = sandbox.json_output(&[ + "--format", + "json", + "listing", + "create", + "--output", + listing_path.to_string_lossy().as_ref(), + "--key", + "missing-seller-eggs", + "--title", + "Missing Seller Eggs", + "--category", + "eggs", + "--summary", + "Fresh eggs", + "--bin-id", + "bin-1", + "--quantity-amount", + "1", + "--quantity-unit", + "each", + "--price-amount", + "6", + "--price-currency", + "USD", + "--price-per-amount", + "1", + "--price-per-unit", + "each", + "--available", + "10", + ]); + assert!(!listing_output.status.success()); + assert_eq!(listing["operation_id"], "listing.create"); + assert_eq!(listing["errors"][0]["code"], "account_unresolved"); + assert_contains( + &listing["errors"][0]["message"], + "farm-bound seller account", + ); + assert_eq!( + listing["errors"][0]["detail"]["seller_actor_source"], + "farm_config" + ); + assert_eq!( + listing["errors"][0]["detail"]["farm_bound_seller_account_id"], + first_account_id + ); + assert_next_action_present(&listing, "radroots account import <path>"); + assert_next_action_present(&listing, "radroots farm rebind <selector>"); + assert!(!listing_path.exists()); +} + +#[test] fn farm_rebind_allows_watch_only_target_and_attach_secret_recovers_publish() { let sandbox = RadrootsCliSandbox::new(); sandbox.json_success(&["--format", "json", "account", "create"]); @@ -2380,6 +2554,16 @@ fn assert_action_present(value: &Value, action: &str) { ); } +fn assert_next_action_present(value: &Value, action: &str) { + assert!( + next_action_commands(value) + .iter() + .any(|entry| *entry == action), + "expected next action `{action}` in `{}`", + value["next_actions"] + ); +} + fn assert_action_absent(value: &Value, action: &str) { assert!( action_list(value).iter().all(|entry| *entry != action), @@ -2396,3 +2580,12 @@ fn action_list(value: &Value) -> Vec<&str> { .map(|entry| entry.as_str().expect("action")) .collect() } + +fn next_action_commands(value: &Value) -> Vec<&str> { + value["next_actions"] + .as_array() + .expect("next actions") + .iter() + .filter_map(|entry| entry["command"].as_str()) + .collect() +}