cli

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

commit f59fbea5a1faa094bf0ed646a1883890de18f3fa
parent 45d7d5e64d14697b144dce99ba5719b6968878cd
Author: triesap <tyson@radroots.org>
Date:   Mon, 20 Apr 2026 18:32:31 +0000

align cli account handling with default semantics

Diffstat:
Msrc/commands/identity.rs | 4++--
Msrc/runtime/accounts.rs | 96++++++++++++++++++++++++++++++++++++++++++++++---------------------------------
Msrc/runtime/signer.rs | 8++++----
Mtests/identity_commands.rs | 85++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mtests/workflow.rs | 2+-
5 files changed, 147 insertions(+), 48 deletions(-)

diff --git a/src/commands/identity.rs b/src/commands/identity.rs @@ -5,13 +5,13 @@ use crate::domain::runtime::{ use crate::runtime::RuntimeError; use crate::runtime::accounts::{ AccountCreateMode, AccountRecordView, SHARED_ACCOUNT_STORE_SOURCE, account_resolution_view, - account_summary_view, create_or_migrate_selected_account, resolve_account_resolution, + account_summary_view, create_or_migrate_default_account, resolve_account_resolution, select_account, snapshot, unresolved_account_reason, }; use crate::runtime::config::RuntimeConfig; pub fn init(config: &RuntimeConfig) -> Result<AccountNewView, RuntimeError> { - let result = create_or_migrate_selected_account(config)?; + let result = create_or_migrate_default_account(config)?; let account = account_summary(&result.account); Ok(AccountNewView { state: match result.mode { diff --git a/src/runtime/accounts.rs b/src/runtime/accounts.rs @@ -1,5 +1,6 @@ use radroots_nostr_accounts::prelude::{ - RadrootsNostrAccountRecord, RadrootsNostrAccountsManager, RadrootsNostrSelectedAccountStatus, + RadrootsNostrAccountRecord, RadrootsNostrAccountStatus, RadrootsNostrAccountsError, + RadrootsNostrAccountsManager, }; use radroots_secret_vault::{ RadrootsHostVaultCapabilities, RadrootsResolvedSecretBackend, @@ -74,28 +75,32 @@ pub struct AccountResolution { pub default_account: Option<AccountRecordView>, } -pub fn create_or_migrate_selected_account( +pub fn create_or_migrate_default_account( config: &RuntimeConfig, ) -> Result<AccountCreateResult, RuntimeError> { let manager = account_manager(config)?; let existing = manager.list_accounts()?; - let mode = if existing.is_empty() && config.identity.path.exists() { - manager.migrate_legacy_identity_file(&config.identity.path, None, true)?; - AccountCreateMode::Migrated + let (mode, created_account_id) = if existing.is_empty() && config.identity.path.exists() { + ( + AccountCreateMode::Migrated, + manager.migrate_legacy_identity_file(&config.identity.path, None, false)?, + ) } else { - manager.generate_identity(None, true)?; - AccountCreateMode::Created + ( + AccountCreateMode::Created, + manager.generate_identity(None, false)?, + ) }; let snapshot = snapshot(config)?; let Some(account) = snapshot .accounts .into_iter() - .find(|account| account.is_default) + .find(|account| account.record.account_id == created_account_id) else { return Err(RuntimeError::Accounts( radroots_nostr_accounts::prelude::RadrootsNostrAccountsError::InvalidState( - "default account missing after account create".to_owned(), + "created account missing after account create".to_owned(), ), )); }; @@ -115,21 +120,18 @@ pub fn resolve_account(config: &RuntimeConfig) -> Result<Option<AccountRecordVie pub fn resolve_account_resolution( config: &RuntimeConfig, ) -> Result<AccountResolution, RuntimeError> { - let snapshot = snapshot(config)?; + let manager = account_manager(config)?; + let snapshot = snapshot_from_manager(&manager)?; let default_account = snapshot .accounts .iter() .find(|account| account.is_default) .cloned(); if let Some(selector) = config.account.selector.as_deref() { - let Some(account) = find_by_selector(snapshot.accounts.as_slice(), selector) else { - return Err(RuntimeError::Config(format!( - "account selector `{selector}` did not match any local account" - ))); - }; + let account = resolve_selector_account(&manager, &snapshot, selector)?; return Ok(AccountResolution { source: AccountResolutionSource::InvocationOverride, - resolved_account: Some(account.clone()), + resolved_account: Some(account), default_account, }); } @@ -151,13 +153,9 @@ pub fn select_account( ) -> Result<AccountRecordView, RuntimeError> { let manager = account_manager(config)?; let snapshot = snapshot_from_manager(&manager)?; - let Some(account) = find_by_selector(snapshot.accounts.as_slice(), selector) else { - return Err(RuntimeError::Config(format!( - "account selector `{selector}` did not match any local account" - ))); - }; + let account = resolve_selector_account(&manager, &snapshot, selector)?; - manager.select_account(&account.record.account_id)?; + manager.set_default_account(&account.record.account_id)?; let snapshot = snapshot_from_manager(&manager)?; snapshot .accounts @@ -174,19 +172,19 @@ pub fn select_account( pub fn resolved_account_signing_status( config: &RuntimeConfig, -) -> Result<RadrootsNostrSelectedAccountStatus, RuntimeError> { +) -> Result<RadrootsNostrAccountStatus, RuntimeError> { let manager = account_manager(config)?; let resolution = resolve_account_resolution(config)?; let Some(account) = resolution.resolved_account else { - return Ok(RadrootsNostrSelectedAccountStatus::NotConfigured); + return Ok(RadrootsNostrAccountStatus::NotConfigured); }; Ok( match manager.get_signing_identity(&account.record.account_id)? { - Some(_) => RadrootsNostrSelectedAccountStatus::Ready { + Some(_) => RadrootsNostrAccountStatus::Ready { account: account.record.clone(), }, - None => RadrootsNostrSelectedAccountStatus::PublicOnly { + None => RadrootsNostrAccountStatus::PublicOnly { account: account.record.clone(), }, }, @@ -272,14 +270,14 @@ pub fn secret_backend_status(config: &RuntimeConfig) -> AccountSecretBackendStat fn snapshot_from_manager( manager: &RadrootsNostrAccountsManager, ) -> Result<AccountSnapshot, RuntimeError> { - let selected_account_id = manager.selected_account_id()?.map(|id| id.to_string()); + let default_account_id = manager.default_account_id()?.map(|id| id.to_string()); let accounts = manager .list_accounts()? .into_iter() .map(|record| AccountRecordView { - is_default: selected_account_id + is_default: default_account_id .as_deref() - .is_some_and(|selected| selected == record.account_id.as_str()), + .is_some_and(|default| default == record.account_id.as_str()), signer: "local", record, }) @@ -288,20 +286,38 @@ fn snapshot_from_manager( Ok(AccountSnapshot { accounts }) } -fn find_by_selector<'a>( - accounts: &'a [AccountRecordView], +fn resolve_selector_account( + manager: &RadrootsNostrAccountsManager, + snapshot: &AccountSnapshot, selector: &str, -) -> Option<&'a AccountRecordView> { +) -> Result<AccountRecordView, RuntimeError> { + let record = manager + .resolve_account_selector(selector) + .map_err(|error| selector_runtime_error(selector, error))?; + snapshot + .accounts + .iter() + .find(|account| account.record.account_id == record.account_id) + .cloned() + .ok_or_else(|| { + RuntimeError::Accounts(RadrootsNostrAccountsError::InvalidState( + "resolved account missing from snapshot".to_owned(), + )) + }) +} + +fn selector_runtime_error(selector: &str, error: RadrootsNostrAccountsError) -> RuntimeError { let normalized = selector.trim(); - if normalized.is_empty() { - return None; + match error { + RadrootsNostrAccountsError::InvalidAccountSelector(reason) => RuntimeError::Config(reason), + RadrootsNostrAccountsError::AccountNotFound(_) => RuntimeError::Config(format!( + "account selector `{normalized}` did not match any local account" + )), + RadrootsNostrAccountsError::AmbiguousAccountSelector(_) => RuntimeError::Config(format!( + "account selector `{normalized}` matched multiple local accounts; use account id or npub" + )), + other => RuntimeError::Accounts(other), } - - accounts.iter().find(|account| { - account.record.account_id.as_str() == normalized - || account.record.public_identity.public_key_npub == normalized - || account.record.label.as_deref() == Some(normalized) - }) } fn account_manager(config: &RuntimeConfig) -> Result<RadrootsNostrAccountsManager, RuntimeError> { diff --git a/src/runtime/signer.rs b/src/runtime/signer.rs @@ -7,7 +7,7 @@ use crate::runtime::config::{ CapabilityBindingConfig, CapabilityBindingTargetKind, RuntimeConfig, SIGNER_REMOTE_NIP46_CAPABILITY, SignerBackend, }; -use radroots_nostr_accounts::prelude::RadrootsNostrSelectedAccountStatus; +use radroots_nostr_accounts::prelude::RadrootsNostrAccountStatus; use radroots_nostr_signer::prelude::{ RadrootsNostrLocalSignerAvailability, RadrootsNostrLocalSignerCapability, RadrootsNostrSignerCapability, @@ -158,7 +158,7 @@ fn resolve_local_signer_status(config: &RuntimeConfig) -> SignerStatusView { let used_fallback = secret_backend.used_fallback; match crate::runtime::accounts::resolved_account_signing_status(config) { - Ok(RadrootsNostrSelectedAccountStatus::Ready { account }) => { + Ok(RadrootsNostrAccountStatus::Ready { account }) => { let capability = RadrootsNostrSignerCapability::LocalAccount( RadrootsNostrLocalSignerCapability::new( account.account_id.clone(), @@ -191,7 +191,7 @@ fn resolve_local_signer_status(config: &RuntimeConfig) -> SignerStatusView { myc: None, } } - Ok(RadrootsNostrSelectedAccountStatus::PublicOnly { account }) => SignerStatusView { + Ok(RadrootsNostrAccountStatus::PublicOnly { account }) => SignerStatusView { mode: config.signer.backend.as_str().to_owned(), state: "unconfigured".to_owned(), source: SHARED_ACCOUNT_STORE_SOURCE.to_owned(), @@ -213,7 +213,7 @@ fn resolve_local_signer_status(config: &RuntimeConfig) -> SignerStatusView { }), myc: None, }, - Ok(RadrootsNostrSelectedAccountStatus::NotConfigured) => SignerStatusView { + Ok(RadrootsNostrAccountStatus::NotConfigured) => SignerStatusView { mode: config.signer.backend.as_str().to_owned(), state: "unconfigured".to_owned(), source: SHARED_ACCOUNT_STORE_SOURCE.to_owned(), diff --git a/tests/identity_commands.rs b/tests/identity_commands.rs @@ -163,7 +163,7 @@ fn account_new_rejects_plaintext_fallback_backend() { } #[test] -fn account_whoami_json_reads_selected_account() { +fn account_whoami_json_reads_default_account() { let dir = tempdir().expect("tempdir"); let init = cli_command_in(dir.path()) @@ -200,6 +200,48 @@ fn account_whoami_json_reads_selected_account() { } #[test] +fn account_new_does_not_replace_existing_default_account() { + let dir = tempdir().expect("tempdir"); + let store_path = data_root(dir.path()).join("shared/accounts/store.json"); + + let first = cli_command_in(dir.path()) + .args(["--json", "account", "new"]) + .output() + .expect("run first account new"); + assert!(first.status.success()); + let first_json: Value = serde_json::from_slice(first.stdout.as_slice()).expect("first json"); + let first_id = first_json["account"]["id"] + .as_str() + .expect("first account id") + .to_owned(); + assert_eq!(first_json["account"]["is_default"], true); + + let second = cli_command_in(dir.path()) + .args(["--json", "account", "new"]) + .output() + .expect("run second account new"); + assert!(second.status.success()); + let second_json: Value = serde_json::from_slice(second.stdout.as_slice()).expect("second json"); + assert_eq!(second_json["account"]["is_default"], false); + + let store_json: Value = + serde_json::from_slice(fs::read(&store_path).expect("read store").as_slice()) + .expect("parse store"); + assert_eq!(store_json["default_account_id"], first_id); + + let whoami = cli_command_in(dir.path()) + .args(["--json", "account", "whoami"]) + .output() + .expect("run account whoami"); + assert!(whoami.status.success()); + let whoami_json: Value = serde_json::from_slice(whoami.stdout.as_slice()).expect("whoami json"); + assert_eq!( + whoami_json["account_resolution"]["resolved_account"]["id"], + first_id + ); +} + +#[test] fn account_whoami_json_reports_unconfigured_without_accounts() { let dir = tempdir().expect("tempdir"); @@ -307,3 +349,44 @@ fn account_use_selects_existing_account() { true ); } + +#[test] +fn account_use_rejects_ambiguous_label_selector() { + let dir = tempdir().expect("tempdir"); + let store_path = data_root(dir.path()).join("shared/accounts/store.json"); + + let first = cli_command_in(dir.path()) + .args(["--json", "account", "new"]) + .output() + .expect("run first account new"); + assert!(first.status.success()); + + let second = cli_command_in(dir.path()) + .args(["--json", "account", "new"]) + .output() + .expect("run second account new"); + assert!(second.status.success()); + + let mut store_json: Value = + serde_json::from_slice(fs::read(&store_path).expect("read store").as_slice()) + .expect("parse store"); + let accounts = store_json["accounts"] + .as_array_mut() + .expect("accounts array"); + accounts[0]["label"] = Value::from("shared"); + accounts[1]["label"] = Value::from("shared"); + fs::write( + &store_path, + serde_json::to_vec_pretty(&store_json).expect("serialize store"), + ) + .expect("write store"); + + let output = cli_command_in(dir.path()) + .args(["account", "use", "shared"]) + .output() + .expect("run account use"); + + assert_eq!(output.status.code(), Some(2)); + let stderr = String::from_utf8(output.stderr).expect("utf8 stderr"); + assert!(stderr.contains("matched multiple local accounts")); +} diff --git a/tests/workflow.rs b/tests/workflow.rs @@ -155,7 +155,7 @@ fn status_points_to_account_selection_when_accounts_exist_without_default() { let mut store_json: Value = serde_json::from_slice(fs::read(&store_path).expect("read store").as_slice()) .expect("parse store"); - store_json["selected_account_id"] = Value::Null; + store_json["default_account_id"] = Value::Null; fs::write( &store_path, serde_json::to_vec_pretty(&store_json).expect("serialize store"),