cli

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

commit 6f70dc2dfa821f3a0e62d9485bf21f8817ec7103
parent 7e9023919e2ad6b1748be4ea17d2213321bfb864
Author: triesap <tyson@radroots.org>
Date:   Sat, 27 Jun 2026 08:35:18 +0000

cli: warn on farm-bound account removal

- detect approved account removal that orphans the farm seller binding
- surface farm_bound_seller_orphaned warnings with recovery actions
- render removed account fields in terminal account output
- cover farm-bound and non-farm-bound removal in signer tests

Diffstat:
Msrc/ops/exec/core.rs | 71+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Msrc/out/terminal/renderers/account.rs | 14++++++++++++++
Mtests/signer_runtime_modes.rs | 172+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
3 files changed, 255 insertions(+), 2 deletions(-)

diff --git a/src/ops/exec/core.rs b/src/ops/exec/core.rs @@ -18,6 +18,7 @@ use crate::ops::{ StoreInitRequest, StoreInitResult, StoreStatusGetRequest, StoreStatusGetResult, WorkspaceGetRequest, WorkspaceGetResult, WorkspaceInitRequest, WorkspaceInitResult, }; +use crate::out::envelope::OutputWarning; use crate::runtime::RuntimeError; use crate::runtime::account::{ AccountResolution, AccountRuntimeFailure, account_resolution_view, account_summary_view, @@ -483,15 +484,29 @@ impl OperationService<AccountRemoveRequest> for CoreOperationService<'_> { )); } + let resolved_farm_config = + map_runtime(crate::runtime::farm_config::load(self.config, None))?; let result = remove_account(self.config, selector.as_str()).map_err(|error| { OperationAdapterError::unconfigured(request.operation_id(), error.to_string()) })?; - json_operation_result::<AccountRemoveResult>(json!({ + let removed_account_id = result.removed_account.record.account_id.to_string(); + let farm_orphan_warning = + account_remove_farm_orphan_warning(resolved_farm_config.as_ref(), &removed_account_id); + let mut result_value = json!({ "state": "removed", "removed_account": account_summary_view(&result.removed_account), "default_cleared": result.default_cleared, "remaining_account_count": result.remaining_account_count, - })) + }); + if let Some(warning) = farm_orphan_warning.as_ref() { + result_value["warnings"] = json!([warning.result_value()]); + result_value["actions"] = json!(warning.actions.clone()); + } + let mut operation_result = json_operation_result::<AccountRemoveResult>(result_value)?; + if let Some(warning) = farm_orphan_warning { + operation_result.warnings.push(warning.output_warning()); + } + Ok(operation_result) } } @@ -1018,6 +1033,58 @@ fn config_actions( publish_recovery_actions(config, account, publish) } +#[derive(Debug, Clone)] +struct AccountRemoveFarmOrphanWarning { + message: String, + subject_account_id: String, + farm_config_scope: String, + farm_config_path: String, + actions: Vec<String>, +} + +impl AccountRemoveFarmOrphanWarning { + const CODE: &'static str = "farm_bound_seller_orphaned"; + + fn result_value(&self) -> Value { + json!({ + "code": Self::CODE, + "message": self.message.clone(), + "subject_account_id": self.subject_account_id.clone(), + "farm_config": { + "scope": self.farm_config_scope.clone(), + "path": self.farm_config_path.clone(), + }, + "actions": self.actions.clone(), + }) + } + + fn output_warning(&self) -> OutputWarning { + OutputWarning { + code: Self::CODE.to_owned(), + message: self.message.clone(), + } + } +} + +fn account_remove_farm_orphan_warning( + resolved: Option<&crate::runtime::farm_config::ResolvedFarmConfig>, + removed_account_id: &str, +) -> Option<AccountRemoveFarmOrphanWarning> { + let resolved = resolved?; + if resolved.document.selection.account != removed_account_id { + return None; + } + Some(AccountRemoveFarmOrphanWarning { + message: format!( + "removed account `{removed_account_id}` is still bound as the farm seller account" + ), + subject_account_id: removed_account_id.to_owned(), + farm_config_scope: resolved.scope.as_str().to_owned(), + farm_config_path: resolved.path.display().to_string(), + actions: crate::runtime::farm::farm_bound_seller_recovery_actions("<selector>"), + }) +} + fn publish_recovery_actions( config: &RuntimeConfig, account: &AccountResolution, diff --git a/src/out/terminal/renderers/account.rs b/src/out/terminal/renderers/account.rs @@ -63,6 +63,7 @@ fn add_account_fields(document: &mut TerminalDocument, result: &Value) { &["resolved_account", "account_id"][..], &["default_account", "account_id"][..], &["selected_account", "account_id"][..], + &["removed_account", "id"][..], ] { if let Some(account_id) = common::string(result, path) { common::push_field(document, "Account", account_id); @@ -70,13 +71,26 @@ fn add_account_fields(document: &mut TerminalDocument, result: &Value) { } } common::push_path_field(document, "Label", result, &["account", "label"]); + common::push_path_field(document, "Label", result, &["removed_account", "label"]); common::push_path_field(document, "Public key", result, &["account", "public_key"]); + common::push_path_field( + document, + "Public key", + result, + &["removed_account", "public_key"], + ); common::push_bool_field( document, "Write capable", result, &["account", "write_capable"], ); + common::push_bool_field( + document, + "Write capable", + result, + &["removed_account", "write_capable"], + ); common::push_path_field(document, "Source", result, &["source"]); } diff --git a/tests/signer_runtime_modes.rs b/tests/signer_runtime_modes.rs @@ -498,6 +498,157 @@ fn account_remove_dry_run_validates_selector_without_mutating_store() { } #[test] +fn account_remove_warns_when_farm_bound_seller_is_orphaned() { + 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"); + let farm = create_test_farm(&sandbox); + let farm_path = farm["result"]["config"]["path"] + .as_str() + .expect("farm path"); + let farm_before_remove = fs::read_to_string(farm_path).expect("farm before account remove"); + let second = sandbox.json_success(&["--format", "json", "account", "create"]); + let second_account_id = second["result"]["account"]["id"] + .as_str() + .expect("second account id"); + + let non_bound_removed = sandbox.json_success(&[ + "--format", + "json", + "--approval-token", + "approve", + "account", + "remove", + second_account_id, + ]); + + assert_eq!(non_bound_removed["operation_id"], "account.remove"); + assert_eq!(non_bound_removed["result"]["state"], "removed"); + assert_eq!( + non_bound_removed["result"]["removed_account"]["id"], + second_account_id + ); + assert!(non_bound_removed["result"].get("warnings").is_none()); + assert!(non_bound_removed["result"].get("actions").is_none()); + assert!( + non_bound_removed["warnings"] + .as_array() + .expect("top-level warnings") + .is_empty() + ); + assert!( + non_bound_removed["next_actions"] + .as_array() + .expect("next actions") + .is_empty() + ); + assert_eq!( + fs::read_to_string(farm_path).expect("farm after non-bound remove"), + farm_before_remove + ); + + let orphaned = sandbox.json_success(&[ + "--format", + "json", + "--approval-token", + "approve", + "account", + "remove", + first_account_id, + ]); + + assert_eq!(orphaned["operation_id"], "account.remove"); + assert_eq!(orphaned["result"]["state"], "removed"); + assert_eq!( + orphaned["result"]["removed_account"]["id"], + first_account_id + ); + assert_eq!( + orphaned["result"]["warnings"][0]["code"], + "farm_bound_seller_orphaned" + ); + assert_eq!( + orphaned["result"]["warnings"][0]["subject_account_id"], + first_account_id + ); + assert_eq!( + orphaned["result"]["warnings"][0]["farm_config"]["scope"], + "workspace" + ); + assert_eq!( + orphaned["result"]["warnings"][0]["farm_config"]["path"], + farm_path + ); + assert_eq!( + orphaned["warnings"][0]["code"], + "farm_bound_seller_orphaned" + ); + assert_action_present(&orphaned, "radroots account import <path>"); + assert_action_present(&orphaned, "radroots --dry-run farm rebind <selector>"); + assert_action_present( + &orphaned, + "radroots --approval-token approve farm rebind <selector>", + ); + assert_next_action_present(&orphaned, "radroots account import <path>"); + assert_next_action_present(&orphaned, "radroots --dry-run farm rebind <selector>"); + assert_next_action_present( + &orphaned, + "radroots --approval-token approve farm rebind <selector>", + ); + assert_eq!( + fs::read_to_string(farm_path).expect("farm after farm-bound remove"), + farm_before_remove + ); +} + +#[test] +fn account_remove_farm_orphan_warning_renders_terminal() { + let sandbox = RadrootsCliSandbox::new(); + let created = sandbox.json_success(&["--format", "json", "account", "create"]); + let account_id = created["result"]["account"]["id"] + .as_str() + .expect("account id") + .to_owned(); + create_test_farm(&sandbox); + + let output = sandbox + .command() + .args([ + "--approval-token", + "approve", + "account", + "remove", + account_id.as_str(), + ]) + .output() + .expect("terminal account remove"); + + assert!(output.status.success()); + assert!(output.stderr.is_empty()); + let stdout = String::from_utf8(output.stdout).expect("terminal stdout"); + assert!(stdout.contains("Account removed"), "{stdout}"); + assert!(stdout.contains(account_id.as_str()), "{stdout}"); + assert!( + stdout.contains("Warnings\n farm_bound_seller_orphaned:"), + "{stdout}" + ); + assert!( + stdout.contains("Next\n radroots account import <path>"), + "{stdout}" + ); + assert!( + stdout.contains("radroots --dry-run farm rebind <selector>"), + "{stdout}" + ); + assert!( + stdout.contains("radroots --approval-token approve farm rebind <selector>"), + "{stdout}" + ); +} + +#[test] fn account_selection_update_dry_run_validates_selector_without_mutating_selection() { let sandbox = RadrootsCliSandbox::new(); let first = sandbox.json_success(&["--format", "json", "account", "create"]); @@ -2634,3 +2785,24 @@ fn next_action_commands(value: &Value) -> Vec<&str> { .filter_map(|entry| entry["command"].as_str()) .collect() } + +fn create_test_farm(sandbox: &RadrootsCliSandbox) -> Value { + sandbox.json_success(&[ + "--format", + "json", + "farm", + "create", + "--name", + "Green Farm", + "--location", + "farmstand", + "--city", + "San Francisco", + "--country", + "US", + "--geohash", + "9q8yy", + "--delivery-method", + "pickup", + ]) +}