cli

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

commit 106516a900d4e97c500105ee16036cc0f91efe94
parent bccb5d0432da311b68a4a003184e1a93330ff488
Author: triesap <tyson@radroots.org>
Date:   Mon, 27 Apr 2026 06:24:07 +0000

cli: prove local account resolution

- wire account import get remove and selection inputs
- classify account selector failures as unresolved accounts
- test local signer default and invocation override resolution
- test watch-only import signer readiness boundaries

Diffstat:
Msrc/operation_adapter.rs | 24++++++++++++++++++++++--
Msrc/operation_core.rs | 12+++++++++---
Msrc/target_cli.rs | 37+++++++++++++++++++++++++++----------
Mtests/signer_runtime_modes.rs | 179+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
4 files changed, 237 insertions(+), 15 deletions(-)

diff --git a/src/operation_adapter.rs b/src/operation_adapter.rs @@ -519,7 +519,9 @@ fn classify_runtime_failure( &lowered, &[ "no account", + "account selector", "account selection", + "did not match any local account", "unresolved account", "selected account", ], @@ -771,12 +773,30 @@ fn value_to_data(value: Value) -> OperationData { fn target_operation_input(command: &crate::target_cli::TargetCommand) -> OperationData { use crate::target_cli::{ - BasketCommand, BasketItemCommand, BasketQuoteCommand, ListingCommand, MarketCommand, - MarketListingCommand, MarketProductCommand, OrderCommand, OrderEventCommand, TargetCommand, + AccountCommand, AccountSelectionCommand, BasketCommand, BasketItemCommand, + BasketQuoteCommand, ListingCommand, MarketCommand, MarketListingCommand, + MarketProductCommand, OrderCommand, OrderEventCommand, TargetCommand, }; let mut input = OperationData::new(); match command { + TargetCommand::Account(args) => match &args.command { + AccountCommand::Import(args) => { + insert_path(&mut input, "path", &args.path); + if args.default { + input.insert("default".to_owned(), Value::Bool(true)); + } + } + AccountCommand::Get(args) => insert_string(&mut input, "selector", &args.selector), + AccountCommand::Remove(args) => insert_string(&mut input, "selector", &args.selector), + AccountCommand::Selection(args) => match &args.command { + AccountSelectionCommand::Update(args) => { + insert_string(&mut input, "selector", &args.selector) + } + AccountSelectionCommand::Get | AccountSelectionCommand::Clear => {} + }, + AccountCommand::Create | AccountCommand::List => {} + }, TargetCommand::Listing(args) => match &args.command { ListingCommand::Create(args) => { insert_path(&mut input, "output", &args.output); diff --git a/src/operation_core.rs b/src/operation_core.rs @@ -260,7 +260,9 @@ impl OperationService<AccountGetRequest> for CoreOperationService<'_> { } else { self.config }; - let resolution = map_runtime(resolve_account_resolution(config))?; + let resolution = resolve_account_resolution(config).map_err(|error| { + OperationAdapterError::unconfigured(request.operation_id(), error.to_string()) + })?; let reason = if resolution.resolved_account.is_some() { None } else { @@ -315,7 +317,9 @@ impl OperationService<AccountRemoveRequest> for CoreOperationService<'_> { )); } - let result = map_runtime(remove_account(self.config, selector.as_str()))?; + 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!({ "state": "removed", "removed_account": account_summary_view(&result.removed_account), @@ -354,7 +358,9 @@ impl OperationService<AccountSelectionUpdateRequest> for CoreOperationService<'_ })); } - let account = map_runtime(select_account(self.config, selector.as_str()))?; + let account = select_account(self.config, selector.as_str()).map_err(|error| { + OperationAdapterError::unconfigured(request.operation_id(), error.to_string()) + })?; json_operation_result::<AccountSelectionUpdateResult>(json!({ "state": "default", "account": account_summary_view(&account), diff --git a/src/target_cli.rs b/src/target_cli.rs @@ -85,13 +85,13 @@ impl TargetCommand { }, Self::Account(args) => match &args.command { AccountCommand::Create => "account.create", - AccountCommand::Import => "account.import", - AccountCommand::Get => "account.get", + AccountCommand::Import(_) => "account.import", + AccountCommand::Get(_) => "account.get", AccountCommand::List => "account.list", - AccountCommand::Remove => "account.remove", - AccountCommand::Selection(selection) => match selection.command { + AccountCommand::Remove(_) => "account.remove", + AccountCommand::Selection(selection) => match &selection.command { AccountSelectionCommand::Get => "account.selection.get", - AccountSelectionCommand::Update => "account.selection.update", + AccountSelectionCommand::Update(_) => "account.selection.update", AccountSelectionCommand::Clear => "account.selection.clear", }, }, @@ -268,23 +268,40 @@ pub struct AccountArgs { #[derive(Debug, Clone, Subcommand)] pub enum AccountCommand { Create, - Import, - Get, + Import(AccountImportArgs), + Get(AccountGetArgs), List, - Remove, + Remove(AccountSelectorArgs), Selection(AccountSelectionArgs), } #[derive(Debug, Clone, Args)] +pub struct AccountImportArgs { + pub path: Option<PathBuf>, + #[arg(long, action = clap::ArgAction::SetTrue)] + pub default: bool, +} + +#[derive(Debug, Clone, Args)] +pub struct AccountGetArgs { + pub selector: Option<String>, +} + +#[derive(Debug, Clone, Args)] +pub struct AccountSelectorArgs { + pub selector: Option<String>, +} + +#[derive(Debug, Clone, Args)] pub struct AccountSelectionArgs { #[command(subcommand)] pub command: AccountSelectionCommand, } -#[derive(Debug, Clone, Copy, Subcommand)] +#[derive(Debug, Clone, Subcommand)] pub enum AccountSelectionCommand { Get, - Update, + Update(AccountSelectorArgs), Clear, } diff --git a/tests/signer_runtime_modes.rs b/tests/signer_runtime_modes.rs @@ -55,6 +55,171 @@ fn local_signer_status_reports_ready_after_account_create() { } #[test] +fn local_account_selection_and_invocation_override_resolve_signer_actor() { + let sandbox = RadrootsCliSandbox::new(); + + let first = sandbox.json_success(&["--format", "json", "account", "create"]); + let second = sandbox.json_success(&["--format", "json", "account", "create"]); + let first_account_id = first["result"]["account"]["id"] + .as_str() + .expect("first account id"); + let second_account_id = second["result"]["account"]["id"] + .as_str() + .expect("second account id"); + assert_eq!(first["result"]["account"]["is_default"], true); + assert_eq!(second["result"]["account"]["is_default"], false); + + let default_status = sandbox.json_success(&["--format", "json", "signer", "status", "get"]); + assert_eq!(default_status["result"]["state"], "ready"); + assert_eq!( + default_status["result"]["signer_account_id"], + first_account_id + ); + assert_eq!( + default_status["result"]["account_resolution"]["source"], + "default_account" + ); + + let override_status = sandbox.json_success(&[ + "--format", + "json", + "--account-id", + second_account_id, + "signer", + "status", + "get", + ]); + assert_eq!(override_status["actor"]["account_id"], second_account_id); + assert_eq!(override_status["actor"]["role"], "account"); + assert_eq!( + override_status["result"]["signer_account_id"], + second_account_id + ); + assert_eq!( + override_status["result"]["account_resolution"]["source"], + "invocation_override" + ); + assert_eq!( + override_status["result"]["account_resolution"]["default_account"]["id"], + first_account_id + ); + + let selected = sandbox.json_success(&[ + "--format", + "json", + "account", + "selection", + "update", + second_account_id, + ]); + assert_eq!(selected["operation_id"], "account.selection.update"); + assert_eq!(selected["result"]["state"], "default"); + assert_eq!(selected["result"]["account"]["id"], second_account_id); + + let selected_status = sandbox.json_success(&["--format", "json", "signer", "status", "get"]); + assert_eq!( + selected_status["result"]["signer_account_id"], + second_account_id + ); + assert_eq!( + selected_status["result"]["account_resolution"]["source"], + "default_account" + ); + + let selected_get = + sandbox.json_success(&["--format", "json", "account", "get", first_account_id]); + assert_eq!(selected_get["operation_id"], "account.get"); + assert_eq!( + selected_get["result"]["account_resolution"]["source"], + "invocation_override" + ); + assert_eq!( + selected_get["result"]["account_resolution"]["resolved_account"]["id"], + first_account_id + ); +} + +#[test] +fn unresolved_account_override_returns_account_failure() { + let sandbox = RadrootsCliSandbox::new(); + let (output, value) = sandbox.json_output(&[ + "--format", + "json", + "--account-id", + "missing-account", + "account", + "get", + ]); + + assert!(!output.status.success()); + assert_eq!(value["operation_id"], "account.get"); + assert_eq!(value["result"], serde_json::Value::Null); + assert_eq!(value["errors"][0]["code"], "account_unresolved"); + assert_eq!(value["errors"][0]["exit_code"], 5); + assert_eq!(value["errors"][0]["detail"]["class"], "account"); + assert_contains(&value["errors"][0]["message"], "account selector"); +} + +#[test] +fn watch_only_import_reports_unconfigured_local_signer() { + let sandbox = RadrootsCliSandbox::new(); + let public_identity = identity_public(11); + let public_identity_file = + write_public_identity_profile(&sandbox, "watch-only", &public_identity); + + let imported = sandbox.json_success(&[ + "--format", + "json", + "--approval-token", + "approve", + "account", + "import", + "--default", + public_identity_file.to_string_lossy().as_ref(), + ]); + + assert_eq!(imported["operation_id"], "account.import"); + assert_eq!(imported["result"]["state"], "imported"); + assert_eq!( + imported["result"]["account"]["id"], + public_identity.id.as_str() + ); + assert_eq!(imported["result"]["account"]["signer"], "watch_only"); + assert_eq!(imported["result"]["account"]["is_default"], true); + + let status = sandbox.json_success(&["--format", "json", "signer", "status", "get"]); + + assert_eq!(status["result"]["mode"], "local"); + assert_eq!(status["result"]["state"], "unconfigured"); + assert_eq!( + status["result"]["signer_account_id"], + public_identity.id.as_str() + ); + assert_eq!( + status["result"]["account_resolution"]["source"], + "default_account" + ); + assert_eq!( + status["result"]["account_resolution"]["resolved_account"]["signer"], + "watch_only" + ); + assert_eq!( + status["result"]["local"]["account_id"], + public_identity.id.as_str() + ); + assert_eq!(status["result"]["local"]["availability"], "public_only"); + assert_eq!(status["result"]["local"]["secret_backed"], false); + assert_contains(&status["result"]["reason"], "not secret-backed"); + assert!( + status["result"]["write_kinds"] + .as_array() + .expect("write kinds") + .iter() + .all(|kind| kind["ready"] == false) + ); +} + +#[test] fn myc_signer_status_reports_unavailable_for_missing_executable() { let sandbox = RadrootsCliSandbox::new(); let missing_myc = sandbox.root().join("bin/missing-myc"); @@ -378,6 +543,20 @@ fn identity_public(seed: u8) -> RadrootsIdentityPublic { .to_public() } +fn write_public_identity_profile( + sandbox: &RadrootsCliSandbox, + name: &str, + identity: &RadrootsIdentityPublic, +) -> PathBuf { + let path = sandbox.root().join(format!("{name}.json")); + fs::write( + &path, + serde_json::to_string_pretty(identity).expect("public identity json"), + ) + .expect("write public identity"); + path +} + fn shell_single_quoted(value: &str) -> String { value.replace('\'', "'\"'\"'") }