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:
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"),