cli

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

commit 67da82d341870391792bcc700a34049edd7aaace
parent ee607e0bbe66361a70c32146c785870a9bd912ca
Author: triesap <tyson@radroots.org>
Date:   Mon, 20 Apr 2026 17:20:55 +0000

add typed account resolution surfaces

Diffstat:
Msrc/commands/doctor.rs | 48+++++++++++++++++++++++++++++++++++++-----------
Msrc/commands/identity.rs | 23+++++++++++------------
Msrc/domain/runtime.rs | 24++++++++++++++++--------
Msrc/render/mod.rs | 68+++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------
Msrc/runtime/accounts.rs | 137++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------------
Msrc/runtime/network.rs | 5++---
Msrc/runtime/signer.rs | 57++++++++++++++++++++++++++++++++++++++++++++-------------
Msrc/runtime/workflow.rs | 15+++++++++------
Mtests/doctor.rs | 2++
Mtests/identity_commands.rs | 41++++++++++++++++++++++++++++++++---------
Mtests/myc_status.rs | 4++--
Mtests/relay_net.rs | 6+++++-
Mtests/signer_status.rs | 12+++++++++---
Mtests/workflow.rs | 7++++---
14 files changed, 336 insertions(+), 113 deletions(-)

diff --git a/src/commands/doctor.rs b/src/commands/doctor.rs @@ -46,7 +46,8 @@ pub fn report( ) -> Result<CommandOutput, RuntimeError> { let mut checks = Vec::new(); checks.push(config_check(config)); - checks.push(account_check(config)?); + let account_resolution = crate::runtime::accounts::resolve_account_resolution(config)?; + checks.push(account_check(config, &account_resolution)?); checks.push(relay_check(config)); let signer = resolve_signer_status(config); @@ -72,6 +73,7 @@ pub fn report( let view = DoctorView { ok: severity == DoctorSeverity::Ok, state: severity.status().to_owned(), + account_resolution: crate::runtime::accounts::account_resolution_view(&account_resolution), checks: checks.into_iter().map(|check| check.view).collect(), source: doctor_source(config), actions, @@ -112,7 +114,10 @@ fn config_check(config: &RuntimeConfig) -> EvaluatedCheck { } } -fn account_check(config: &RuntimeConfig) -> Result<EvaluatedCheck, RuntimeError> { +fn account_check( + config: &RuntimeConfig, + account_resolution: &crate::runtime::accounts::AccountResolution, +) -> Result<EvaluatedCheck, RuntimeError> { let snapshot = crate::runtime::accounts::snapshot(config)?; if snapshot.accounts.is_empty() { return Ok(EvaluatedCheck { @@ -129,12 +134,36 @@ fn account_check(config: &RuntimeConfig) -> Result<EvaluatedCheck, RuntimeError> }); } - match crate::runtime::accounts::resolve_account(config)? { + match account_resolution.resolved_account.as_ref() { Some(account) => { - let detail = if account.selected { - format!("{} selected", account.record.account_id) - } else { - format!("{} resolved by selector", account.record.account_id) + let detail = match account_resolution.source { + crate::runtime::accounts::AccountResolutionSource::InvocationOverride => { + match account_resolution.default_account.as_ref() { + Some(default) if default.record.account_id != account.record.account_id => { + format!( + "resolved account {} via invocation override; default account {} remains stored", + account.record.account_id, default.record.account_id + ) + } + Some(default) => format!( + "resolved account {} via invocation override; default account {} is also stored", + account.record.account_id, default.record.account_id + ), + None => format!( + "resolved account {} via invocation override; no default account is stored", + account.record.account_id + ), + } + } + crate::runtime::accounts::AccountResolutionSource::DefaultAccount => { + format!( + "resolved account {} via default account", + account.record.account_id + ) + } + crate::runtime::accounts::AccountResolutionSource::None => { + format!("resolved account {}", account.record.account_id) + } }; Ok(EvaluatedCheck { severity: DoctorSeverity::Ok, @@ -151,10 +180,7 @@ fn account_check(config: &RuntimeConfig) -> Result<EvaluatedCheck, RuntimeError> view: DoctorCheckView { name: "account".to_owned(), status: "warn".to_owned(), - detail: format!( - "accounts exist but no local account is selected in {}", - config.account.store_path.display() - ), + detail: crate::runtime::accounts::unresolved_account_reason(config)?, }, action: Some("radroots account ls"), }), diff --git a/src/commands/identity.rs b/src/commands/identity.rs @@ -4,8 +4,9 @@ use crate::domain::runtime::{ }; use crate::runtime::RuntimeError; use crate::runtime::accounts::{ - AccountCreateMode, AccountRecordView, SHARED_ACCOUNT_STORE_SOURCE, - create_or_migrate_selected_account, resolve_account, select_account, snapshot, + AccountCreateMode, AccountRecordView, SHARED_ACCOUNT_STORE_SOURCE, account_resolution_view, + account_summary_view, create_or_migrate_selected_account, resolve_account_resolution, + select_account, snapshot, unresolved_account_reason, }; use crate::runtime::config::RuntimeConfig; @@ -33,24 +34,22 @@ pub fn init(config: &RuntimeConfig) -> Result<AccountNewView, RuntimeError> { } pub fn show(config: &RuntimeConfig) -> Result<CommandOutput, RuntimeError> { - let view = match resolve_account(config)? { + let resolution = resolve_account_resolution(config)?; + let view = match resolution.resolved_account.as_ref() { Some(account) => AccountWhoamiView { state: "ready".to_owned(), source: SHARED_ACCOUNT_STORE_SOURCE.to_owned(), reason: None, + account_resolution: account_resolution_view(&resolution), public_identity: Some(IdentityPublicView::from_public_identity( &account.record.public_identity, )), - account: Some(account_summary(&account)), }, None => AccountWhoamiView { state: "unconfigured".to_owned(), source: SHARED_ACCOUNT_STORE_SOURCE.to_owned(), - reason: Some(format!( - "no local account is selected in {}", - config.account.store_path.display() - )), - account: None, + reason: Some(unresolved_account_reason(config)?), + account_resolution: account_resolution_view(&resolution), public_identity: None, }, }; @@ -97,13 +96,13 @@ pub fn list(config: &RuntimeConfig) -> Result<CommandOutput, RuntimeError> { pub fn use_account(config: &RuntimeConfig, selector: &str) -> Result<AccountUseView, RuntimeError> { let account = select_account(config, selector)?; Ok(AccountUseView { - state: "active".to_owned(), + state: "default".to_owned(), source: SHARED_ACCOUNT_STORE_SOURCE.to_owned(), - active_account_id: account.record.account_id.to_string(), + default_account_id: account.record.account_id.to_string(), account: account_summary(&account), }) } fn account_summary(account: &AccountRecordView) -> AccountSummaryView { - AccountSummaryView::from_account_record(&account.record, account.signer, account.selected) + account_summary_view(account) } diff --git a/src/domain/runtime.rs b/src/domain/runtime.rs @@ -506,6 +506,7 @@ pub struct CapabilityBindingRuntimeView { pub struct DoctorView { pub ok: bool, pub state: String, + pub account_resolution: AccountResolutionView, pub checks: Vec<DoctorCheckView>, pub source: String, #[serde(default, skip_serializing_if = "Vec::is_empty")] @@ -561,13 +562,21 @@ impl AccountSummaryView { } #[derive(Debug, Clone, Serialize)] +pub struct AccountResolutionView { + pub source: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub resolved_account: Option<AccountSummaryView>, + #[serde(skip_serializing_if = "Option::is_none")] + pub default_account: Option<AccountSummaryView>, +} + +#[derive(Debug, Clone, Serialize)] pub struct AccountWhoamiView { pub state: String, pub source: String, #[serde(skip_serializing_if = "Option::is_none")] pub reason: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub account: Option<AccountSummaryView>, + pub account_resolution: AccountResolutionView, #[serde(skip_serializing_if = "Option::is_none")] pub public_identity: Option<IdentityPublicView>, } @@ -595,7 +604,7 @@ pub struct AccountNewView { pub struct AccountUseView { pub state: String, pub source: String, - pub active_account_id: String, + pub default_account_id: String, pub account: AccountSummaryView, } @@ -693,8 +702,7 @@ impl SetupView { pub struct StatusView { pub state: String, pub source: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub selected_account_id: Option<String>, + pub account_resolution: AccountResolutionView, pub local_state: String, pub local_root: String, pub relay_state: String, @@ -1912,8 +1920,7 @@ pub struct NetStatusView { pub relay_count: usize, pub publish_policy: String, pub signer_mode: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub active_account_id: Option<String>, + pub account_resolution: AccountResolutionView, #[serde(skip_serializing_if = "Option::is_none")] pub reason: Option<String>, #[serde(default, skip_serializing_if = "Vec::is_empty")] @@ -2093,7 +2100,8 @@ pub struct SignerStatusView { pub state: String, pub source: String, #[serde(skip_serializing_if = "Option::is_none")] - pub account_id: Option<String>, + pub signer_account_id: Option<String>, + pub account_resolution: AccountResolutionView, #[serde(skip_serializing_if = "Option::is_none")] pub reason: Option<String>, pub binding: SignerBindingStatusView, diff --git a/src/render/mod.rs b/src/render/mod.rs @@ -226,10 +226,12 @@ fn render_human_view_to( ("mode", view.mode.as_str()), ("status", view.state.as_str()), ]; - if let Some(account_id) = &view.account_id { - signer_rows.push(("account id", account_id.as_str())); + if let Some(account_id) = &view.signer_account_id { + signer_rows.push(("signer account id", account_id.as_str())); } render_pairs(stdout, "signer", signer_rows.as_slice())?; + writeln!(stdout)?; + render_account_resolution(stdout, &view.account_resolution)?; if let Some(reason) = &view.reason { writeln!(stdout, "reason: {reason}")?; } @@ -842,7 +844,7 @@ fn render_account_list(stdout: &mut dyn Write, view: &AccountListView) -> Result ("Account", account.id.clone()), ("Signer", humanize_machine_label(account.signer.as_str())), ( - "Selected", + "Default", if account.is_default { "Yes".to_owned() } else { @@ -892,7 +894,7 @@ fn render_account_use( stdout: &mut dyn Write, view: &crate::domain::runtime::AccountUseView, ) -> Result<(), RuntimeError> { - writeln!(stdout, "Account selected")?; + writeln!(stdout, "Default account updated")?; writeln!(stdout)?; render_account_section(stdout, &view.account) } @@ -903,11 +905,13 @@ fn render_account_whoami( ) -> Result<(), RuntimeError> { match view.state.as_str() { "ready" => { - writeln!(stdout, "Selected account")?; + writeln!(stdout, "Resolved account")?; writeln!(stdout)?; - if let Some(account) = &view.account { + if let Some(account) = &view.account_resolution.resolved_account { render_account_section(stdout, account)?; } + writeln!(stdout)?; + render_account_resolution(stdout, &view.account_resolution)?; if let Some(identity) = &view.public_identity { writeln!(stdout)?; writeln!(stdout, "Identity")?; @@ -921,7 +925,9 @@ fn render_account_whoami( writeln!(stdout, "{reason}")?; } writeln!(stdout)?; - render_item_section(stdout, "Missing", &["Selected account".to_owned()])?; + render_account_resolution(stdout, &view.account_resolution)?; + writeln!(stdout)?; + render_item_section(stdout, "Missing", &["Resolved account".to_owned()])?; writeln!(stdout)?; render_item_section(stdout, "Next", &["radroots account create".to_owned()])?; } @@ -938,6 +944,29 @@ fn render_account_section( push_row(&mut rows, "Name", account.display_name.clone()); rows.push(("Account", account.id.clone())); rows.push(("Signer", humanize_machine_label(account.signer.as_str()))); + rows.push(( + "Default", + if account.is_default { + "Yes".to_owned() + } else { + "No".to_owned() + }, + )); + render_field_rows(stdout, rows.as_slice()) +} + +fn render_account_resolution( + stdout: &mut dyn Write, + resolution: &crate::domain::runtime::AccountResolutionView, +) -> Result<(), RuntimeError> { + writeln!(stdout, "Account resolution")?; + let mut rows = vec![("Source", humanize_machine_label(resolution.source.as_str()))]; + if let Some(account) = &resolution.resolved_account { + rows.push(("Resolved account", account.id.clone())); + } + if let Some(account) = &resolution.default_account { + rows.push(("Default account", account.id.clone())); + } render_field_rows(stdout, rows.as_slice()) } @@ -1483,6 +1512,8 @@ fn render_doctor(stdout: &mut dyn Write, view: &DoctorView) -> Result<(), Runtim } render_item_section(stdout, "Next", &view.actions)?; } + writeln!(stdout)?; + render_account_resolution(stdout, &view.account_resolution)?; Ok(()) } @@ -2907,17 +2938,16 @@ fn render_net_status(stdout: &mut dyn Write, view: &NetStatusView) -> Result<(), }, )?; let relay_count = view.relay_count.to_string(); - let mut rows = vec![ + let rows = vec![ ("status", view.state.as_str()), ("session", view.session.as_str()), ("relays configured", relay_count.as_str()), ("publish policy", view.publish_policy.as_str()), ("signer mode", view.signer_mode.as_str()), ]; - if let Some(account_id) = &view.active_account_id { - rows.push(("active account id", account_id.as_str())); - } render_pairs(stdout, "network", rows.as_slice())?; + writeln!(stdout)?; + render_account_resolution(stdout, &view.account_resolution)?; if let Some(reason) = &view.reason { writeln!(stdout, "reason: {reason}")?; } @@ -3202,7 +3232,7 @@ fn render_farm_setup(stdout: &mut dyn Write, view: &FarmSetupView) -> Result<(), "unconfigured" => { writeln!(stdout, "Not ready yet")?; writeln!(stdout)?; - render_item_section(stdout, "Missing", &["Selected account".to_owned()])?; + render_item_section(stdout, "Missing", &["Resolved account".to_owned()])?; if !view.actions.is_empty() { writeln!(stdout)?; render_item_section(stdout, "Next", &view.actions)?; @@ -3580,7 +3610,9 @@ fn render_status_summary(stdout: &mut dyn Write, view: &StatusView) -> Result<() &view.ready, &view.needs_attention, &view.next, - ) + )?; + writeln!(stdout)?; + render_account_resolution(stdout, &view.account_resolution) } fn render_checklist_summary( @@ -4468,6 +4500,11 @@ mod tests { let output = CommandOutput::unconfigured(CommandView::Doctor(DoctorView { ok: false, state: "warn".to_owned(), + account_resolution: crate::domain::runtime::AccountResolutionView { + source: "none".to_owned(), + resolved_account: None, + default_account: None, + }, checks: vec![ DoctorCheckView { name: "config".to_owned(), @@ -4501,6 +4538,11 @@ mod tests { let output = CommandOutput::success(CommandView::Doctor(DoctorView { ok: true, state: "ok".to_owned(), + account_resolution: crate::domain::runtime::AccountResolutionView { + source: "default_account".to_owned(), + resolved_account: None, + default_account: None, + }, checks: vec![DoctorCheckView { name: "config".to_owned(), status: "ok".to_owned(), diff --git a/src/runtime/accounts.rs b/src/runtime/accounts.rs @@ -7,6 +7,7 @@ use radroots_secret_vault::{ RadrootsSecretVaultError, RadrootsSecretVaultOsKeyring, }; +use crate::domain::runtime::{AccountResolutionView, AccountSummaryView}; use crate::runtime::RuntimeError; use crate::runtime::config::RuntimeConfig; @@ -23,7 +24,7 @@ pub struct AccountSnapshot { #[derive(Debug, Clone)] pub struct AccountRecordView { pub record: RadrootsNostrAccountRecord, - pub selected: bool, + pub is_default: bool, pub signer: &'static str, } @@ -49,6 +50,30 @@ pub struct AccountCreateResult { pub account: AccountRecordView, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AccountResolutionSource { + InvocationOverride, + DefaultAccount, + None, +} + +impl AccountResolutionSource { + pub fn as_str(self) -> &'static str { + match self { + Self::InvocationOverride => "invocation_override", + Self::DefaultAccount => "default_account", + Self::None => "none", + } + } +} + +#[derive(Debug, Clone)] +pub struct AccountResolution { + pub source: AccountResolutionSource, + pub resolved_account: Option<AccountRecordView>, + pub default_account: Option<AccountRecordView>, +} + pub fn create_or_migrate_selected_account( config: &RuntimeConfig, ) -> Result<AccountCreateResult, RuntimeError> { @@ -66,11 +91,11 @@ pub fn create_or_migrate_selected_account( let Some(account) = snapshot .accounts .into_iter() - .find(|account| account.selected) + .find(|account| account.is_default) else { return Err(RuntimeError::Accounts( radroots_nostr_accounts::prelude::RadrootsNostrAccountsError::InvalidState( - "selected account missing after account create".to_owned(), + "default account missing after account create".to_owned(), ), )); }; @@ -84,20 +109,40 @@ pub fn snapshot(config: &RuntimeConfig) -> Result<AccountSnapshot, RuntimeError> } pub fn resolve_account(config: &RuntimeConfig) -> Result<Option<AccountRecordView>, RuntimeError> { + Ok(resolve_account_resolution(config)?.resolved_account) +} + +pub fn resolve_account_resolution( + config: &RuntimeConfig, +) -> Result<AccountResolution, RuntimeError> { let snapshot = snapshot(config)?; + 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" ))); }; - return Ok(Some(account.clone())); + return Ok(AccountResolution { + source: AccountResolutionSource::InvocationOverride, + resolved_account: Some(account.clone()), + default_account, + }); } - Ok(snapshot - .accounts - .into_iter() - .find(|account| account.selected)) + Ok(AccountResolution { + source: if default_account.is_some() { + AccountResolutionSource::DefaultAccount + } else { + AccountResolutionSource::None + }, + resolved_account: default_account.clone(), + default_account, + }) } pub fn select_account( @@ -121,37 +166,72 @@ pub fn select_account( .ok_or_else(|| { RuntimeError::Accounts( radroots_nostr_accounts::prelude::RadrootsNostrAccountsError::InvalidState( - "selected account missing after account use".to_owned(), + "default account missing after account use".to_owned(), ), ) }) } -pub fn selected_account_status( +pub fn resolved_account_signing_status( config: &RuntimeConfig, ) -> Result<RadrootsNostrSelectedAccountStatus, RuntimeError> { let manager = account_manager(config)?; - if let Some(selector) = config.account.selector.as_deref() { - 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 resolution = resolve_account_resolution(config)?; + let Some(account) = resolution.resolved_account else { + return Ok(RadrootsNostrSelectedAccountStatus::NotConfigured); + }; - return Ok( - match manager.get_signing_identity(&account.record.account_id)? { - Some(_) => RadrootsNostrSelectedAccountStatus::Ready { - account: account.record.clone(), - }, - None => RadrootsNostrSelectedAccountStatus::PublicOnly { - account: account.record.clone(), - }, + Ok( + match manager.get_signing_identity(&account.record.account_id)? { + Some(_) => RadrootsNostrSelectedAccountStatus::Ready { + account: account.record.clone(), + }, + None => RadrootsNostrSelectedAccountStatus::PublicOnly { + account: account.record.clone(), }, - ); + }, + ) +} + +pub fn account_summary_view(account: &AccountRecordView) -> AccountSummaryView { + AccountSummaryView::from_account_record(&account.record, account.signer, account.is_default) +} + +pub fn account_resolution_view(resolution: &AccountResolution) -> AccountResolutionView { + AccountResolutionView { + source: resolution.source.as_str().to_owned(), + resolved_account: resolution + .resolved_account + .as_ref() + .map(account_summary_view), + default_account: resolution + .default_account + .as_ref() + .map(account_summary_view), + } +} + +pub fn empty_account_resolution_view() -> AccountResolutionView { + AccountResolutionView { + source: AccountResolutionSource::None.as_str().to_owned(), + resolved_account: None, + default_account: None, } +} - Ok(manager.selected_account_status()?) +pub fn unresolved_account_reason(config: &RuntimeConfig) -> Result<String, RuntimeError> { + let snapshot = snapshot(config)?; + Ok(if snapshot.accounts.is_empty() { + format!( + "no local accounts found in {}", + config.account.store_path.display() + ) + } else { + format!( + "accounts exist in {} but no default account is configured and no invocation override was provided", + config.account.store_path.display() + ) + }) } pub fn secret_backend_status(config: &RuntimeConfig) -> AccountSecretBackendStatus { @@ -197,7 +277,7 @@ fn snapshot_from_manager( .list_accounts()? .into_iter() .map(|record| AccountRecordView { - selected: selected_account_id + is_default: selected_account_id .as_deref() .is_some_and(|selected| selected == record.account_id.as_str()), signer: "local", @@ -314,7 +394,6 @@ enum SecretBackendResolutionError { #[cfg(test)] mod tests { - use super::*; use radroots_protected_store::RadrootsProtectedFileSecretVault; use radroots_secret_vault::RadrootsSecretVault; use std::fs; diff --git a/src/runtime/network.rs b/src/runtime/network.rs @@ -36,8 +36,7 @@ pub fn relay_list(config: &RuntimeConfig) -> RelayListView { } pub fn net_status(config: &RuntimeConfig) -> Result<NetStatusView, RuntimeError> { - let active_account_id = - accounts::resolve_account(config)?.map(|account| account.record.account_id.to_string()); + let account_resolution = accounts::resolve_account_resolution(config)?; let relay_count = config.relay.urls.len(); let configured = relay_count > 0; @@ -56,7 +55,7 @@ pub fn net_status(config: &RuntimeConfig) -> Result<NetStatusView, RuntimeError> relay_count, publish_policy: config.relay.publish_policy.as_str().to_owned(), signer_mode: config.signer.backend.as_str().to_owned(), - active_account_id, + account_resolution: accounts::account_resolution_view(&account_resolution), reason: (!configured) .then_some("no relays are configured for this operator session".to_owned()), actions: relay_actions(config), diff --git a/src/runtime/signer.rs b/src/runtime/signer.rs @@ -2,7 +2,7 @@ use crate::domain::runtime::{ IdentityPublicView, LocalSignerStatusView, MycRemoteSessionView, MycStatusView, SignerBindingStatusView, SignerStatusView, }; -use crate::runtime::accounts::SHARED_ACCOUNT_STORE_SOURCE; +use crate::runtime::accounts::{SHARED_ACCOUNT_STORE_SOURCE, empty_account_resolution_view}; use crate::runtime::config::{ CapabilityBindingConfig, CapabilityBindingTargetKind, RuntimeConfig, SIGNER_REMOTE_NIP46_CAPABILITY, SignerBackend, @@ -100,13 +100,37 @@ pub fn resolve_actor_write_authority( } fn resolve_local_signer_status(config: &RuntimeConfig) -> SignerStatusView { + let (account_resolution, resolved_account_id) = + match crate::runtime::accounts::resolve_account_resolution(config) { + Ok(resolution) => ( + crate::runtime::accounts::account_resolution_view(&resolution), + resolution + .resolved_account + .as_ref() + .map(|account| account.record.account_id.to_string()), + ), + Err(error) => { + return SignerStatusView { + mode: config.signer.backend.as_str().to_owned(), + state: "error".to_owned(), + source: SHARED_ACCOUNT_STORE_SOURCE.to_owned(), + signer_account_id: None, + account_resolution: empty_account_resolution_view(), + reason: Some(error.to_string()), + binding: disabled_binding_status(), + local: None, + myc: None, + }; + } + }; let secret_backend = crate::runtime::accounts::secret_backend_status(config); if secret_backend.state == "unavailable" { return SignerStatusView { mode: config.signer.backend.as_str().to_owned(), state: "unavailable".to_owned(), source: SHARED_ACCOUNT_STORE_SOURCE.to_owned(), - account_id: None, + signer_account_id: resolved_account_id.clone(), + account_resolution: account_resolution.clone(), reason: secret_backend.reason, binding: disabled_binding_status(), local: None, @@ -119,7 +143,8 @@ fn resolve_local_signer_status(config: &RuntimeConfig) -> SignerStatusView { mode: config.signer.backend.as_str().to_owned(), state: "error".to_owned(), source: SHARED_ACCOUNT_STORE_SOURCE.to_owned(), - account_id: None, + signer_account_id: resolved_account_id.clone(), + account_resolution: account_resolution.clone(), reason: secret_backend.reason, binding: disabled_binding_status(), local: None, @@ -132,7 +157,7 @@ fn resolve_local_signer_status(config: &RuntimeConfig) -> SignerStatusView { .unwrap_or_else(|| "unknown".to_owned()); let used_fallback = secret_backend.used_fallback; - match crate::runtime::accounts::selected_account_status(config) { + match crate::runtime::accounts::resolved_account_signing_status(config) { Ok(RadrootsNostrSelectedAccountStatus::Ready { account }) => { let capability = RadrootsNostrSignerCapability::LocalAccount( RadrootsNostrLocalSignerCapability::new( @@ -149,7 +174,8 @@ fn resolve_local_signer_status(config: &RuntimeConfig) -> SignerStatusView { mode: config.signer.backend.as_str().to_owned(), state: "ready".to_owned(), source: SHARED_ACCOUNT_STORE_SOURCE.to_owned(), - account_id: Some(local.account_id.to_string()), + signer_account_id: Some(local.account_id.to_string()), + account_resolution: account_resolution.clone(), reason: None, binding: disabled_binding_status(), local: Some(LocalSignerStatusView { @@ -169,7 +195,8 @@ fn resolve_local_signer_status(config: &RuntimeConfig) -> SignerStatusView { mode: config.signer.backend.as_str().to_owned(), state: "unconfigured".to_owned(), source: SHARED_ACCOUNT_STORE_SOURCE.to_owned(), - account_id: Some(account.account_id.to_string()), + signer_account_id: Some(account.account_id.to_string()), + account_resolution: account_resolution.clone(), reason: Some(format!( "local account {} is present but not secret-backed", account.account_id @@ -190,11 +217,9 @@ fn resolve_local_signer_status(config: &RuntimeConfig) -> SignerStatusView { mode: config.signer.backend.as_str().to_owned(), state: "unconfigured".to_owned(), source: SHARED_ACCOUNT_STORE_SOURCE.to_owned(), - account_id: None, - reason: Some(format!( - "no local account is selected in {}", - config.account.store_path.display() - )), + signer_account_id: None, + account_resolution: account_resolution.clone(), + reason: crate::runtime::accounts::unresolved_account_reason(config).ok(), binding: disabled_binding_status(), local: None, myc: None, @@ -203,7 +228,8 @@ fn resolve_local_signer_status(config: &RuntimeConfig) -> SignerStatusView { mode: config.signer.backend.as_str().to_owned(), state: "error".to_owned(), source: SHARED_ACCOUNT_STORE_SOURCE.to_owned(), - account_id: None, + signer_account_id: resolved_account_id, + account_resolution, reason: Some(error.to_string()), binding: disabled_binding_status(), local: None, @@ -213,6 +239,10 @@ fn resolve_local_signer_status(config: &RuntimeConfig) -> SignerStatusView { } fn resolve_myc_signer_status(config: &RuntimeConfig) -> SignerStatusView { + let account_resolution = match crate::runtime::accounts::resolve_account_resolution(config) { + Ok(resolution) => crate::runtime::accounts::account_resolution_view(&resolution), + Err(_) => empty_account_resolution_view(), + }; let myc = crate::runtime::myc::resolve_status(&config.myc); let resolution = resolve_myc_binding(config, &myc); let binding = resolution.view; @@ -225,7 +255,8 @@ fn resolve_myc_signer_status(config: &RuntimeConfig) -> SignerStatusView { } else { myc.source.clone() }, - account_id: resolution.resolved_account_id, + signer_account_id: resolution.resolved_account_id, + account_resolution, reason: if myc.state == "ready" { binding.reason.clone() } else { diff --git a/src/runtime/workflow.rs b/src/runtime/workflow.rs @@ -66,7 +66,7 @@ pub fn setup(config: &RuntimeConfig, role: SetupRoleArg) -> Result<SetupView, Ru } pub fn status(config: &RuntimeConfig) -> Result<StatusView, RuntimeError> { - let account = accounts::resolve_account(config)?; + let account_resolution = accounts::resolve_account_resolution(config)?; let local_status = local::status(config)?; let farm = inspect_farm(config)?; let relay_configured = relay_configured(config); @@ -77,11 +77,11 @@ pub fn status(config: &RuntimeConfig) -> Result<StatusView, RuntimeError> { let mut next = Vec::new(); let mut state = "ready"; - if account.is_some() { - ready.push("Selected account".to_owned()); + if account_resolution.resolved_account.is_some() { + ready.push("Resolved account".to_owned()); } else { state = "unconfigured"; - needs_attention.push("Selected account".to_owned()); + needs_attention.push("Resolved account".to_owned()); } if local_status.state == "ready" { @@ -111,7 +111,10 @@ pub fn status(config: &RuntimeConfig) -> Result<StatusView, RuntimeError> { } else { push_next(&mut next, Some("radroots setup buyer")); push_next(&mut next, Some("radroots setup seller")); - if account.is_some() && local_status.state == "ready" && !relay_configured { + if account_resolution.resolved_account.is_some() + && local_status.state == "ready" + && !relay_configured + { next.clear(); push_next(&mut next, Some(RELAY_SETUP_ACTION)); push_next(&mut next, Some("radroots status")); @@ -121,7 +124,7 @@ pub fn status(config: &RuntimeConfig) -> Result<StatusView, RuntimeError> { Ok(StatusView { state: state.to_owned(), source: WORKFLOW_SOURCE.to_owned(), - selected_account_id: account.map(|account| account.record.account_id.to_string()), + account_resolution: accounts::account_resolution_view(&account_resolution), local_state: local_status.state, local_root: local_status.local_root, relay_state: relay_state(config).to_owned(), diff --git a/tests/doctor.rs b/tests/doctor.rs @@ -55,6 +55,7 @@ fn doctor_reports_unconfigured_local_bootstrap_state() { assert_eq!(json["checks"][0]["status"], "ok"); assert_eq!(json["checks"][1]["name"], "account"); assert_eq!(json["checks"][1]["status"], "warn"); + assert_eq!(json["account_resolution"]["source"], "none"); assert_eq!(json["checks"][2]["name"], "relays"); assert_eq!(json["checks"][2]["status"], "warn"); assert_eq!(json["checks"][3]["name"], "signer"); @@ -87,6 +88,7 @@ fn doctor_reports_warn_for_ready_local_bootstrap_without_workflow_provider() { assert_eq!(json["state"], "warn"); assert_eq!(json["checks"][1]["name"], "account"); assert_eq!(json["checks"][1]["status"], "ok"); + assert_eq!(json["account_resolution"]["source"], "default_account"); assert_eq!(json["checks"][2]["name"], "relays"); assert_eq!(json["checks"][2]["status"], "ok"); assert_eq!(json["checks"][3]["name"], "signer"); diff --git a/tests/identity_commands.rs b/tests/identity_commands.rs @@ -182,9 +182,20 @@ fn account_whoami_json_reads_selected_account() { let json: Value = serde_json::from_str(stdout.as_str()).expect("json output"); assert_eq!(json["state"], "ready"); assert_eq!(json["source"], "shared account store · local first"); - assert!(json["account"]["id"].is_string()); - assert_eq!(json["account"]["signer"], "local"); - assert_eq!(json["account"]["is_default"], true); + assert_eq!(json["account_resolution"]["source"], "default_account"); + assert!(json["account_resolution"]["resolved_account"]["id"].is_string()); + assert_eq!( + json["account_resolution"]["resolved_account"]["signer"], + "local" + ); + assert_eq!( + json["account_resolution"]["resolved_account"]["is_default"], + true + ); + assert_eq!( + json["account_resolution"]["default_account"]["id"], + json["account_resolution"]["resolved_account"]["id"] + ); assert!(json["public_identity"]["id"].is_string()); } @@ -202,12 +213,14 @@ fn account_whoami_json_reports_unconfigured_without_accounts() { let stdout = String::from_utf8(output.stdout).expect("utf8 stdout"); let json: Value = serde_json::from_str(stdout.as_str()).expect("json output"); assert_eq!(json["state"], "unconfigured"); - assert_eq!(json["account"], Value::Null); + assert_eq!(json["account_resolution"]["source"], "none"); + assert_eq!(json["account_resolution"]["resolved_account"], Value::Null); + assert_eq!(json["account_resolution"]["default_account"], Value::Null); assert_eq!(json["public_identity"], Value::Null); assert!( json["reason"] .as_str() - .is_some_and(|value| value.contains("no local account is selected")) + .is_some_and(|value| value.contains("no local accounts found")) ); } @@ -270,8 +283,8 @@ fn account_use_selects_existing_account() { assert!(use_output.status.success()); let use_json: Value = serde_json::from_slice(use_output.stdout.as_slice()).expect("account use json"); - assert_eq!(use_json["state"], "active"); - assert_eq!(use_json["active_account_id"], first_id); + assert_eq!(use_json["state"], "default"); + assert_eq!(use_json["default_account_id"], first_id); assert_eq!(use_json["account"]["is_default"], true); let whoami = cli_command_in(dir.path()) @@ -281,6 +294,16 @@ fn account_use_selects_existing_account() { assert!(whoami.status.success()); let whoami_json: Value = serde_json::from_slice(whoami.stdout.as_slice()).expect("account whoami json"); - assert_eq!(whoami_json["account"]["id"], first_id); - assert_eq!(whoami_json["account"]["is_default"], true); + assert_eq!( + whoami_json["account_resolution"]["source"], + "default_account" + ); + assert_eq!( + whoami_json["account_resolution"]["resolved_account"]["id"], + first_id + ); + assert_eq!( + whoami_json["account_resolution"]["resolved_account"]["is_default"], + true + ); } diff --git a/tests/myc_status.rs b/tests/myc_status.rs @@ -175,7 +175,7 @@ fn signer_status_reports_degraded_myc_backend_as_external_unavailable() { assert_eq!(json["mode"], "myc"); assert_eq!(json["state"], "degraded"); assert_eq!(json["source"], "myc status command · local first"); - assert_eq!(json["account_id"], Value::Null); + assert_eq!(json["signer_account_id"], Value::Null); assert_eq!(json["myc"]["state"], "degraded"); assert_eq!(json["myc"]["service_status"], "degraded"); assert_eq!(json["binding"]["state"], "unconfigured"); @@ -235,7 +235,7 @@ signer_session_ref = "{signer_session_ref}" assert_eq!(json["mode"], "myc"); assert_eq!(json["state"], "ready"); assert_eq!(json["source"], "workspace config [[capability_binding]]"); - assert_eq!(json["account_id"], managed_account_ref); + assert_eq!(json["signer_account_id"], managed_account_ref); assert_eq!(json["binding"]["state"], "ready"); assert_eq!( json["binding"]["resolved_signer_session_id"], diff --git a/tests/relay_net.rs b/tests/relay_net.rs @@ -142,6 +142,10 @@ fn net_status_json_reports_effective_network_configuration() { assert_eq!(json["relay_count"], 2); assert_eq!(json["publish_policy"], "any"); assert_eq!(json["signer_mode"], "local"); - assert_eq!(json["active_account_id"], account_id); + assert_eq!(json["account_resolution"]["source"], "default_account"); + assert_eq!( + json["account_resolution"]["resolved_account"]["id"], + account_id + ); assert_eq!(json["source"], "cli flags · local first"); } diff --git a/tests/signer_status.rs b/tests/signer_status.rs @@ -69,7 +69,8 @@ fn signer_status_reports_local_ready_when_account_exists() { assert_eq!(json["mode"], "local"); assert_eq!(json["state"], "ready"); assert_eq!(json["source"], "shared account store · local first"); - assert_eq!(json["account_id"], json["local"]["account_id"]); + assert_eq!(json["signer_account_id"], json["local"]["account_id"]); + assert_eq!(json["account_resolution"]["source"], "default_account"); assert_eq!(json["reason"], Value::Null); assert_eq!(json["binding"]["state"], "disabled"); assert_eq!(json["binding"]["source"], "independent local signer mode"); @@ -97,7 +98,7 @@ fn signer_status_reports_local_unconfigured_when_no_account_is_selected() { assert!( json["reason"] .as_str() - .is_some_and(|value| value.contains("no local account is selected")) + .is_some_and(|value| value.contains("no local accounts found")) ); assert_eq!(json["local"], Value::Null); } @@ -161,7 +162,12 @@ fn signer_status_honors_explicit_account_selector_over_default_account() { let json: Value = serde_json::from_slice(output.stdout.as_slice()).expect("signer json"); assert_eq!(json["mode"], "local"); assert_eq!(json["state"], "ready"); - assert_eq!(json["account_id"], first_id); + assert_eq!(json["signer_account_id"], first_id); + assert_eq!(json["account_resolution"]["source"], "invocation_override"); + assert_eq!( + json["account_resolution"]["resolved_account"]["id"], + first_id + ); assert_eq!(json["local"]["account_id"], first_id); assert_eq!(json["local"]["backend"], "encrypted_file"); assert_eq!(json["local"]["used_fallback"], true); diff --git a/tests/workflow.rs b/tests/workflow.rs @@ -102,7 +102,7 @@ fn status_is_unconfigured_before_setup() { assert_eq!( json["needs_attention"], serde_json::json!([ - "Selected account", + "Resolved account", "Local market data", "Relay configuration" ]) @@ -132,7 +132,7 @@ fn status_calls_out_missing_relay_after_buyer_setup() { assert_eq!(json["state"], "unconfigured"); assert_eq!( json["ready"], - serde_json::json!(["Selected account", "Local market data"]) + serde_json::json!(["Resolved account", "Local market data"]) ); assert_eq!( json["needs_attention"], @@ -190,7 +190,8 @@ fn status_reports_farm_publish_need_when_core_state_is_ready() { let stdout = String::from_utf8(output.stdout).expect("utf8 stdout"); assert!(stdout.contains("Status")); assert!(stdout.contains("Ready")); - assert!(stdout.contains("Selected account")); + assert!(stdout.contains("Resolved account")); + assert!(stdout.contains("Account resolution")); assert!(stdout.contains("Local market data")); assert!(stdout.contains("Relay configuration")); assert!(stdout.contains("Needs attention"));